diff --git a/apiserver/plane/app/views/page.py b/apiserver/plane/app/views/page.py
index b218b6687..01a0ffe98 100644
--- a/apiserver/plane/app/views/page.py
+++ b/apiserver/plane/app/views/page.py
@@ -136,7 +136,9 @@ class PageViewSet(BaseViewSet):
)
def lock(self, request, slug, project_id, page_id):
- page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
+ page = Page.objects.filter(
+ pk=page_id, workspace__slug=slug, project_id=project_id
+ ).first()
# only the owner can lock the page
if request.user.id != page.owned_by_id:
@@ -149,7 +151,9 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
def unlock(self, request, slug, project_id, page_id):
- page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
+ page = Page.objects.filter(
+ pk=page_id, workspace__slug=slug, project_id=project_id
+ ).first()
# only the owner can unlock the page
if request.user.id != page.owned_by_id:
@@ -164,66 +168,8 @@ class PageViewSet(BaseViewSet):
def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True)
- page_view = request.GET.get("page_view", False)
-
- if not page_view:
- return Response(
- {"error": "Page View parameter is required"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # All Pages
- if page_view == "all":
- return Response(
- PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
- )
-
- # Recent pages
- if page_view == "recent":
- current_time = date.today()
- day_before = current_time - timedelta(days=1)
- todays_pages = queryset.filter(updated_at__date=date.today())
- yesterdays_pages = queryset.filter(updated_at__date=day_before)
- earlier_this_week = queryset.filter(
- updated_at__date__range=(
- (timezone.now() - timedelta(days=7)),
- (timezone.now() - timedelta(days=2)),
- )
- )
- return Response(
- {
- "today": PageSerializer(todays_pages, many=True).data,
- "yesterday": PageSerializer(yesterdays_pages, many=True).data,
- "earlier_this_week": PageSerializer(
- earlier_this_week, many=True
- ).data,
- },
- status=status.HTTP_200_OK,
- )
-
- # Favorite Pages
- if page_view == "favorite":
- queryset = queryset.filter(is_favorite=True)
- return Response(
- PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
- )
-
- # My pages
- if page_view == "created_by_me":
- queryset = queryset.filter(owned_by=request.user)
- return Response(
- PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
- )
-
- # Created by other Pages
- if page_view == "created_by_other":
- queryset = queryset.filter(~Q(owned_by=request.user), access=0)
- return Response(
- PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
- )
-
return Response(
- {"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST
+ PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
def archive(self, request, slug, project_id, page_id):
@@ -247,29 +193,44 @@ class PageViewSet(BaseViewSet):
{"error": "Only the owner of the page can unarchive a page"},
status=status.HTTP_400_BAD_REQUEST,
)
-
+
# if parent page is archived then the page will be un archived breaking the hierarchy
if page.parent_id and page.parent.archived_at:
page.parent = None
- page.save(update_fields=['parent'])
+ page.save(update_fields=["parent"])
unarchive_archive_page_and_descendants(page_id, None)
return Response(status=status.HTTP_204_NO_CONTENT)
def archive_list(self, request, slug, project_id):
- pages = (
- Page.objects.filter(
- project_id=project_id,
- workspace__slug=slug,
- )
- .filter(archived_at__isnull=False)
- )
+ pages = Page.objects.filter(
+ project_id=project_id,
+ workspace__slug=slug,
+ ).filter(archived_at__isnull=False)
return Response(
PageSerializer(pages, many=True).data, status=status.HTTP_200_OK
)
+ def destroy(self, request, slug, project_id, pk):
+ page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
+
+ if page.archived_at is None:
+ return Response(
+ {"error": "The page should be archived before deleting"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # remove parent from all the children
+ _ = Page.objects.filter(
+ parent_id=pk, project_id=project_id, workspace__slug=slug
+ ).update(parent=None)
+
+
+ page.delete()
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
class PageFavoriteViewSet(BaseViewSet):
permission_classes = [
@@ -306,6 +267,7 @@ class PageFavoriteViewSet(BaseViewSet):
page_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+
class PageLogEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
@@ -397,4 +359,4 @@ class SubPagesEndpoint(BaseAPIView):
)
return Response(
SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK
- )
\ No newline at end of file
+ )
diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py
index d9e1e8ef2..6a09b08ba 100644
--- a/apiserver/plane/bgtasks/issue_automation_task.py
+++ b/apiserver/plane/bgtasks/issue_automation_task.py
@@ -12,7 +12,7 @@ from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
-from plane.db.models import Issue, Project, State, Page
+from plane.db.models import Issue, Project, State
from plane.bgtasks.issue_activites_task import issue_activity
@@ -20,7 +20,6 @@ from plane.bgtasks.issue_activites_task import issue_activity
def archive_and_close_old_issues():
archive_old_issues()
close_old_issues()
- delete_archived_pages()
def archive_old_issues():
@@ -166,21 +165,4 @@ def close_old_issues():
if settings.DEBUG:
print(e)
capture_exception(e)
- return
-
-
-def delete_archived_pages():
- try:
- pages_to_delete = Page.objects.filter(
- archived_at__isnull=False,
- archived_at__lte=(timezone.now() - timedelta(days=30)),
- )
-
- pages_to_delete._raw_delete(pages_to_delete.db)
- return
- except Exception as e:
- if settings.DEBUG:
- print(e)
- capture_exception(e)
- return
-
+ return
\ No newline at end of file
diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py
index da415058d..81eef5cae 100644
--- a/apiserver/plane/db/models/issue.py
+++ b/apiserver/plane/db/models/issue.py
@@ -133,6 +133,7 @@ class Issue(ProjectBaseModel):
except ImportError:
pass
+
if self._state.adding:
# Get the maximum display_id value from the database
last_id = IssueSequence.objects.filter(project=self.project).aggregate(
diff --git a/packages/editor/core/src/ui/hooks/useEditor.tsx b/packages/editor/core/src/ui/hooks/useEditor.tsx
index 966f9227d..51bf725ec 100644
--- a/packages/editor/core/src/ui/hooks/useEditor.tsx
+++ b/packages/editor/core/src/ui/hooks/useEditor.tsx
@@ -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 : "
",
+ onCreate: async ({ editor }) => {
+ onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()))
+ },
onUpdate: async ({ editor }) => {
// for instant feedback loop
setIsSubmitting?.("submitting");
diff --git a/packages/editor/document-editor/Readme.md b/packages/editor/document-editor/Readme.md
new file mode 100644
index 000000000..edbda8ea3
--- /dev/null
+++ b/packages/editor/document-editor/Readme.md
@@ -0,0 +1 @@
+# Document Editor
\ No newline at end of file
diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json
new file mode 100644
index 000000000..5f82a35f7
--- /dev/null
+++ b/packages/editor/document-editor/package.json
@@ -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"
+ ]
+}
diff --git a/packages/editor/document-editor/postcss.config.js b/packages/editor/document-editor/postcss.config.js
new file mode 100644
index 000000000..419fe25d1
--- /dev/null
+++ b/packages/editor/document-editor/postcss.config.js
@@ -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: {},
+ },
+ };
\ No newline at end of file
diff --git a/packages/editor/document-editor/src/index.ts b/packages/editor/document-editor/src/index.ts
new file mode 100644
index 000000000..53ac942e4
--- /dev/null
+++ b/packages/editor/document-editor/src/index.ts
@@ -0,0 +1,3 @@
+export { DocumentEditor, DocumentEditorWithRef } from "./ui"
+export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "./ui/readonly"
+export { FixedMenu } from "./ui/menu/fixed-menu"
diff --git a/packages/editor/document-editor/src/ui/components/alert-label.tsx b/packages/editor/document-editor/src/ui/components/alert-label.tsx
new file mode 100644
index 000000000..7246647bc
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/components/alert-label.tsx
@@ -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 (
+
+
+ {label}
+
+ )
+
+}
diff --git a/packages/editor/document-editor/src/ui/components/content-browser.tsx b/packages/editor/document-editor/src/ui/components/content-browser.tsx
new file mode 100644
index 000000000..755d67b2d
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/components/content-browser.tsx
@@ -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) => (
+
+
+ Table of Contents
+
+
+ {markings.length !== 0 ? (
+ markings.map((marking) =>
+ marking.level === 1 ? (
+
scrollSummary(editor, marking)}
+ heading={marking.text}
+ />
+ ) : (
+ scrollSummary(editor, marking)}
+ subHeading={marking.text}
+ />
+ )
+ )
+ ) : (
+
+ {"Headings will be displayed here for Navigation"}
+
+ )}
+
+);
diff --git a/packages/editor/document-editor/src/ui/components/editor-header.tsx b/packages/editor/document-editor/src/ui/components/editor-header.tsx
new file mode 100644
index 000000000..32ebe43c9
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/components/editor-header.tsx
@@ -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 (
+
+
+
+
+
setSummaryPopoverVisible(true)}
+ onMouseLeave={() => setSummaryPopoverVisible(false)}
+ >
+
+ {summaryPopoverVisible &&
+
+
+
+ }
+
+ {isLocked &&
}
+ {(isArchived && archivedAt) &&
}
+
+
+ {(!readonly && uploadFile) &&
}
+
+ {!isArchived &&
{`Last updated at ${new Date(documentDetails.last_updated_at).toLocaleString()}`}
}
+
+
+
+
+ )
+
+}
diff --git a/packages/editor/document-editor/src/ui/components/heading-component.tsx b/packages/editor/document-editor/src/ui/components/heading-component.tsx
new file mode 100644
index 000000000..629d3b427
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/components/heading-component.tsx
@@ -0,0 +1,29 @@
+export const HeadingComp = ({
+ heading,
+ onClick,
+}: {
+ heading: string;
+ onClick: (event: React.MouseEvent) => void;
+}) => (
+
+ {heading}
+
+);
+
+export const SubheadingComp = ({
+ subHeading,
+ onClick,
+}: {
+ subHeading: string;
+ onClick: (event: React.MouseEvent) => void;
+}) => (
+
+ {subHeading}
+
+);
diff --git a/packages/editor/document-editor/src/ui/components/page-renderer.tsx b/packages/editor/document-editor/src/ui/components/page-renderer.tsx
new file mode 100644
index 000000000..aca50f3ff
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx
@@ -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 (
+
+
+
+
{documentDetails.title}
+
+
+
+
+
+ )
+}
diff --git a/packages/editor/document-editor/src/ui/components/popover.tsx b/packages/editor/document-editor/src/ui/components/popover.tsx
new file mode 100644
index 000000000..8c587b603
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/components/popover.tsx
@@ -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) => {
+ const { children, title = "SummaryPopover", placement } = props;
+
+ const [referenceElement, setReferenceElement] = useState(null);
+ const [popperElement, setPopperElement] = useState(null);
+
+ const { styles, attributes } = usePopper(referenceElement, popperElement, {
+ placement: placement ?? "auto",
+ });
+
+ return (
+
+ {({ open }) => {
+ if (open) {
+ }
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+ }}
+
+ );
+};
diff --git a/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx b/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx
new file mode 100644
index 000000000..304c80018
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx
@@ -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 (
+
+
+
+
+ )
+}
diff --git a/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx b/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx
new file mode 100644
index 000000000..c28cb4d32
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx
@@ -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
+}
+
+export interface IVerticalDropdownMenuProps {
+ items: IVerticalDropdownItemProps[],
+}
+
+const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => {
+
+ return (
+
+
+
+ )
+}
+
+export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => {
+
+ return (
+
+ }>
+ {items.map((item, index) => (
+
+ ))}
+
+ )
+}
diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx
new file mode 100644
index 000000000..cf5dc9e71
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/extensions/index.tsx
@@ -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,
+ }),
+ ];
diff --git a/packages/editor/document-editor/src/ui/extensions/slash-command.tsx b/packages/editor/document-editor/src/ui/extensions/slash-command.tsx
new file mode 100644
index 000000000..e00585dd8
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/extensions/slash-command.tsx
@@ -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: ,
+ 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: ,
+ command: ({ editor, range }: CommandProps) => {
+ toggleHeadingOne(editor, range);
+ },
+ },
+ {
+ title: "Heading 2",
+ description: "Medium section heading.",
+ searchTerms: ["subtitle", "medium"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) => {
+ toggleHeadingTwo(editor, range);
+ },
+ },
+ {
+ title: "Heading 3",
+ description: "Small section heading.",
+ searchTerms: ["subtitle", "small"],
+ icon: ,
+ 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: ,
+ command: ({ editor, range }: CommandProps) => {
+ toggleTaskList(editor, range)
+ },
+ },
+ {
+ title: "Bullet List",
+ description: "Create a simple bullet list.",
+ searchTerms: ["unordered", "point"],
+ icon:
,
+ command: ({ editor, range }: CommandProps) => {
+ toggleBulletList(editor, range);
+ },
+ },
+ {
+ title: "Divider",
+ description: "Visually divide blocks",
+ searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) => {
+ editor.chain().focus().deleteRange(range).setHorizontalRule().run();
+ },
+ },
+ {
+ title: "Table",
+ description: "Create a Table",
+ searchTerms: ["table", "cell", "db", "data", "tabular"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) => {
+ insertTableCommand(editor, range);
+ },
+ },
+ {
+ title: "Numbered List",
+ description: "Create a list with numbering.",
+ searchTerms: ["ordered"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) => {
+ toggleOrderedList(editor, range)
+ },
+ },
+ {
+ title: "Quote",
+ description: "Capture a quote.",
+ searchTerms: ["blockquote"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) =>
+ toggleBlockquote(editor, range)
+ },
+ {
+ title: "Code",
+ description: "Capture a code snippet.",
+ searchTerms: ["codeblock"],
+ icon:
,
+ 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: ,
+ 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(null);
+
+ useLayoutEffect(() => {
+ const container = commandListContainer?.current;
+
+ const item = container?.children[selectedIndex] as HTMLElement;
+
+ if (item && container) updateScrollView(container, item);
+ }, [selectedIndex]);
+
+ return items.length > 0 ? (
+
+ {items.map((item: CommandItemProps, index: number) => (
+
+ ))}
+
+ ) : 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;
diff --git a/packages/editor/document-editor/src/ui/hooks/use-editor-markings.tsx b/packages/editor/document-editor/src/ui/hooks/use-editor-markings.tsx
new file mode 100644
index 000000000..96bd40101
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/hooks/use-editor-markings.tsx
@@ -0,0 +1,33 @@
+import { Editor } from "@tiptap/react";
+import { useState } from "react";
+import { IMarking } from "..";
+
+export const useEditorMarkings = () => {
+
+ const [markings, setMarkings] = useState([])
+
+ 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,
+ }
+}
diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx
new file mode 100644
index 000000000..be75ff8fb
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/index.tsx
@@ -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;
+export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise;
+
+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;
+}
+
+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("")
+ 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 (
+
+
+
+
+
+
+
+
+ {/* Page Element */}
+
+
+
+ );
+}
+
+const DocumentEditorWithRef = React.forwardRef((props, ref) => (
+
+));
+
+DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
+
+export { DocumentEditor, DocumentEditorWithRef }
diff --git a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx
new file mode 100644
index 000000000..2cd07ec14
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx
@@ -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 (
+
+
+ {basicMarkItems.map((item, index) => (
+
+ ))}
+
+
+ {listItems.map((item, index) => (
+
+ ))}
+
+
+ {userActionItems.map((item, index) => (
+
+ ))}
+
+
+ {complexItems.map((item, index) => (
+
+ ))}
+
+
+ );
+};
diff --git a/packages/editor/document-editor/src/ui/menu/icon.tsx b/packages/editor/document-editor/src/ui/menu/icon.tsx
new file mode 100644
index 000000000..c0006b3f2
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/menu/icon.tsx
@@ -0,0 +1,13 @@
+import React from "react";
+
+type Props = {
+ iconName: string;
+ className?: string;
+};
+
+export const Icon: React.FC = ({ iconName, className = "" }) => (
+
+ {iconName}
+
+);
+
diff --git a/packages/editor/document-editor/src/ui/menu/index.tsx b/packages/editor/document-editor/src/ui/menu/index.tsx
new file mode 100644
index 000000000..3abc58022
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/menu/index.tsx
@@ -0,0 +1 @@
+export { FixedMenu } from "./fixed-menu";
\ No newline at end of file
diff --git a/packages/editor/document-editor/src/ui/readonly/index.tsx b/packages/editor/document-editor/src/ui/readonly/index.tsx
new file mode 100644
index 000000000..d6dcb0818
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/readonly/index.tsx
@@ -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
+}
+
+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 (
+
+ )
+}
+
+
+const DocumentReadOnlyEditorWithRef = forwardRef<
+ EditorHandle,
+ IDocumentReadOnlyEditor
+>((props, ref) => );
+
+DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef";
+
+export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef }
diff --git a/packages/editor/document-editor/src/ui/tooltip.tsx b/packages/editor/document-editor/src/ui/tooltip.tsx
new file mode 100644
index 000000000..f29d8a491
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/tooltip.tsx
@@ -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 = ({
+ tooltipHeading,
+ tooltipContent,
+ position = "top",
+ children,
+ disabled = false,
+ className = "",
+ openDelay = 200,
+ closeDelay,
+}) => {
+ const { theme } = useTheme();
+
+ return (
+
+ {tooltipHeading && (
+
+ {tooltipHeading}
+
+ )}
+ {tooltipContent}
+
+ }
+ position={position}
+ renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
+ React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
+ }
+ />
+ );
+};
diff --git a/packages/editor/document-editor/src/ui/types/editor-types.ts b/packages/editor/document-editor/src/ui/types/editor-types.ts
new file mode 100644
index 000000000..4996c0e3b
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/types/editor-types.ts
@@ -0,0 +1,8 @@
+
+export interface DocumentDetails {
+ title: string;
+ created_by: string;
+ created_on: Date;
+ last_updated_by: string;
+ last_updated_at: Date;
+}
diff --git a/packages/editor/document-editor/src/ui/types/menu-actions.d.ts b/packages/editor/document-editor/src/ui/types/menu-actions.d.ts
new file mode 100644
index 000000000..ebb253312
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/types/menu-actions.d.ts
@@ -0,0 +1,14 @@
+
+export interface IDuplicationConfig {
+ action: () => Promise
+}
+export interface IPageLockConfig {
+ is_locked: boolean,
+ action: () => Promise
+ locked_by?: string,
+}
+export interface IPageArchiveConfig {
+ is_archived: boolean,
+ archived_at?: Date,
+ action: () => Promise
+ }
diff --git a/packages/editor/document-editor/src/ui/utils/editor-summary-utils.ts b/packages/editor/document-editor/src/ui/utils/editor-summary-utils.ts
new file mode 100644
index 000000000..94d0cbbbc
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/utils/editor-summary-utils.ts
@@ -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)
+ }
+ }
+
diff --git a/packages/editor/document-editor/src/ui/utils/menu-actions.ts b/packages/editor/document-editor/src/ui/utils/menu-actions.ts
new file mode 100644
index 000000000..c6fd32c21
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/utils/menu-actions.ts
@@ -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())
+ }
+}
diff --git a/packages/editor/document-editor/src/ui/utils/menu-options.ts b/packages/editor/document-editor/src/ui/utils/menu-options.ts
new file mode 100644
index 000000000..152ff4ac3
--- /dev/null
+++ b/packages/editor/document-editor/src/ui/utils/menu-options.ts
@@ -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
+}
diff --git a/packages/editor/document-editor/tailwind.config.js b/packages/editor/document-editor/tailwind.config.js
new file mode 100644
index 000000000..f32063158
--- /dev/null
+++ b/packages/editor/document-editor/tailwind.config.js
@@ -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,
+};
diff --git a/packages/editor/document-editor/tsconfig.json b/packages/editor/document-editor/tsconfig.json
new file mode 100644
index 000000000..57d0e9a74
--- /dev/null
+++ b/packages/editor/document-editor/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "tsconfig/react-library.json",
+ "include": ["src/**/*", "index.d.ts"],
+ "exclude": ["dist", "build", "node_modules"]
+}
diff --git a/packages/editor/document-editor/tsup.config.ts b/packages/editor/document-editor/tsup.config.ts
new file mode 100644
index 000000000..5e89e04af
--- /dev/null
+++ b/packages/editor/document-editor/tsup.config.ts
@@ -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,
+}));
diff --git a/turbo.json b/turbo.json
index 454e09f14..c732dae7b 100644
--- a/turbo.json
+++ b/turbo.json
@@ -31,6 +31,7 @@
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build",
+ "@plane/document-editor#build",
"@plane/ui#build"
]
},
@@ -40,6 +41,7 @@
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build",
+ "@plane/document-editor#build",
"@plane/ui#build"
]
},
@@ -48,6 +50,7 @@
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build",
+ "@plane/document-editor#build",
"@plane/ui#build"
]
},
@@ -56,6 +59,7 @@
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build",
+ "@plane/document-editor#build",
"@plane/ui#build"
]
},
@@ -67,6 +71,12 @@
"cache": true,
"dependsOn": ["@plane/editor-core#build"]
},
+ "@plane/document-editor#build": {
+ "cache": true,
+ "dependsOn": [
+ "@plane/editor-core#build"
+ ]
+ },
"test": {
"dependsOn": ["^build"],
"outputs": []
diff --git a/web/components/command-palette/command-pallette.tsx b/web/components/command-palette/command-pallette.tsx
index 33c958b6c..0ca293475 100644
--- a/web/components/command-palette/command-pallette.tsx
+++ b/web/components/command-palette/command-pallette.tsx
@@ -203,8 +203,6 @@ export const CommandPalette: FC = observer(() => {
toggleCreatePageModal(false)}
- user={user}
- workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
>
diff --git a/web/components/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx
index 63acb8510..71a174f18 100644
--- a/web/components/cycles/active-cycle-details.tsx
+++ b/web/components/cycles/active-cycle-details.tsx
@@ -250,7 +250,7 @@ export const ActiveCycleDetails: React.FC = observer((props
handleRemoveFromFavorites(e);
}}
>
-
+
) : (