[WEB-1116] fix: page outline not reflecting changes in realtime (#5567)

* fix: svg not supported in image uploads

* fix: svg image file error message fixed

* fix: heading not updating with realtime

* chore: add read-only editor support

* fix: headings show on initial render

* fix: types and imports

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
M. Palanikannan 2024-09-23 14:44:27 +05:30 committed by GitHub
parent b6e813cb9a
commit a05876552c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 145 additions and 57 deletions

View file

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

View file

@ -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({

View file

@ -19,6 +19,7 @@ import {
CustomQuoteExtension,
CustomTypographyExtension,
DropHandlerExtension,
HeadingListExtension,
ImageExtension,
ListKeymap,
Table,
@ -166,4 +167,5 @@ export const CoreEditorExtensions = ({
includeChildren: true,
}),
CharacterCount,
HeadingListExtension,
];

View file

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

View file

@ -19,3 +19,4 @@ export * from "./quote";
export * from "./read-only-extensions";
export * from "./side-menu";
export * from "./slash-commands";
export * from "./headers";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -25,6 +25,8 @@ export type EditorReadOnlyRefApi = {
paragraphs: number;
words: number;
};
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
getHeadings: () => IMarking[];
};
export interface EditorRefApi extends EditorReadOnlyRefApi {

View file

@ -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<EditorRefApi>;
editorReady: boolean;
handleConnectionStatus: (status: boolean) => void;
handleEditorReady: (value: boolean) => void;
handleReadOnlyEditorReady: (value: boolean) => void;
markings: IMarking[];
page: IPage;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
sidePeekVisible: boolean;
updateMarkings: (description_html: string) => void;
};
export const PageEditorBody: React.FC<Props> = observer((props) => {
@ -54,11 +53,9 @@ export const PageEditorBody: React.FC<Props> = 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<Props> = 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<Props> = observer((props) => {
const handleServerConnect = useCallback(() => {
handleConnectionStatus(false);
}, []);
const handleServerError = useCallback(() => {
handleConnectionStatus(true);
}, []);
@ -116,10 +113,6 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
[]
);
useEffect(() => {
updateMarkings(pageDescription ?? "<p></p>");
}, [pageDescription, updateMarkings]);
const realtimeConfig: TRealtimeConfig = useMemo(
() => ({
url: `${LIVE_URL}/collaboration`,
@ -144,10 +137,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
})}
>
{!isFullWidth && (
<PageContentBrowser
editorRef={(isContentEditable ? editorRef : readOnlyEditorRef)?.current}
markings={markings}
/>
<PageContentBrowser editorRef={(isContentEditable ? editorRef : readOnlyEditorRef)?.current} />
)}
</Row>
<div

View file

@ -13,7 +13,6 @@ type Props = {
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
hasConnectionFailed: boolean;
markings: IMarking[];
page: IPage;
readOnlyEditorReady: boolean;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
@ -27,7 +26,6 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
editorRef,
handleDuplicatePage,
hasConnectionFailed,
markings,
page,
readOnlyEditorReady,
readOnlyEditorRef,
@ -48,7 +46,6 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
<PageSummaryPopover
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
isFullWidth={isFullWidth}
markings={markings}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
/>

View file

@ -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<EditorRefApi>;
handleDuplicatePage: () => void;
hasConnectionFailed: boolean;
markings: IMarking[];
page: IPage;
readOnlyEditorReady: boolean;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
@ -29,7 +28,6 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
editorRef,
handleDuplicatePage,
hasConnectionFailed,
markings,
page,
readOnlyEditorReady,
readOnlyEditorRef,
@ -47,20 +45,21 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
<>
<Header variant={EHeaderVariant.SECONDARY} showOnMobile={false}>
<Header.LeftItem className="gap-0 w-full">
<div
className={cn("flex-shrink-0 my-auto", {
"w-40 lg:w-56": !isFullWidth,
"w-[5%]": isFullWidth,
})}
>
<PageSummaryPopover
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
isFullWidth={isFullWidth}
markings={markings}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
/>
</div>
{(editorReady || readOnlyEditorReady) && (
<div
className={cn("flex-shrink-0 my-auto", {
"w-40 lg:w-56": !isFullWidth,
"w-[5%]": isFullWidth,
})}
>
<PageSummaryPopover
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
isFullWidth={isFullWidth}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
/>
</div>
)}
{(editorReady || readOnlyEditorReady) && isContentEditable && editorRef.current && (
<PageToolbar editorRef={editorRef?.current} />
)}
@ -79,7 +78,6 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
readOnlyEditorRef={readOnlyEditorRef}
editorReady={editorReady}
readOnlyEditorReady={readOnlyEditorReady}
markings={markings}
handleDuplicatePage={handleDuplicatePage}
hasConnectionFailed={hasConnectionFailed}
page={page}

View file

@ -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}
/>
<PageEditorBody
editorReady={editorReady}
editorRef={editorRef}
handleConnectionStatus={(status) => setHasConnectionFailed(status)}
handleEditorReady={(val) => setEditorReady(val)}
handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)}
markings={markings}
page={page}
readOnlyEditorRef={readOnlyEditorRef}
sidePeekVisible={sidePeekVisible}
updateMarkings={updateMarkings}
/>
</>
);

View file

@ -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> = (props) => {
const { editorRef, markings, setSidePeekVisible } = props;
const { editorRef, setSidePeekVisible } = props;
// states
const [headings, setHeadings] = useState<IMarking[]>([]);
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> = (props) => {
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="h-full flex flex-col items-start gap-y-2 overflow-y-auto mt-2">
{markings.length !== 0 ? (
markings.map((marking) => {
{headings && headings.length !== 0 ? (
headings.map((marking) => {
const Component = HeadingComponent[marking.level];
if (!Component) return null;
return (

View file

@ -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> = (props) => {
const { editorRef, markings, sidePeekVisible, setSidePeekVisible } = props;
const { editorRef, sidePeekVisible, setSidePeekVisible } = props;
// refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -54,7 +53,7 @@ export const PageSummaryPopover: React.FC<Props> = (props) => {
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
>
<PageContentBrowser setSidePeekVisible={setSidePeekVisible} editorRef={editorRef} markings={markings} />
<PageContentBrowser setSidePeekVisible={setSidePeekVisible} editorRef={editorRef} />
</div>
)}
</div>
@ -66,7 +65,7 @@ export const PageSummaryPopover: React.FC<Props> = (props) => {
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
>
<PageContentBrowser editorRef={editorRef} markings={markings} />
<PageContentBrowser editorRef={editorRef} />
</div>
)}
</div>