[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:
parent
b6e813cb9a
commit
a05876552c
17 changed files with 145 additions and 57 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
CustomQuoteExtension,
|
||||
CustomTypographyExtension,
|
||||
DropHandlerExtension,
|
||||
HeadingListExtension,
|
||||
ImageExtension,
|
||||
ListKeymap,
|
||||
Table,
|
||||
|
|
@ -166,4 +167,5 @@ export const CoreEditorExtensions = ({
|
|||
includeChildren: true,
|
||||
}),
|
||||
CharacterCount,
|
||||
HeadingListExtension,
|
||||
];
|
||||
|
|
|
|||
57
packages/editor/src/core/extensions/headers.ts
Normal file
57
packages/editor/src/core/extensions/headers.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
|
|
@ -19,3 +19,4 @@ export * from "./quote";
|
|||
export * from "./read-only-extensions";
|
||||
export * from "./side-menu";
|
||||
export * from "./slash-commands";
|
||||
export * from "./headers";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export type EditorReadOnlyRefApi = {
|
|||
paragraphs: number;
|
||||
words: number;
|
||||
};
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
|
||||
getHeadings: () => IMarking[];
|
||||
};
|
||||
|
||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue