diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index b60196beacd..6f3ae179f93 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -192,7 +192,7 @@ export const ImageItem = (editor: Editor) => ({ key: "image", name: "Image", - isActive: () => editor?.isActive("image"), + isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"), command: (savedSelection: Selection | null) => editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }), icon: ImageIcon, diff --git a/packages/editor/src/core/extensions/drop.tsx b/packages/editor/src/core/extensions/drop.tsx index 943ab60d46f..2b616dc4c09 100644 --- a/packages/editor/src/core/extensions/drop.tsx +++ b/packages/editor/src/core/extensions/drop.tsx @@ -1,6 +1,6 @@ import { Extension } from "@tiptap/core"; -import { Plugin, PluginKey } from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; export const DropHandlerExtension = () => Extension.create({ diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 1c2e1889112..513b522ee21 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -19,6 +19,7 @@ import { CustomQuoteExtension, CustomTypographyExtension, DropHandlerExtension, + HeadingListExtension, ImageExtension, ListKeymap, Table, @@ -166,4 +167,5 @@ export const CoreEditorExtensions = ({ includeChildren: true, }), CharacterCount, + HeadingListExtension, ]; diff --git a/packages/editor/src/core/extensions/headers.ts b/packages/editor/src/core/extensions/headers.ts new file mode 100644 index 00000000000..3960d5f039c --- /dev/null +++ b/packages/editor/src/core/extensions/headers.ts @@ -0,0 +1,57 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +export interface IMarking { + type: "heading"; + level: number; + text: string; + sequence: number; +} + +export const HeadingListExtension = Extension.create({ + name: "headingList", + + addStorage() { + return { + headings: [] as IMarking[], + }; + }, + + addProseMirrorPlugins() { + const plugin = new Plugin({ + key: new PluginKey("heading-list"), + appendTransaction: (_, __, newState) => { + const headings: IMarking[] = []; + let h1Sequence = 0; + let h2Sequence = 0; + let h3Sequence = 0; + + newState.doc.descendants((node) => { + if (node.type.name === "heading") { + const level = node.attrs.level; + const text = node.textContent; + + headings.push({ + type: "heading", + level: level, + text: text, + sequence: level === 1 ? ++h1Sequence : level === 2 ? ++h2Sequence : ++h3Sequence, + }); + } + }); + + this.storage.headings = headings; + + this.editor.emit("update", { editor: this.editor, transaction: newState.tr }); + + return null; + }, + }); + + return [plugin]; + }, + + getHeadings() { + return this.storage.headings; + }, +}); diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts index 658dd2f7997..9209f9480ff 100644 --- a/packages/editor/src/core/extensions/index.ts +++ b/packages/editor/src/core/extensions/index.ts @@ -19,3 +19,4 @@ export * from "./quote"; export * from "./read-only-extensions"; export * from "./side-menu"; export * from "./slash-commands"; +export * from "./headers"; diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx index 2898b6cdc03..1c0a9add7a2 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -19,6 +19,7 @@ import { TableRow, Table, CustomMention, + HeadingListExtension, CustomReadOnlyImageExtension, } from "@/extensions"; // helpers @@ -108,4 +109,5 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { readonly: true, }), CharacterCount, + HeadingListExtension, ]; diff --git a/packages/editor/src/core/extensions/table/table/utilities/create-cell.ts b/packages/editor/src/core/extensions/table/table/utilities/create-cell.ts index 5fc2b146d06..684a6d344d3 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/create-cell.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/create-cell.ts @@ -1,4 +1,4 @@ -import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model"; +import { Fragment, Node as ProsemirrorNode, NodeType } from "@tiptap/pm/model"; export function createCell( cellType: NodeType, diff --git a/packages/editor/src/core/extensions/table/table/utilities/get-table-node-types.ts b/packages/editor/src/core/extensions/table/table/utilities/get-table-node-types.ts index 28c322a1f1f..5722c4cae2c 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/get-table-node-types.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/get-table-node-types.ts @@ -1,4 +1,4 @@ -import { NodeType, Schema } from "prosemirror-model"; +import { NodeType, Schema } from "@tiptap/pm/model"; export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } { if (schema.cached.tableNodeTypes) { diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 0542418e5d5..d89d62c95fe 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -154,11 +154,27 @@ export const useEditor = (props: CustomEditorProps) => { const item = getEditorMenuItem(itemName); return item ? item.isActive() : false; }, + onHeadingChange: (callback: (headings: IMarking[]) => void) => { + // Subscribe to update event emitted from headers extension + editorRef.current?.on("update", () => { + callback(editorRef.current?.storage.headingList.headings); + }); + // Return a function to unsubscribe to the continuous transactions of + // the editor on unmounting the component that has subscribed to this + // method + return () => { + editorRef.current?.off("update"); + }; + }, + getHeadings: () => { + return editorRef?.current?.storage.headingList.headings; + }, onStateChange: (callback: () => void) => { // Subscribe to editor state changes editorRef.current?.on("transaction", () => { callback(); }); + // Return a function to unsubscribe to the continuous transactions of // the editor on unmounting the component that has subscribed to this // method diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index 3ee7f8e9075..66d7fed18b9 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -89,6 +89,21 @@ export const useReadOnlyEditor = ({ words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0, }; }, + onHeadingChange: (callback: (headings: IMarking[]) => void) => { + // Subscribe to update event emitted from headers extension + editorRef.current?.on("update", () => { + callback(editorRef.current?.storage.headingList.headings); + }); + // Return a function to unsubscribe to the continuous transactions of + // the editor on unmounting the component that has subscribed to this + // method + return () => { + editorRef.current?.off("update"); + }; + }, + getHeadings: () => { + return editorRef?.current?.storage.headingList.headings; + }, })); if (!editor) { diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index f6c790305f3..c89771e1514 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -25,6 +25,8 @@ export type EditorReadOnlyRefApi = { paragraphs: number; words: number; }; + onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void; + getHeadings: () => IMarking[]; }; export interface EditorRefApi extends EditorReadOnlyRefApi { diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index 6cd898c0079..b6480e9a75e 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // document-editor @@ -38,14 +38,13 @@ const fileService = new FileService(); type Props = { editorRef: React.RefObject; + editorReady: boolean; handleConnectionStatus: (status: boolean) => void; handleEditorReady: (value: boolean) => void; handleReadOnlyEditorReady: (value: boolean) => void; - markings: IMarking[]; page: IPage; readOnlyEditorRef: React.RefObject; sidePeekVisible: boolean; - updateMarkings: (description_html: string) => void; }; export const PageEditorBody: React.FC = observer((props) => { @@ -54,11 +53,9 @@ export const PageEditorBody: React.FC = observer((props) => { handleConnectionStatus, handleEditorReady, handleReadOnlyEditorReady, - markings, page, readOnlyEditorRef, sidePeekVisible, - updateMarkings, } = props; // router const { workspaceSlug, projectId } = useParams(); @@ -70,10 +67,9 @@ export const PageEditorBody: React.FC = observer((props) => { project: { getProjectMemberIds }, } = useMember(); // derived values - const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : ""; + const workspaceId = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "" : ""; const pageId = page?.id; const pageTitle = page?.name ?? ""; - const pageDescription = page?.description_html; const { isContentEditable, updateTitle, setIsSubmitting } = page; const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : []; const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite); @@ -104,6 +100,7 @@ export const PageEditorBody: React.FC = observer((props) => { const handleServerConnect = useCallback(() => { handleConnectionStatus(false); }, []); + const handleServerError = useCallback(() => { handleConnectionStatus(true); }, []); @@ -116,10 +113,6 @@ export const PageEditorBody: React.FC = observer((props) => { [] ); - useEffect(() => { - updateMarkings(pageDescription ?? "

"); - }, [pageDescription, updateMarkings]); - const realtimeConfig: TRealtimeConfig = useMemo( () => ({ url: `${LIVE_URL}/collaboration`, @@ -144,10 +137,7 @@ export const PageEditorBody: React.FC = observer((props) => { })} > {!isFullWidth && ( - + )}
; handleDuplicatePage: () => void; hasConnectionFailed: boolean; - markings: IMarking[]; page: IPage; readOnlyEditorReady: boolean; readOnlyEditorRef: React.RefObject; @@ -27,7 +26,6 @@ export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { editorRef, handleDuplicatePage, hasConnectionFailed, - markings, page, readOnlyEditorReady, readOnlyEditorRef, @@ -48,7 +46,6 @@ export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { diff --git a/web/core/components/pages/editor/header/root.tsx b/web/core/components/pages/editor/header/root.tsx index 8fe5040650c..bdac23822ba 100644 --- a/web/core/components/pages/editor/header/root.tsx +++ b/web/core/components/pages/editor/header/root.tsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react"; -import { EditorReadOnlyRefApi, EditorRefApi, IMarking } from "@plane/editor"; +import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; // components import { Header, EHeaderVariant } from "@plane/ui"; import { PageEditorMobileHeaderRoot, PageExtraOptions, PageSummaryPopover, PageToolbar } from "@/components/pages"; @@ -15,7 +15,6 @@ type Props = { editorRef: React.RefObject; handleDuplicatePage: () => void; hasConnectionFailed: boolean; - markings: IMarking[]; page: IPage; readOnlyEditorReady: boolean; readOnlyEditorRef: React.RefObject; @@ -29,7 +28,6 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { editorRef, handleDuplicatePage, hasConnectionFailed, - markings, page, readOnlyEditorReady, readOnlyEditorRef, @@ -47,20 +45,21 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { <>
-
- -
+ {(editorReady || readOnlyEditorReady) && ( +
+ +
+ )} {(editorReady || readOnlyEditorReady) && isContentEditable && editorRef.current && ( )} @@ -79,7 +78,6 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { readOnlyEditorRef={readOnlyEditorRef} editorReady={editorReady} readOnlyEditorReady={readOnlyEditorReady} - markings={markings} handleDuplicatePage={handleDuplicatePage} hasConnectionFailed={hasConnectionFailed} page={page} diff --git a/web/core/components/pages/editor/page-root.tsx b/web/core/components/pages/editor/page-root.tsx index b3c02cd22c8..ff77983be9d 100644 --- a/web/core/components/pages/editor/page-root.tsx +++ b/web/core/components/pages/editor/page-root.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; // editor -import { EditorReadOnlyRefApi, EditorRefApi, useEditorMarkings } from "@plane/editor"; +import { EditorRefApi } from "@plane/editor"; // types import { TPage } from "@plane/types"; // ui @@ -44,8 +44,7 @@ export const PageRoot = observer((props: TPageRootProps) => { const { createPage } = useProjectPages(); // derived values const { access, description_html, name, isContentEditable } = page; - // editor markings hook - const { markings, updateMarkings } = useEditorMarkings(); + // update query params const { updateQueryParams } = useQueryParams(); @@ -127,7 +126,6 @@ export const PageRoot = observer((props: TPageRootProps) => { editorRef={editorRef} handleDuplicatePage={handleDuplicatePage} hasConnectionFailed={hasConnectionFailed} - markings={markings} page={page} readOnlyEditorReady={readOnlyEditorReady} readOnlyEditorRef={readOnlyEditorRef} @@ -135,15 +133,14 @@ export const PageRoot = observer((props: TPageRootProps) => { sidePeekVisible={sidePeekVisible} /> setHasConnectionFailed(status)} handleEditorReady={(val) => setEditorReady(val)} handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)} - markings={markings} page={page} readOnlyEditorRef={readOnlyEditorRef} sidePeekVisible={sidePeekVisible} - updateMarkings={updateMarkings} /> ); diff --git a/web/core/components/pages/editor/summary/content-browser.tsx b/web/core/components/pages/editor/summary/content-browser.tsx index 7f7ad405327..669d2e978c8 100644 --- a/web/core/components/pages/editor/summary/content-browser.tsx +++ b/web/core/components/pages/editor/summary/content-browser.tsx @@ -1,15 +1,27 @@ -// types +import { useState, useEffect } from "react"; +// plane editor import { EditorReadOnlyRefApi, EditorRefApi, IMarking } from "@plane/editor"; +// components import { OutlineHeading1, OutlineHeading2, OutlineHeading3 } from "./heading-components"; type Props = { editorRef: EditorRefApi | EditorReadOnlyRefApi | null; - markings: IMarking[]; setSidePeekVisible?: (sidePeekState: boolean) => void; }; export const PageContentBrowser: React.FC = (props) => { - const { editorRef, markings, setSidePeekVisible } = props; + const { editorRef, setSidePeekVisible } = props; + // states + const [headings, setHeadings] = useState([]); + + useEffect(() => { + const unsubscribe = editorRef?.onHeadingChange(setHeadings); + // for initial render of this component to get the editor headings + setHeadings(editorRef?.getHeadings() ?? []); + return () => { + if (unsubscribe) unsubscribe(); + }; + }, [editorRef]); const handleOnClick = (marking: IMarking) => { editorRef?.scrollSummary(marking); @@ -27,8 +39,8 @@ export const PageContentBrowser: React.FC = (props) => { return (
- {markings.length !== 0 ? ( - markings.map((marking) => { + {headings && headings.length !== 0 ? ( + headings.map((marking) => { const Component = HeadingComponent[marking.level]; if (!Component) return null; return ( diff --git a/web/core/components/pages/editor/summary/popover.tsx b/web/core/components/pages/editor/summary/popover.tsx index ccb6b7b8c39..5d14234f037 100644 --- a/web/core/components/pages/editor/summary/popover.tsx +++ b/web/core/components/pages/editor/summary/popover.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { usePopper } from "react-popper"; import { List } from "lucide-react"; // document editor -import { EditorReadOnlyRefApi, EditorRefApi, IMarking } from "@plane/editor"; +import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; // helpers import { cn } from "@/helpers/common.helper"; // components @@ -11,13 +11,12 @@ import { PageContentBrowser } from "./content-browser"; type Props = { editorRef: EditorRefApi | EditorReadOnlyRefApi | null; isFullWidth: boolean; - markings: IMarking[]; sidePeekVisible: boolean; setSidePeekVisible: (sidePeekState: boolean) => void; }; export const PageSummaryPopover: React.FC = (props) => { - const { editorRef, markings, sidePeekVisible, setSidePeekVisible } = props; + const { editorRef, sidePeekVisible, setSidePeekVisible } = props; // refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -54,7 +53,7 @@ export const PageSummaryPopover: React.FC = (props) => { style={summaryPopoverStyles.popper} {...summaryPopoverAttributes.popper} > - +
)}
@@ -66,7 +65,7 @@ export const PageSummaryPopover: React.FC = (props) => { style={summaryPopoverStyles.popper} {...summaryPopoverAttributes.popper} > - +
)}