feat: New Pages with Enhanced Document Editor Packages made over Editor Core 📝 (#2784)
* fix: page transaction model * fix: page transaction model * feat: updated ui for page route * chore: initailized `document-editor` package for plane * fix: format persistence while pasting markdown in editor * feat: Inititalized Document-Editor and Editor with Ref * feat: added tooltip component and slash command for editor * feat: added `document-editor` extensions * feat: added custom search component for embedding labels * feat: added top bar menu component * feat: created document-editor exposed components * feat: integrated `document-editor` in `pages` route * chore: updated dependencies * feat: merge conflict resolution * chore: modified configuration for document editor * feat: added content browser menu for document editor summary * feat: added fixed menu and editor instances * feat: added document edittor instances and summary table * feat: implemented document-editor in PageDetail * chore: css and export fixes * fix: migration and optimisation * fix: added `on_create` hook in the core editor * feat: added conditional menu bar action in document-editor * feat: added menu actions from single page view * feat: added services for archiving, unarchiving and retriving archived pages * feat: added services for page archives * feat: implemented page archives in page list view * feat: implemented page archives in document-editor * feat: added editor marking hook * chore: seperated editor header from the main content * chore: seperated editor summary utilities from the main editor * chore: refactored necessary components from the document editor * chore: removed summary sidebar component from the main content editor * chore: removed scrollSummaryDependency from Header and Sidebar * feat: seperated page renderer as a seperate component * chore: seperated page_renderer and sidebar as component from index * feat: added locked property to IPage type * feat: added lock/unlock services in page service * chore: seperated DocumentDetails as exported interface from index * feat: seperated document editor configs as seperate interfaces * chore: seperated menu options from the editor header component * fix: fixed page_lock performing lock/unlock operation on queryset instead of single instance * fix: css positioning changes * feat: added archive/lock alert labels * feat: added boolean props in menu-actions/options * feat: added lock/unlock & archive/unarchive services * feat: added on update mutations for archived pages in page-view * feat: added archive/lock on_update mutations in single page vieq * feat: exported readonly editor for locked pages * chore: seperated kanban menu props and saved over passing redundant data * fix: readonly editor not generating markings on first render * fix: cheveron overflowing from editor-header * chore: removed unused utility actions * fix: enabled sidebar view by default * feat: removed locking on pages in archived state * feat: added indentation in heading component * fix: button classnames in vertical dropdowns * feat: added `last_archived_at` and `last_edited_at` details in editor-header * feat: changed types for archived updates and document last updates * feat: updated editor and header props * feat: updated queryset according to new page query format * feat: added parameters in page view for shared / private pages * feat: updated other-page-view to shared page view && same with private pages * feat: added page-view as shared / private * fix: replaced deleting to archiving for pages * feat: handle restoring of page from archived section from list view * feat: made previledge based option render for pages * feat: removed layout view for page list view * feat: linting changes * fix: adding mobx changes to pages * fix: removed uneccessary migrations * fix: mobx store changes * fix: adding date-fns pacakge * fix: updating yarn lock * fix: removing unneccessary method params * chore: added access specifier to the create/update page modal * fix: tab view layout changes * chore: delete endpoint for page * fix: page actions, including- archive, favorite, access control, delete * chore: remove archive page modal * fix: build errors --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com> Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
parent
2a2e504ebb
commit
4416419c9b
68 changed files with 2800 additions and 2173 deletions
|
|
@ -18,6 +18,7 @@ interface CustomEditorProps {
|
|||
value: string;
|
||||
deleteFile: DeleteImage;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
onStart?: (json: any, html: string) => void;
|
||||
onChange?: (json: any, html: string) => void;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
|
|
@ -34,6 +35,7 @@ export const useEditor = ({
|
|||
editorProps = {},
|
||||
value,
|
||||
extensions = [],
|
||||
onStart,
|
||||
onChange,
|
||||
setIsSubmitting,
|
||||
forwardedRef,
|
||||
|
|
@ -60,6 +62,9 @@ export const useEditor = ({
|
|||
],
|
||||
content:
|
||||
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
||||
onCreate: async ({ editor }) => {
|
||||
onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()))
|
||||
},
|
||||
onUpdate: async ({ editor }) => {
|
||||
// for instant feedback loop
|
||||
setIsSubmitting?.("submitting");
|
||||
|
|
|
|||
1
packages/editor/document-editor/Readme.md
Normal file
1
packages/editor/document-editor/Readme.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Document Editor
|
||||
73
packages/editor/document-editor/package.json
Normal file
73
packages/editor/document-editor/package.json
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
{
|
||||
"name": "@plane/document-editor",
|
||||
"version": "0.0.1",
|
||||
"description": "Package that powers Plane's Pages Editor",
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "12.3.2",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@plane/ui": "*",
|
||||
"@plane/editor-core": "*",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@tiptap/core": "^2.1.7",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.1.11",
|
||||
"@tiptap/extension-horizontal-rule": "^2.1.11",
|
||||
"@tiptap/extension-list-item": "^2.1.11",
|
||||
"@tiptap/extension-placeholder": "^2.1.11",
|
||||
"@tiptap/suggestion": "^2.1.7",
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.5",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^1.2.1",
|
||||
"eslint": "8.36.0",
|
||||
"eslint-config-next": "13.2.4",
|
||||
"eventsource-parser": "^0.1.0",
|
||||
"highlight.js": "^11.8.0",
|
||||
"lowlight": "^3.0.0",
|
||||
"lucide-react": "^0.244.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-popper": "^2.3.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.2",
|
||||
"use-debounce": "^9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^7.32.0",
|
||||
"postcss": "^8.4.29",
|
||||
"tailwind-config-custom": "*",
|
||||
"tsconfig": "*",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"keywords": [
|
||||
"editor",
|
||||
"rich-text",
|
||||
"markdown",
|
||||
"nextjs",
|
||||
"react"
|
||||
]
|
||||
}
|
||||
9
packages/editor/document-editor/postcss.config.js
Normal file
9
packages/editor/document-editor/postcss.config.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// If you want to use other PostCSS plugins, see the following:
|
||||
// https://tailwindcss.com/docs/using-with-preprocessors
|
||||
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
3
packages/editor/document-editor/src/index.ts
Normal file
3
packages/editor/document-editor/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { DocumentEditor, DocumentEditorWithRef } from "./ui"
|
||||
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "./ui/readonly"
|
||||
export { FixedMenu } from "./ui/menu/fixed-menu"
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Icon } from "lucide-react"
|
||||
|
||||
interface IAlertLabelProps {
|
||||
Icon: Icon,
|
||||
backgroundColor: string,
|
||||
textColor?: string,
|
||||
label: string,
|
||||
}
|
||||
|
||||
export const AlertLabel = ({ Icon, backgroundColor,textColor, label }: IAlertLabelProps) => {
|
||||
|
||||
return (
|
||||
<div className={`text-xs flex items-center gap-1 ${backgroundColor} p-0.5 pl-3 pr-3 mr-1 rounded`}>
|
||||
<Icon size={12} />
|
||||
<span className={`normal-case ${textColor}`}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { HeadingComp, SubheadingComp } from "./heading-component";
|
||||
import { IMarking } from "..";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { scrollSummary } from "../utils/editor-summary-utils";
|
||||
|
||||
interface ContentBrowserProps {
|
||||
editor: Editor;
|
||||
markings: IMarking[];
|
||||
}
|
||||
|
||||
export const ContentBrowser = ({
|
||||
editor,
|
||||
markings,
|
||||
}: ContentBrowserProps) => (
|
||||
<div className="mt-4 flex w-[250px] flex-col h-full">
|
||||
<h2 className="ml-4 border-b border-solid border-custom-border py-5 font-medium leading-[85.714%] tracking-tight max-md:ml-2.5">
|
||||
Table of Contents
|
||||
</h2>
|
||||
<div className="mt-3 h-0.5 w-full self-stretch border-custom-border" />
|
||||
{markings.length !== 0 ? (
|
||||
markings.map((marking) =>
|
||||
marking.level === 1 ? (
|
||||
<HeadingComp
|
||||
onClick={() => scrollSummary(editor, marking)}
|
||||
heading={marking.text}
|
||||
/>
|
||||
) : (
|
||||
<SubheadingComp
|
||||
onClick={() => scrollSummary(editor, marking)}
|
||||
subHeading={marking.text}
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<p className="ml-3 mr-3 flex h-full items-center px-5 text-center text-xs text-gray-500">
|
||||
{"Headings will be displayed here for Navigation"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { Editor } from "@tiptap/react"
|
||||
import { Lock, ArchiveIcon, MenuSquare } from "lucide-react"
|
||||
import { useRef, useState } from "react"
|
||||
import { usePopper } from "react-popper"
|
||||
import { IMarking, UploadImage } from ".."
|
||||
import { FixedMenu } from "../menu"
|
||||
import { DocumentDetails } from "../types/editor-types"
|
||||
import { AlertLabel } from "./alert-label"
|
||||
import { ContentBrowser } from "./content-browser"
|
||||
import { IVerticalDropdownItemProps, VerticalDropdownMenu } from "./vertical-dropdown-menu"
|
||||
|
||||
interface IEditorHeader {
|
||||
editor: Editor,
|
||||
KanbanMenuOptions: IVerticalDropdownItemProps[],
|
||||
sidePeakVisible: boolean,
|
||||
setSidePeakVisible: (currentState: boolean) => void,
|
||||
markings: IMarking[],
|
||||
isLocked: boolean,
|
||||
isArchived: boolean,
|
||||
archivedAt?: Date,
|
||||
readonly: boolean,
|
||||
uploadFile?: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
|
||||
documentDetails: DocumentDetails
|
||||
}
|
||||
|
||||
export const EditorHeader = ({ documentDetails, archivedAt, editor, sidePeakVisible, readonly, setSidePeakVisible, markings, uploadFile, setIsSubmitting, KanbanMenuOptions, isArchived, isLocked }: IEditorHeader) => {
|
||||
|
||||
const summaryMenuRef = useRef(null);
|
||||
const summaryButtonRef = useRef(null);
|
||||
const [summaryPopoverVisible, setSummaryPopoverVisible] = useState(false);
|
||||
|
||||
const { styles: summaryPopoverStyles, attributes: summaryPopoverAttributes } = usePopper(summaryButtonRef.current, summaryMenuRef.current, {
|
||||
placement: "bottom-start"
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
<div className="border-custom-border self-stretch flex flex-col border-b border-solid max-md:max-w-full">
|
||||
<div
|
||||
className="self-center flex ml-0 w-full items-start justify-between gap-5 max-md:max-w-full max-md:flex-wrap max-md:justify-center">
|
||||
<div className={"flex flex-row items-center"}>
|
||||
<div
|
||||
onMouseEnter={() => setSummaryPopoverVisible(true)}
|
||||
onMouseLeave={() => setSummaryPopoverVisible(false)}
|
||||
>
|
||||
<button
|
||||
ref={summaryButtonRef}
|
||||
className={"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors"}
|
||||
onClick={() => {
|
||||
setSidePeakVisible(!sidePeakVisible)
|
||||
setSummaryPopoverVisible(false)
|
||||
}}
|
||||
>
|
||||
<MenuSquare
|
||||
size={20}
|
||||
/>
|
||||
</button>
|
||||
{summaryPopoverVisible &&
|
||||
<div style={summaryPopoverStyles.popper} {...summaryPopoverAttributes.popper} className="z-10 h-[300px] w-[300px] ml-[40px] mt-[40px] shadow-xl rounded border-custom-border border-solid border-2 bg-custom-background-100 border-b pl-3 pr-3 pb-3 overflow-scroll">
|
||||
<ContentBrowser editor={editor} markings={markings} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{isLocked && <AlertLabel Icon={Lock} backgroundColor={"bg-red-200"} label={"Locked"} />}
|
||||
{(isArchived && archivedAt) && <AlertLabel Icon={ArchiveIcon} backgroundColor={"bg-blue-200"} label={`Archived at ${new Date(archivedAt).toLocaleString()}`} />}
|
||||
</div>
|
||||
|
||||
{(!readonly && uploadFile) && <FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />}
|
||||
<div className="self-center flex items-start gap-3 my-auto max-md:justify-center"
|
||||
>
|
||||
{!isArchived && <p className="text-sm text-custom-text-300">{`Last updated at ${new Date(documentDetails.last_updated_at).toLocaleString()}`}</p>}
|
||||
<VerticalDropdownMenu items={KanbanMenuOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
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-bold font-medium leading-[125%] tracking-tight hover:text-custom-primary max-md:ml-2.5"
|
||||
>
|
||||
{heading}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export const SubheadingComp = ({
|
||||
subHeading,
|
||||
onClick,
|
||||
}: {
|
||||
subHeading: string;
|
||||
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
|
||||
}) => (
|
||||
<p
|
||||
onClick={onClick}
|
||||
className="ml-6 mt-2 text-xs cursor-pointer font-medium tracking-tight text-gray-400 hover:text-custom-primary"
|
||||
>
|
||||
{subHeading}
|
||||
</p>
|
||||
);
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core"
|
||||
import { Editor } from "@tiptap/react"
|
||||
import { DocumentDetails } from "../types/editor-types"
|
||||
|
||||
interface IPageRenderer {
|
||||
sidePeakVisible: boolean,
|
||||
documentDetails: DocumentDetails ,
|
||||
editor: Editor,
|
||||
editorClassNames: string,
|
||||
editorContentCustomClassNames?: string
|
||||
}
|
||||
|
||||
export const PageRenderer = ({ sidePeakVisible, documentDetails, editor, editorClassNames, editorContentCustomClassNames }: IPageRenderer) => {
|
||||
return (
|
||||
<div className={`flex h-[88vh] flex-col w-full max-md:w-full max-md:ml-0 transition-all duration-200 ease-in-out ${sidePeakVisible ? 'ml-[3%] ' : 'ml-0'}`}>
|
||||
<div className="items-start mt-4 h-full flex flex-col w-fit max-md:max-w-full overflow-auto">
|
||||
<div className="flex flex-col py-2 max-md:max-w-full">
|
||||
<h1
|
||||
className="border-none outline-none bg-transparent text-4xl font-bold leading-8 tracking-tight self-center w-[700px] max-w-full"
|
||||
>{documentDetails.title}</h1>
|
||||
</div>
|
||||
<div className="border-custom-border border-b border-solid self-stretch w-full h-0.5 mt-3" />
|
||||
<div className="flex flex-col max-md:max-w-full">
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||
</div>
|
||||
</EditorContainer >
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import React, { Fragment, useState } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// icons
|
||||
import { ChevronUp, MenuIcon } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
placement?: Placement;
|
||||
};
|
||||
|
||||
export const SummaryPopover: React.FC<Props> = (props) => {
|
||||
const { children, title = "SummaryPopover", placement } = props;
|
||||
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "auto",
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover as="div">
|
||||
{({ open }) => {
|
||||
if (open) {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Popover.Button as={React.Fragment}>
|
||||
<Button
|
||||
ref={setReferenceElement}
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
>
|
||||
<MenuIcon size={20} />
|
||||
</Button>
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel>
|
||||
<div
|
||||
className="z-10 bg-custom-background-100 border border-custom-border-200 shadow-custom-shadow-rg rounded overflow-hidden"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="w-[18.75rem] max-h-[37.5rem] flex flex-col overflow-hidden">{children}</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Editor } from "@tiptap/react"
|
||||
import { IMarking } from ".."
|
||||
import { ContentBrowser } from "./content-browser"
|
||||
|
||||
interface ISummarySideBarProps {
|
||||
editor: Editor,
|
||||
markings: IMarking[],
|
||||
sidePeakVisible: boolean
|
||||
}
|
||||
|
||||
export const SummarySideBar = ({ editor, markings, sidePeakVisible }: ISummarySideBarProps) => {
|
||||
return (
|
||||
|
||||
<div className={`flex flex-col items-stretch w-[21%] max-md:w-full max-md:ml-0 border-custom-border border-r border-solid transition-all duration-200 ease-in-out transform ${sidePeakVisible ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||
<ContentBrowser editor={editor} markings={markings} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { Button, CustomMenu } from "@plane/ui"
|
||||
import { ChevronUp, Icon, 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: Icon,
|
||||
label: string,
|
||||
action: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export interface IVerticalDropdownMenuProps {
|
||||
items: IVerticalDropdownItemProps[],
|
||||
}
|
||||
|
||||
const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => {
|
||||
|
||||
return (
|
||||
<CustomMenu.MenuItem>
|
||||
<Button variant={"neutral-primary"} onClick={action} className="flex flex-row border-none items-center m-1 max-md:pr-5 cursor-pointer">
|
||||
<Icon size={16} />
|
||||
<div className="text-custom-text-300 ml-2 mr-2 leading-5 tracking-tight whitespace-nowrap self-start text-md">
|
||||
{label}
|
||||
</div>
|
||||
</Button>
|
||||
</CustomMenu.MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => {
|
||||
|
||||
return (
|
||||
<CustomMenu maxHeight={"lg"} className={"h-4"} placement={"bottom-start"} optionsClassName={"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "} customButton={
|
||||
<MoreVertical size={18}/>
|
||||
}>
|
||||
{items.map((item, index) => (
|
||||
<VerticalDropdownItem
|
||||
key={index}
|
||||
type={item.type}
|
||||
Icon={item.Icon}
|
||||
label={item.label}
|
||||
action={item.action}
|
||||
/>
|
||||
))}
|
||||
</CustomMenu>
|
||||
)
|
||||
}
|
||||
59
packages/editor/document-editor/src/ui/extensions/index.tsx
Normal file
59
packages/editor/document-editor/src/ui/extensions/index.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import { common, createLowlight } from 'lowlight'
|
||||
import { InputRule } from "@tiptap/core";
|
||||
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
|
||||
import SlashCommand from "./slash-command";
|
||||
import { UploadImage } from "../";
|
||||
|
||||
const lowlight = createLowlight(common)
|
||||
lowlight.register("ts", ts);
|
||||
|
||||
export const DocumentEditorExtensions = (
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||
) => [
|
||||
HorizontalRule.extend({
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
|
||||
handler: ({ state, range, commands }) => {
|
||||
commands.splitBlock();
|
||||
|
||||
const attributes = {};
|
||||
const { tr } = state;
|
||||
const start = range.from;
|
||||
const end = range.to;
|
||||
// @ts-ignore
|
||||
tr.replaceWith(start - 1, end, this.type.create(attributes));
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
}).configure({
|
||||
HTMLAttributes: {
|
||||
class: "mb-6 border-t border-custom-border-300",
|
||||
},
|
||||
}),
|
||||
SlashCommand(uploadFile, setIsSubmitting),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
if (node.type.name === "image" || node.type.name === "table") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "Press '/' for commands...";
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
];
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
|
||||
import { Editor, Range, Extension } from "@tiptap/core";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import tippy from "tippy.js";
|
||||
import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
Text,
|
||||
TextQuote,
|
||||
Code,
|
||||
MinusSquare,
|
||||
CheckSquare,
|
||||
ImageIcon,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
import { UploadImage } from "../";
|
||||
import { cn, insertTableCommand, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, insertImageCommand, toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree } from "@plane/editor-core";
|
||||
|
||||
interface CommandItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
interface CommandProps {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
const Command = Extension.create({
|
||||
name: "slash-command",
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
allow({ editor }) {
|
||||
return !editor.isActive("table");
|
||||
},
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const getSuggestionItems =
|
||||
(
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||
) =>
|
||||
({ query }: { query: string }) =>
|
||||
[
|
||||
{
|
||||
title: "Text",
|
||||
description: "Just start typing with plain text.",
|
||||
searchTerms: ["p", "paragraph"],
|
||||
icon: <Text size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
searchTerms: ["title", "big", "large"],
|
||||
icon: <Heading1 size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingOne(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
searchTerms: ["subtitle", "medium"],
|
||||
icon: <Heading2 size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingTwo(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading3 size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingThree(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "To-do List",
|
||||
description: "Track tasks with a to-do list.",
|
||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||
icon: <CheckSquare size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleTaskList(editor, range)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Bullet List",
|
||||
description: "Create a simple bullet list.",
|
||||
searchTerms: ["unordered", "point"],
|
||||
icon: <List size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleBulletList(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Divider",
|
||||
description: "Visually divide blocks",
|
||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||
icon: <MinusSquare size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Table",
|
||||
description: "Create a Table",
|
||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||
icon: <Table size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertTableCommand(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Numbered List",
|
||||
description: "Create a list with numbering.",
|
||||
searchTerms: ["ordered"],
|
||||
icon: <ListOrdered size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleOrderedList(editor, range)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Quote",
|
||||
description: "Capture a quote.",
|
||||
searchTerms: ["blockquote"],
|
||||
icon: <TextQuote size={18} />,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
toggleBlockquote(editor, range)
|
||||
},
|
||||
{
|
||||
title: "Code",
|
||||
description: "Capture a code snippet.",
|
||||
searchTerms: ["codeblock"],
|
||||
icon: <Code size={18} />,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
title: "Image",
|
||||
description: "Upload an image from your computer.",
|
||||
searchTerms: ["photo", "picture", "media"],
|
||||
icon: <ImageIcon size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
|
||||
},
|
||||
},
|
||||
].filter((item) => {
|
||||
if (typeof query === "string" && query.length > 0) {
|
||||
const search = query.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(search) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||
const containerHeight = container.offsetHeight;
|
||||
const itemHeight = item ? item.offsetHeight : 0;
|
||||
|
||||
const top = item.offsetTop;
|
||||
const bottom = top + itemHeight;
|
||||
|
||||
if (top < container.scrollTop) {
|
||||
container.scrollTop -= container.scrollTop - top + 5;
|
||||
} else if (bottom > containerHeight + container.scrollTop) {
|
||||
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
|
||||
}
|
||||
};
|
||||
|
||||
const CommandList = ({
|
||||
items,
|
||||
command,
|
||||
}: {
|
||||
items: CommandItemProps[];
|
||||
command: any;
|
||||
editor: any;
|
||||
range: any;
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
if (item) {
|
||||
command(item);
|
||||
}
|
||||
},
|
||||
[command, items]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (navigationKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
if (e.key === "ArrowUp") {
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [items, selectedIndex, setSelectedIndex, selectItem]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer?.current;
|
||||
|
||||
const item = container?.children[selectedIndex] as HTMLElement;
|
||||
|
||||
if (item && container) updateScrollView(container, item);
|
||||
}, [selectedIndex]);
|
||||
|
||||
return items.length > 0 ? (
|
||||
<div
|
||||
id="slash-command"
|
||||
ref={commandListContainer}
|
||||
className="z-50 fixed h-auto max-h-[330px] w-72 overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-100 px-1 py-2 shadow-md transition-all"
|
||||
>
|
||||
{items.map((item: CommandItemProps, index: number) => (
|
||||
<button
|
||||
className={cn(
|
||||
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
|
||||
{ "bg-custom-primary-100/5 text-custom-text-100": index === selectedIndex }
|
||||
)}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-xs text-custom-text-300">{item.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component = new ReactRenderer(CommandList, {
|
||||
props,
|
||||
// @ts-ignore
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.querySelector("#editor-container"),
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
popup?.[0].destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const SlashCommand = (
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||
) =>
|
||||
Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems(uploadFile, setIsSubmitting),
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
|
||||
export default SlashCommand;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { Editor } from "@tiptap/react";
|
||||
import { useState } from "react";
|
||||
import { IMarking } from "..";
|
||||
|
||||
export const useEditorMarkings = () => {
|
||||
|
||||
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
|
||||
if (nodes) {
|
||||
nodes.forEach((node) => {
|
||||
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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
setMarkings(tempMarkings)
|
||||
}
|
||||
|
||||
return {
|
||||
updateMarkings,
|
||||
markings,
|
||||
}
|
||||
}
|
||||
151
packages/editor/document-editor/src/ui/index.tsx
Normal file
151
packages/editor/document-editor/src/ui/index.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"use client"
|
||||
import React, { useState } from 'react';
|
||||
import { cn, getEditorClassNames, useEditor } from '@plane/editor-core';
|
||||
import { DocumentEditorExtensions } from './extensions';
|
||||
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from './types/menu-actions';
|
||||
import { EditorHeader } from './components/editor-header';
|
||||
import { useEditorMarkings } from './hooks/use-editor-markings';
|
||||
import { SummarySideBar } from './components/summary-side-bar';
|
||||
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>;
|
||||
|
||||
interface IDocumentEditor {
|
||||
documentDetails: DocumentDetails,
|
||||
value: string;
|
||||
uploadFile: UploadImage;
|
||||
deleteFile: DeleteImage;
|
||||
customClassName?: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
onChange: (json: any, html: string) => void;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
forwardedRef?: any;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
duplicationConfig?: IDuplicationConfig,
|
||||
pageLockConfig?: IPageLockConfig,
|
||||
pageArchiveConfig?: IPageArchiveConfig
|
||||
}
|
||||
interface DocumentEditorProps extends IDocumentEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
}
|
||||
|
||||
export interface IMarking {
|
||||
type: "heading",
|
||||
level: number,
|
||||
text: string,
|
||||
sequence: number
|
||||
}
|
||||
|
||||
const DocumentEditor = ({
|
||||
documentDetails,
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
editorContentCustomClassNames,
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
customClassName,
|
||||
forwardedRef,
|
||||
duplicationConfig,
|
||||
pageLockConfig,
|
||||
pageArchiveConfig
|
||||
}: IDocumentEditor) => {
|
||||
|
||||
// const [alert, setAlert] = useState<string>("")
|
||||
const { markings, updateMarkings } = useEditorMarkings()
|
||||
const [sidePeakVisible, setSidePeakVisible] = useState(true)
|
||||
const router = useRouter()
|
||||
|
||||
const editor = useEditor({
|
||||
onChange(json, html) {
|
||||
updateMarkings(json)
|
||||
onChange(json, html)
|
||||
},
|
||||
onStart(json) {
|
||||
updateMarkings(json)
|
||||
},
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
forwardedRef,
|
||||
extensions: DocumentEditorExtensions(uploadFile, setIsSubmitting),
|
||||
});
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
const KanbanMenuOptions = getMenuOptions(
|
||||
{
|
||||
editor: editor,
|
||||
router: router,
|
||||
duplicationConfig: duplicationConfig,
|
||||
pageLockConfig: pageLockConfig,
|
||||
pageArchiveConfig: pageArchiveConfig,
|
||||
}
|
||||
)
|
||||
const editorClassNames = getEditorClassNames({ noBorder: true, borderOnFocus: false, customClassName });
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="top-0 sticky z-10 bg-custom-background-100">
|
||||
<EditorHeader
|
||||
readonly={false}
|
||||
KanbanMenuOptions={KanbanMenuOptions}
|
||||
editor={editor}
|
||||
sidePeakVisible={sidePeakVisible}
|
||||
setSidePeakVisible={setSidePeakVisible}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
<div className="self-center items-stretch w-full max-md:max-w-full h-full">
|
||||
<div className={cn("gap-5 flex max-md:flex-col max-md:items-stretch max-md:gap-0 h-full", { "justify-center": !sidePeakVisible })}>
|
||||
<SummarySideBar
|
||||
editor={editor}
|
||||
markings={markings}
|
||||
sidePeakVisible={sidePeakVisible}
|
||||
/>
|
||||
<PageRenderer
|
||||
editor={editor}
|
||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||
editorClassNames={editorClassNames}
|
||||
sidePeakVisible={sidePeakVisible}
|
||||
documentDetails={documentDetails}
|
||||
/>
|
||||
{/* Page Element */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DocumentEditorWithRef = React.forwardRef<EditorHandle, IDocumentEditor>((props, ref) => (
|
||||
<DocumentEditor {...props} forwardedRef={ref} />
|
||||
));
|
||||
|
||||
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
|
||||
|
||||
export { DocumentEditor, DocumentEditorWithRef }
|
||||
142
packages/editor/document-editor/src/ui/menu/fixed-menu.tsx
Normal file
142
packages/editor/document-editor/src/ui/menu/fixed-menu.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { Editor } from "@tiptap/react";
|
||||
import { BoldIcon, Heading1, Heading2, Heading3 } from "lucide-react";
|
||||
|
||||
import { BoldItem, BulletListItem, cn, CodeItem, ImageItem, ItalicItem, NumberedListItem, QuoteItem, StrikeThroughItem, TableItem, UnderLineItem, HeadingOneItem, HeadingTwoItem, HeadingThreeItem } from "@plane/editor-core";
|
||||
import { UploadImage } from "..";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: typeof BoldIcon;
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
uploadFile: UploadImage;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
}
|
||||
|
||||
export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
||||
const basicMarkItems: BubbleMenuItem[] = [
|
||||
HeadingOneItem(props.editor),
|
||||
HeadingTwoItem(props.editor),
|
||||
HeadingThreeItem(props.editor),
|
||||
BoldItem(props.editor),
|
||||
ItalicItem(props.editor),
|
||||
UnderLineItem(props.editor),
|
||||
StrikeThroughItem(props.editor),
|
||||
];
|
||||
|
||||
const listItems: BubbleMenuItem[] = [
|
||||
BulletListItem(props.editor),
|
||||
NumberedListItem(props.editor),
|
||||
];
|
||||
|
||||
const userActionItems: BubbleMenuItem[] = [
|
||||
QuoteItem(props.editor),
|
||||
CodeItem(props.editor),
|
||||
];
|
||||
|
||||
const complexItems: BubbleMenuItem[] = [
|
||||
TableItem(props.editor),
|
||||
ImageItem(props.editor, props.uploadFile, props.setIsSubmitting),
|
||||
];
|
||||
|
||||
// const handleAccessChange = (accessKey: string) => {
|
||||
// props.commentAccessSpecifier?.onAccessChange(accessKey);
|
||||
// };
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-fit rounded bg-custom-background-100"
|
||||
>
|
||||
<div className="flex">
|
||||
{basicMarkItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||
{
|
||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
size={ item.icon === Heading1 || item.icon === Heading2 || item.icon === Heading3 ? 20 : 15}
|
||||
className={cn({
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{listItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||
{
|
||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{userActionItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||
{
|
||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{complexItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||
{
|
||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
13
packages/editor/document-editor/src/ui/menu/icon.tsx
Normal file
13
packages/editor/document-editor/src/ui/menu/icon.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
iconName: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Icon: React.FC<Props> = ({ iconName, className = "" }) => (
|
||||
<span className={`material-symbols-rounded text-sm leading-5 font-light ${className}`}>
|
||||
{iconName}
|
||||
</span>
|
||||
);
|
||||
|
||||
1
packages/editor/document-editor/src/ui/menu/index.tsx
Normal file
1
packages/editor/document-editor/src/ui/menu/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { FixedMenu } from "./fixed-menu";
|
||||
121
packages/editor/document-editor/src/ui/readonly/index.tsx
Normal file
121
packages/editor/document-editor/src/ui/readonly/index.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { cn, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core"
|
||||
import { useRouter } from "next/router";
|
||||
import { useState, forwardRef, useEffect } from 'react'
|
||||
import { EditorHeader } from "../components/editor-header";
|
||||
import { PageRenderer } from "../components/page-renderer";
|
||||
import { SummarySideBar } from "../components/summary-side-bar";
|
||||
import { useEditorMarkings } from "../hooks/use-editor-markings";
|
||||
import { DocumentDetails } from "../types/editor-types";
|
||||
import { IPageArchiveConfig, IPageLockConfig, IDuplicationConfig } from "../types/menu-actions";
|
||||
import { getMenuOptions } from "../utils/menu-options";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
value: string,
|
||||
noBorder: boolean,
|
||||
borderOnFocus: boolean,
|
||||
customClassName: string,
|
||||
documentDetails: DocumentDetails,
|
||||
pageLockConfig?: IPageLockConfig,
|
||||
pageArchiveConfig?: IPageArchiveConfig,
|
||||
pageDuplicationConfig?: IDuplicationConfig,
|
||||
}
|
||||
|
||||
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,
|
||||
}: DocumentReadOnlyEditorProps) => {
|
||||
|
||||
const router = useRouter()
|
||||
const [sidePeakVisible, setSidePeakVisible] = useState(true)
|
||||
const { markings, updateMarkings } = useEditorMarkings()
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
value,
|
||||
forwardedRef,
|
||||
})
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
updateMarkings(editor.getJSON())
|
||||
}
|
||||
}, [editor?.getJSON()])
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
const editorClassNames = getEditorClassNames({
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName
|
||||
})
|
||||
|
||||
const KanbanMenuOptions = getMenuOptions({
|
||||
editor: editor,
|
||||
router: router,
|
||||
pageArchiveConfig: pageArchiveConfig,
|
||||
pageLockConfig: pageLockConfig,
|
||||
duplicationConfig: pageDuplicationConfig,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="top-0 sticky z-10 bg-custom-background-100">
|
||||
<EditorHeader
|
||||
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
|
||||
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
|
||||
readonly={true}
|
||||
editor={editor}
|
||||
sidePeakVisible={sidePeakVisible}
|
||||
setSidePeakVisible={setSidePeakVisible}
|
||||
KanbanMenuOptions={KanbanMenuOptions}
|
||||
markings={markings}
|
||||
documentDetails={documentDetails}
|
||||
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
|
||||
/>
|
||||
</div>
|
||||
<div className="self-center items-stretch w-full max-md:max-w-full overflow-y-hidden">
|
||||
<div className={cn("gap-5 flex max-md:flex-col max-md:items-stretch max-md:gap-0 overflow-y-hidden", { "justify-center": !sidePeakVisible })}>
|
||||
<SummarySideBar
|
||||
editor={editor}
|
||||
markings={markings}
|
||||
sidePeakVisible={sidePeakVisible}
|
||||
/>
|
||||
<PageRenderer
|
||||
editor={editor}
|
||||
editorClassNames={editorClassNames}
|
||||
sidePeakVisible={sidePeakVisible}
|
||||
documentDetails={documentDetails}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<
|
||||
EditorHandle,
|
||||
IDocumentReadOnlyEditor
|
||||
>((props, ref) => <DocumentReadOnlyEditor {...props} forwardedRef={ref} />);
|
||||
|
||||
DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef";
|
||||
|
||||
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef }
|
||||
77
packages/editor/document-editor/src/ui/tooltip.tsx
Normal file
77
packages/editor/document-editor/src/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import * as React from 'react';
|
||||
|
||||
// next-themes
|
||||
import { useTheme } from "next-themes";
|
||||
// tooltip2
|
||||
import { Tooltip2 } from "@blueprintjs/popover2";
|
||||
|
||||
type Props = {
|
||||
tooltipHeading?: string;
|
||||
tooltipContent: string | React.ReactNode;
|
||||
position?:
|
||||
| "top"
|
||||
| "right"
|
||||
| "bottom"
|
||||
| "left"
|
||||
| "auto"
|
||||
| "auto-end"
|
||||
| "auto-start"
|
||||
| "bottom-left"
|
||||
| "bottom-right"
|
||||
| "left-bottom"
|
||||
| "left-top"
|
||||
| "right-bottom"
|
||||
| "right-top"
|
||||
| "top-left"
|
||||
| "top-right";
|
||||
children: JSX.Element;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
openDelay?: number;
|
||||
closeDelay?: number;
|
||||
};
|
||||
|
||||
export const Tooltip: React.FC<Props> = ({
|
||||
tooltipHeading,
|
||||
tooltipContent,
|
||||
position = "top",
|
||||
children,
|
||||
disabled = false,
|
||||
className = "",
|
||||
openDelay = 200,
|
||||
closeDelay,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<Tooltip2
|
||||
disabled={disabled}
|
||||
hoverOpenDelay={openDelay}
|
||||
hoverCloseDelay={closeDelay}
|
||||
content={
|
||||
<div
|
||||
className={`relative z-50 max-w-xs gap-1 rounded-md p-2 text-xs shadow-md ${
|
||||
theme === "custom"
|
||||
? "bg-custom-background-100 text-custom-text-200"
|
||||
: "bg-black text-gray-400"
|
||||
} break-words overflow-hidden ${className}`}
|
||||
>
|
||||
{tooltipHeading && (
|
||||
<h5
|
||||
className={`font-medium ${
|
||||
theme === "custom" ? "text-custom-text-100" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{tooltipHeading}
|
||||
</h5>
|
||||
)}
|
||||
{tooltipContent}
|
||||
</div>
|
||||
}
|
||||
position={position}
|
||||
renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
|
||||
React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
export interface DocumentDetails {
|
||||
title: string;
|
||||
created_by: string;
|
||||
created_on: Date;
|
||||
last_updated_by: string;
|
||||
last_updated_at: Date;
|
||||
}
|
||||
14
packages/editor/document-editor/src/ui/types/menu-actions.d.ts
vendored
Normal file
14
packages/editor/document-editor/src/ui/types/menu-actions.d.ts
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
|
||||
export interface IDuplicationConfig {
|
||||
action: () => Promise<void>
|
||||
}
|
||||
export interface IPageLockConfig {
|
||||
is_locked: boolean,
|
||||
action: () => Promise<void>
|
||||
locked_by?: string,
|
||||
}
|
||||
export interface IPageArchiveConfig {
|
||||
is_archived: boolean,
|
||||
archived_at?: Date,
|
||||
action: () => Promise<void>
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { Editor } from "@tiptap/react";
|
||||
import { IMarking } from "..";
|
||||
|
||||
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) {
|
||||
count++;
|
||||
if (count === n) {
|
||||
pos = position;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
return pos;
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollSummary(editor: Editor, marking: IMarking) {
|
||||
if (editor) {
|
||||
const pos = findNthH1(editor, marking.sequence, marking.level)
|
||||
scrollToNode(editor, pos)
|
||||
}
|
||||
}
|
||||
|
||||
12
packages/editor/document-editor/src/ui/utils/menu-actions.ts
Normal file
12
packages/editor/document-editor/src/ui/utils/menu-actions.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Editor } from "@tiptap/core"
|
||||
|
||||
export const copyMarkdownToClipboard = (editor: Editor | null) => {
|
||||
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||
navigator.clipboard.writeText(markdownOutput)
|
||||
}
|
||||
|
||||
export const CopyPageLink = () => {
|
||||
if (window){
|
||||
navigator.clipboard.writeText(window.location.toString())
|
||||
}
|
||||
}
|
||||
75
packages/editor/document-editor/src/ui/utils/menu-options.ts
Normal file
75
packages/editor/document-editor/src/ui/utils/menu-options.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { Editor } from "@tiptap/react"
|
||||
import { Archive, ArchiveIcon, ArchiveRestoreIcon, ClipboardIcon, Copy, Link, Lock, Unlock, XCircle } from "lucide-react"
|
||||
import { NextRouter } from "next/router"
|
||||
import { IVerticalDropdownItemProps } from "../components/vertical-dropdown-menu"
|
||||
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "../types/menu-actions"
|
||||
import { copyMarkdownToClipboard, CopyPageLink } from "./menu-actions"
|
||||
|
||||
export interface MenuOptionsProps{
|
||||
editor: Editor,
|
||||
router: NextRouter,
|
||||
duplicationConfig?: IDuplicationConfig,
|
||||
pageLockConfig?: IPageLockConfig ,
|
||||
pageArchiveConfig?: IPageArchiveConfig,
|
||||
}
|
||||
|
||||
export const getMenuOptions = ({ editor, router, duplicationConfig, pageLockConfig, pageArchiveConfig } : MenuOptionsProps) => {
|
||||
|
||||
const KanbanMenuOptions: IVerticalDropdownItemProps[] = [
|
||||
{
|
||||
key: 1,
|
||||
type: "copy_markdown",
|
||||
Icon: ClipboardIcon,
|
||||
action: () => copyMarkdownToClipboard(editor),
|
||||
label: "Copy Markdown"
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
type: "close_page",
|
||||
Icon: XCircle,
|
||||
action: () => router.back(),
|
||||
label: "Close the page"
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
type: "copy_page_link",
|
||||
Icon: Link,
|
||||
action: () => CopyPageLink(),
|
||||
label: "Copy Page Link"
|
||||
},
|
||||
]
|
||||
|
||||
// If duplicateConfig is given, page duplication will be allowed
|
||||
if (duplicationConfig) {
|
||||
KanbanMenuOptions.push({
|
||||
key: KanbanMenuOptions.length++,
|
||||
type: "duplicate_page",
|
||||
Icon: Copy,
|
||||
action: duplicationConfig.action,
|
||||
label: "Make a copy"
|
||||
})
|
||||
}
|
||||
// If Lock Configuration is given then, lock page option will be available in the kanban menu
|
||||
if (pageLockConfig) {
|
||||
KanbanMenuOptions.push({
|
||||
key: KanbanMenuOptions.length++,
|
||||
type: pageLockConfig.is_locked ? "unlock_page" : "lock_page",
|
||||
Icon: pageLockConfig.is_locked ? Unlock : Lock,
|
||||
label: pageLockConfig.is_locked ? "Unlock Page" : "Lock Page",
|
||||
action: pageLockConfig.action
|
||||
})
|
||||
}
|
||||
|
||||
// Archiving will be visible in the menu bar config once the pageArchiveConfig is given.
|
||||
if (pageArchiveConfig) {
|
||||
KanbanMenuOptions.push({
|
||||
key: KanbanMenuOptions.length++,
|
||||
type: pageArchiveConfig.is_archived ? "unarchive_page" : "archive_page",
|
||||
Icon: pageArchiveConfig.is_archived ? ArchiveRestoreIcon : Archive,
|
||||
label: pageArchiveConfig.is_archived ? "Restore Page" : "Archive Page",
|
||||
action: pageArchiveConfig.action,
|
||||
})
|
||||
}
|
||||
|
||||
return KanbanMenuOptions
|
||||
}
|
||||
6
packages/editor/document-editor/tailwind.config.js
Normal file
6
packages/editor/document-editor/tailwind.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
|
||||
|
||||
module.exports = {
|
||||
// prefix ui lib classes to avoid conflicting with the app
|
||||
...sharedConfig,
|
||||
};
|
||||
5
packages/editor/document-editor/tsconfig.json
Normal file
5
packages/editor/document-editor/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "tsconfig/react-library.json",
|
||||
"include": ["src/**/*", "index.d.ts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
11
packages/editor/document-editor/tsup.config.ts
Normal file
11
packages/editor/document-editor/tsup.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig, Options } from "tsup";
|
||||
|
||||
export default defineConfig((options: Options) => ({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: false,
|
||||
external: ["react"],
|
||||
injectStyle: true,
|
||||
...options,
|
||||
}));
|
||||
Loading…
Add table
Add a link
Reference in a new issue