[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", key: "image",
name: "Image", name: "Image",
isActive: () => editor?.isActive("image"), isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
command: (savedSelection: Selection | null) => command: (savedSelection: Selection | null) =>
editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }), editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }),
icon: ImageIcon, icon: ImageIcon,

View file

@ -1,6 +1,6 @@
import { Extension } from "@tiptap/core"; import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "prosemirror-state"; import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView } from "prosemirror-view"; import { EditorView } from "@tiptap/pm/view";
export const DropHandlerExtension = () => export const DropHandlerExtension = () =>
Extension.create({ Extension.create({

View file

@ -19,6 +19,7 @@ import {
CustomQuoteExtension, CustomQuoteExtension,
CustomTypographyExtension, CustomTypographyExtension,
DropHandlerExtension, DropHandlerExtension,
HeadingListExtension,
ImageExtension, ImageExtension,
ListKeymap, ListKeymap,
Table, Table,
@ -166,4 +167,5 @@ export const CoreEditorExtensions = ({
includeChildren: true, includeChildren: true,
}), }),
CharacterCount, 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 "./read-only-extensions";
export * from "./side-menu"; export * from "./side-menu";
export * from "./slash-commands"; export * from "./slash-commands";
export * from "./headers";

View file

@ -19,6 +19,7 @@ import {
TableRow, TableRow,
Table, Table,
CustomMention, CustomMention,
HeadingListExtension,
CustomReadOnlyImageExtension, CustomReadOnlyImageExtension,
} from "@/extensions"; } from "@/extensions";
// helpers // helpers
@ -108,4 +109,5 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
readonly: true, readonly: true,
}), }),
CharacterCount, 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( export function createCell(
cellType: NodeType, 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 } { export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
if (schema.cached.tableNodeTypes) { if (schema.cached.tableNodeTypes) {

View file

@ -154,11 +154,27 @@ export const useEditor = (props: CustomEditorProps) => {
const item = getEditorMenuItem(itemName); const item = getEditorMenuItem(itemName);
return item ? item.isActive() : false; 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) => { onStateChange: (callback: () => void) => {
// Subscribe to editor state changes // Subscribe to editor state changes
editorRef.current?.on("transaction", () => { editorRef.current?.on("transaction", () => {
callback(); callback();
}); });
// Return a function to unsubscribe to the continuous transactions of // Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this // the editor on unmounting the component that has subscribed to this
// method // method

View file

@ -89,6 +89,21 @@ export const useReadOnlyEditor = ({
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0, 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) { if (!editor) {

View file

@ -25,6 +25,8 @@ export type EditorReadOnlyRefApi = {
paragraphs: number; paragraphs: number;
words: number; words: number;
}; };
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
getHeadings: () => IMarking[];
}; };
export interface EditorRefApi extends EditorReadOnlyRefApi { 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 { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
// document-editor // document-editor
@ -38,14 +38,13 @@ const fileService = new FileService();
type Props = { type Props = {
editorRef: React.RefObject<EditorRefApi>; editorRef: React.RefObject<EditorRefApi>;
editorReady: boolean;
handleConnectionStatus: (status: boolean) => void; handleConnectionStatus: (status: boolean) => void;
handleEditorReady: (value: boolean) => void; handleEditorReady: (value: boolean) => void;
handleReadOnlyEditorReady: (value: boolean) => void; handleReadOnlyEditorReady: (value: boolean) => void;
markings: IMarking[];
page: IPage; page: IPage;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>; readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
sidePeekVisible: boolean; sidePeekVisible: boolean;
updateMarkings: (description_html: string) => void;
}; };
export const PageEditorBody: React.FC<Props> = observer((props) => { export const PageEditorBody: React.FC<Props> = observer((props) => {
@ -54,11 +53,9 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
handleConnectionStatus, handleConnectionStatus,
handleEditorReady, handleEditorReady,
handleReadOnlyEditorReady, handleReadOnlyEditorReady,
markings,
page, page,
readOnlyEditorRef, readOnlyEditorRef,
sidePeekVisible, sidePeekVisible,
updateMarkings,
} = props; } = props;
// router // router
const { workspaceSlug, projectId } = useParams(); const { workspaceSlug, projectId } = useParams();
@ -70,10 +67,9 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
project: { getProjectMemberIds }, project: { getProjectMemberIds },
} = useMember(); } = useMember();
// derived values // derived values
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : ""; const workspaceId = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "" : "";
const pageId = page?.id; const pageId = page?.id;
const pageTitle = page?.name ?? ""; const pageTitle = page?.name ?? "";
const pageDescription = page?.description_html;
const { isContentEditable, updateTitle, setIsSubmitting } = page; const { isContentEditable, updateTitle, setIsSubmitting } = page;
const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : []; const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : [];
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite); const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
@ -104,6 +100,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
const handleServerConnect = useCallback(() => { const handleServerConnect = useCallback(() => {
handleConnectionStatus(false); handleConnectionStatus(false);
}, []); }, []);
const handleServerError = useCallback(() => { const handleServerError = useCallback(() => {
handleConnectionStatus(true); 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( const realtimeConfig: TRealtimeConfig = useMemo(
() => ({ () => ({
url: `${LIVE_URL}/collaboration`, url: `${LIVE_URL}/collaboration`,
@ -144,10 +137,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
})} })}
> >
{!isFullWidth && ( {!isFullWidth && (
<PageContentBrowser <PageContentBrowser editorRef={(isContentEditable ? editorRef : readOnlyEditorRef)?.current} />
editorRef={(isContentEditable ? editorRef : readOnlyEditorRef)?.current}
markings={markings}
/>
)} )}
</Row> </Row>
<div <div

View file

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

View file

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

View file

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
// editor // editor
import { EditorReadOnlyRefApi, EditorRefApi, useEditorMarkings } from "@plane/editor"; import { EditorRefApi } from "@plane/editor";
// types // types
import { TPage } from "@plane/types"; import { TPage } from "@plane/types";
// ui // ui
@ -44,8 +44,7 @@ export const PageRoot = observer((props: TPageRootProps) => {
const { createPage } = useProjectPages(); const { createPage } = useProjectPages();
// derived values // derived values
const { access, description_html, name, isContentEditable } = page; const { access, description_html, name, isContentEditable } = page;
// editor markings hook
const { markings, updateMarkings } = useEditorMarkings();
// update query params // update query params
const { updateQueryParams } = useQueryParams(); const { updateQueryParams } = useQueryParams();
@ -127,7 +126,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
editorRef={editorRef} editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage} handleDuplicatePage={handleDuplicatePage}
hasConnectionFailed={hasConnectionFailed} hasConnectionFailed={hasConnectionFailed}
markings={markings}
page={page} page={page}
readOnlyEditorReady={readOnlyEditorReady} readOnlyEditorReady={readOnlyEditorReady}
readOnlyEditorRef={readOnlyEditorRef} readOnlyEditorRef={readOnlyEditorRef}
@ -135,15 +133,14 @@ export const PageRoot = observer((props: TPageRootProps) => {
sidePeekVisible={sidePeekVisible} sidePeekVisible={sidePeekVisible}
/> />
<PageEditorBody <PageEditorBody
editorReady={editorReady}
editorRef={editorRef} editorRef={editorRef}
handleConnectionStatus={(status) => setHasConnectionFailed(status)} handleConnectionStatus={(status) => setHasConnectionFailed(status)}
handleEditorReady={(val) => setEditorReady(val)} handleEditorReady={(val) => setEditorReady(val)}
handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)} handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)}
markings={markings}
page={page} page={page}
readOnlyEditorRef={readOnlyEditorRef} readOnlyEditorRef={readOnlyEditorRef}
sidePeekVisible={sidePeekVisible} 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"; import { EditorReadOnlyRefApi, EditorRefApi, IMarking } from "@plane/editor";
// components
import { OutlineHeading1, OutlineHeading2, OutlineHeading3 } from "./heading-components"; import { OutlineHeading1, OutlineHeading2, OutlineHeading3 } from "./heading-components";
type Props = { type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null; editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
markings: IMarking[];
setSidePeekVisible?: (sidePeekState: boolean) => void; setSidePeekVisible?: (sidePeekState: boolean) => void;
}; };
export const PageContentBrowser: React.FC<Props> = (props) => { 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) => { const handleOnClick = (marking: IMarking) => {
editorRef?.scrollSummary(marking); editorRef?.scrollSummary(marking);
@ -27,8 +39,8 @@ export const PageContentBrowser: React.FC<Props> = (props) => {
return ( return (
<div className="h-full flex flex-col overflow-hidden"> <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"> <div className="h-full flex flex-col items-start gap-y-2 overflow-y-auto mt-2">
{markings.length !== 0 ? ( {headings && headings.length !== 0 ? (
markings.map((marking) => { headings.map((marking) => {
const Component = HeadingComponent[marking.level]; const Component = HeadingComponent[marking.level];
if (!Component) return null; if (!Component) return null;
return ( return (

View file

@ -2,7 +2,7 @@ import { useState } from "react";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
import { List } from "lucide-react"; import { List } from "lucide-react";
// document editor // document editor
import { EditorReadOnlyRefApi, EditorRefApi, IMarking } from "@plane/editor"; import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// components // components
@ -11,13 +11,12 @@ import { PageContentBrowser } from "./content-browser";
type Props = { type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null; editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
isFullWidth: boolean; isFullWidth: boolean;
markings: IMarking[];
sidePeekVisible: boolean; sidePeekVisible: boolean;
setSidePeekVisible: (sidePeekState: boolean) => void; setSidePeekVisible: (sidePeekState: boolean) => void;
}; };
export const PageSummaryPopover: React.FC<Props> = (props) => { export const PageSummaryPopover: React.FC<Props> = (props) => {
const { editorRef, markings, sidePeekVisible, setSidePeekVisible } = props; const { editorRef, sidePeekVisible, setSidePeekVisible } = props;
// refs // refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -54,7 +53,7 @@ export const PageSummaryPopover: React.FC<Props> = (props) => {
style={summaryPopoverStyles.popper} style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper} {...summaryPopoverAttributes.popper}
> >
<PageContentBrowser setSidePeekVisible={setSidePeekVisible} editorRef={editorRef} markings={markings} /> <PageContentBrowser setSidePeekVisible={setSidePeekVisible} editorRef={editorRef} />
</div> </div>
)} )}
</div> </div>
@ -66,7 +65,7 @@ export const PageSummaryPopover: React.FC<Props> = (props) => {
style={summaryPopoverStyles.popper} style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper} {...summaryPopoverAttributes.popper}
> >
<PageContentBrowser editorRef={editorRef} markings={markings} /> <PageContentBrowser editorRef={editorRef} />
</div> </div>
)} )}
</div> </div>