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:
Henit Chobisa 2023-11-20 21:31:12 +05:30 committed by sriram veeraghanta
parent 2a2e504ebb
commit 4416419c9b
68 changed files with 2800 additions and 2173 deletions

View file

@ -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");

View file

@ -0,0 +1 @@
# Document Editor

View 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"
]
}

View 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: {},
},
};

View file

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

View file

@ -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>
)
}

View file

@ -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>
);

View file

@ -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>
)
}

View file

@ -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>
);

View file

@ -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>
)
}

View file

@ -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>
);
};

View file

@ -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>
)
}

View file

@ -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>
)
}

View 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,
}),
];

View file

@ -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;

View file

@ -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,
}
}

View 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 }

View 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>
);
};

View 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>
);

View file

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

View 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 }

View 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 })
}
/>
);
};

View file

@ -0,0 +1,8 @@
export interface DocumentDetails {
title: string;
created_by: string;
created_on: Date;
last_updated_by: string;
last_updated_at: Date;
}

View 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>
}

View file

@ -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)
}
}

View 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())
}
}

View 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
}

View 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,
};

View file

@ -0,0 +1,5 @@
{
"extends": "tsconfig/react-library.json",
"include": ["src/**/*", "index.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View 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,
}));