chore: realtime updates fix
This commit is contained in:
parent
be722f708d
commit
b53016b449
32 changed files with 3929 additions and 1969 deletions
|
|
@ -44,13 +44,16 @@
|
|||
"@tiptap/extension-blockquote": "^2.22.3",
|
||||
"@tiptap/extension-character-count": "^2.22.3",
|
||||
"@tiptap/extension-collaboration": "^2.22.3",
|
||||
"@tiptap/extension-document": "^2.22.3",
|
||||
"@tiptap/extension-emoji": "^2.22.3",
|
||||
"@tiptap/extension-heading": "^2.22.3",
|
||||
"@tiptap/extension-image": "^2.22.3",
|
||||
"@tiptap/extension-list-item": "^2.22.3",
|
||||
"@tiptap/extension-mention": "^2.22.3",
|
||||
"@tiptap/extension-placeholder": "^2.22.3",
|
||||
"@tiptap/extension-task-item": "^2.22.3",
|
||||
"@tiptap/extension-task-list": "^2.22.3",
|
||||
"@tiptap/extension-text": "^2.22.3",
|
||||
"@tiptap/extension-text-align": "^2.22.3",
|
||||
"@tiptap/extension-text-style": "^2.22.3",
|
||||
"@tiptap/extension-underline": "^2.22.3",
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// contexts
|
||||
import { CollaborationProvider, useCollaboration } from "@/contexts/collaboration-context";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
|
||||
// constants
|
||||
import { DocumentEditorSideEffects } from "@/plane-editor/components/document-editor-side-effects";
|
||||
// types
|
||||
import type { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
|
||||
|
||||
function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
|
||||
// Inner component that has access to collaboration context
|
||||
const CollaborativeDocumentEditorInner: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
|
||||
const {
|
||||
aiHandler,
|
||||
bubbleMenuEnabled = true,
|
||||
|
|
@ -41,15 +42,20 @@ function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
|
|||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
tabIndex,
|
||||
user,
|
||||
extendedDocumentEditorProps,
|
||||
titleRef,
|
||||
updatePageProperties,
|
||||
isFetchingFallbackBinary,
|
||||
} = props;
|
||||
|
||||
// use document editor
|
||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
|
||||
// Get non-null provider from context
|
||||
const { provider, state, actions } = useCollaboration();
|
||||
|
||||
// Editor initialization with guaranteed non-null provider
|
||||
const { editor, titleEditor } = useCollaborativeEditor({
|
||||
provider,
|
||||
disabledExtensions,
|
||||
editable,
|
||||
editorClassName,
|
||||
|
|
@ -70,11 +76,11 @@ function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
|
|||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
tabIndex,
|
||||
titleRef,
|
||||
updatePageProperties,
|
||||
user,
|
||||
extendedDocumentEditorProps,
|
||||
actions,
|
||||
});
|
||||
|
||||
const editorContainerClassNames = getEditorClassNames({
|
||||
|
|
@ -83,37 +89,71 @@ function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
|
|||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
// Show loader ONLY when cache is known empty and server hasn't synced yet
|
||||
const shouldShowSyncLoader = state.isCacheReady && !state.hasCachedContent && !state.isServerSynced;
|
||||
const shouldWaitForFallbackBinary = isFetchingFallbackBinary && !state.hasCachedContent && state.isServerDisconnected;
|
||||
const isLoading = shouldShowSyncLoader || shouldWaitForFallbackBinary;
|
||||
|
||||
// Gate content rendering on isDocReady to prevent empty editor flash
|
||||
const showContentSkeleton = !state.isDocReady;
|
||||
|
||||
if (!editor || !titleEditor) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentEditorSideEffects editor={editor} id={id} extendedEditorProps={extendedEditorProps} />
|
||||
<PageRenderer
|
||||
aiHandler={aiHandler}
|
||||
bubbleMenuEnabled={bubbleMenuEnabled}
|
||||
displayConfig={displayConfig}
|
||||
documentLoaderClassName={documentLoaderClassName}
|
||||
editor={editor}
|
||||
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
|
||||
extendedEditorProps={extendedEditorProps}
|
||||
id={id}
|
||||
isTouchDevice={!!isTouchDevice}
|
||||
isLoading={!hasServerSynced && !hasServerConnectionFailed}
|
||||
tabIndex={tabIndex}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
disabledExtensions={disabledExtensions}
|
||||
extendedDocumentEditorProps={extendedDocumentEditorProps}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"transition-opacity duration-200",
|
||||
showContentSkeleton && !isLoading && "opacity-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<PageRenderer
|
||||
aiHandler={aiHandler}
|
||||
bubbleMenuEnabled={bubbleMenuEnabled}
|
||||
displayConfig={displayConfig}
|
||||
documentLoaderClassName={documentLoaderClassName}
|
||||
disabledExtensions={disabledExtensions}
|
||||
extendedDocumentEditorProps={extendedDocumentEditorProps}
|
||||
editor={editor}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
titleEditor={titleEditor}
|
||||
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
|
||||
extendedEditorProps={extendedEditorProps}
|
||||
id={id}
|
||||
isLoading={isLoading}
|
||||
isTouchDevice={!!isTouchDevice}
|
||||
tabIndex={tabIndex}
|
||||
provider={provider}
|
||||
state={state}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const CollaborativeDocumentEditorWithRef = React.forwardRef(function CollaborativeDocumentEditorWithRef(
|
||||
props: ICollaborativeDocumentEditorProps,
|
||||
ref: React.ForwardedRef<EditorRefApi>
|
||||
) {
|
||||
return <CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />;
|
||||
});
|
||||
// Outer component that provides collaboration context
|
||||
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
|
||||
const { id, realtimeConfig, serverHandler, user } = props;
|
||||
|
||||
const token = useMemo(() => JSON.stringify(user), [user]);
|
||||
|
||||
return (
|
||||
<CollaborationProvider
|
||||
docId={id}
|
||||
serverUrl={realtimeConfig.url}
|
||||
authToken={token}
|
||||
onStateChange={serverHandler?.onStateChange}
|
||||
>
|
||||
<CollaborativeDocumentEditorInner {...props} />
|
||||
</CollaborationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
|
||||
(props, ref) => (
|
||||
<CollaborativeDocumentEditor key={props.id} {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi>} />
|
||||
)
|
||||
);
|
||||
|
||||
CollaborativeDocumentEditorWithRef.displayName = "CollaborativeDocumentEditorWithRef";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { DocumentContentLoader, EditorContainer, EditorContentWrapper } from "@/components/editors";
|
||||
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
|
||||
import { BlockMenu, EditorBubbleMenu } from "@/components/menus";
|
||||
// types
|
||||
import type { TCollabValue } from "@/contexts";
|
||||
import type {
|
||||
ICollaborativeDocumentEditorPropsExtended,
|
||||
IEditorProps,
|
||||
|
|
@ -20,6 +22,7 @@ type Props = {
|
|||
displayConfig: TDisplayConfig;
|
||||
documentLoaderClassName?: string;
|
||||
editor: Editor;
|
||||
titleEditor?: Editor;
|
||||
editorContainerClassName: string;
|
||||
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
|
||||
extendedEditorProps: IEditorPropsExtended;
|
||||
|
|
@ -28,11 +31,12 @@ type Props = {
|
|||
isLoading?: boolean;
|
||||
isTouchDevice: boolean;
|
||||
tabIndex?: number;
|
||||
provider?: HocuspocusProvider;
|
||||
state?: TCollabValue["state"];
|
||||
};
|
||||
|
||||
export function PageRenderer(props: Props) {
|
||||
const {
|
||||
aiHandler,
|
||||
bubbleMenuEnabled,
|
||||
disabledExtensions,
|
||||
displayConfig,
|
||||
|
|
@ -45,8 +49,10 @@ export function PageRenderer(props: Props) {
|
|||
isLoading,
|
||||
isTouchDevice,
|
||||
tabIndex,
|
||||
titleEditor,
|
||||
provider,
|
||||
state,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("frame-renderer flex-grow w-full", {
|
||||
|
|
@ -56,33 +62,54 @@ export function PageRenderer(props: Props) {
|
|||
{isLoading ? (
|
||||
<DocumentContentLoader className={documentLoaderClassName} />
|
||||
) : (
|
||||
<EditorContainer
|
||||
displayConfig={displayConfig}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
id={id}
|
||||
isTouchDevice={isTouchDevice}
|
||||
>
|
||||
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
||||
{editor.isEditable && !isTouchDevice && (
|
||||
<div>
|
||||
{bubbleMenuEnabled && (
|
||||
<EditorBubbleMenu
|
||||
disabledExtensions={disabledExtensions}
|
||||
editor={editor}
|
||||
extendedEditorProps={extendedEditorProps}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
<>
|
||||
{titleEditor && (
|
||||
<div className="relative w-full py-3">
|
||||
<EditorContainer
|
||||
editor={titleEditor}
|
||||
id={id + "-title"}
|
||||
isTouchDevice={isTouchDevice}
|
||||
editorContainerClassName="page-title-editor bg-transparent py-3 border-none"
|
||||
displayConfig={displayConfig}
|
||||
>
|
||||
<EditorContentWrapper
|
||||
editor={titleEditor}
|
||||
id={id + "-title"}
|
||||
tabIndex={tabIndex}
|
||||
className="no-scrollbar placeholder-custom-text-400 bg-transparent tracking-[-2%] font-bold text-[2rem] leading-[2.375rem] w-full outline-none p-0 border-none resize-none rounded-none"
|
||||
/>
|
||||
)}
|
||||
<BlockMenu
|
||||
editor={editor}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
disabledExtensions={disabledExtensions}
|
||||
/>
|
||||
<AIFeaturesMenu menu={aiHandler?.menu} />
|
||||
</EditorContainer>
|
||||
</div>
|
||||
)}
|
||||
</EditorContainer>
|
||||
<EditorContainer
|
||||
displayConfig={displayConfig}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
id={id}
|
||||
isTouchDevice={isTouchDevice}
|
||||
provider={provider}
|
||||
state={state}
|
||||
>
|
||||
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
||||
{editor.isEditable && !isTouchDevice && (
|
||||
<div>
|
||||
{bubbleMenuEnabled && (
|
||||
<EditorBubbleMenu
|
||||
editor={editor}
|
||||
disabledExtensions={disabledExtensions}
|
||||
extendedEditorProps={extendedEditorProps}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
/>
|
||||
)}
|
||||
<BlockMenu
|
||||
editor={editor}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
disabledExtensions={disabledExtensions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</EditorContainer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// components
|
||||
import type { TCollabValue } from "@/contexts";
|
||||
import { LinkContainer } from "@/plane-editor/components/link-container";
|
||||
// plugins
|
||||
import { nodeHighlightPluginKey } from "@/plugins/highlight";
|
||||
// types
|
||||
import type { TDisplayConfig } from "@/types";
|
||||
|
||||
|
|
@ -18,12 +22,85 @@ type Props = {
|
|||
editorContainerClassName: string;
|
||||
id: string;
|
||||
isTouchDevice: boolean;
|
||||
provider?: HocuspocusProvider | undefined;
|
||||
state?: TCollabValue["state"];
|
||||
};
|
||||
|
||||
export function EditorContainer(props: Props) {
|
||||
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice } = props;
|
||||
export const EditorContainer: FC<Props> = (props) => {
|
||||
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice, provider, state } = props;
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const hasScrolledOnce = useRef(false);
|
||||
const scrollToNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
if (!editor) return false;
|
||||
|
||||
const doc = editor.state.doc;
|
||||
let pos: number | null = null;
|
||||
|
||||
doc.descendants((node, position) => {
|
||||
if (node.attrs && node.attrs.id === nodeId) {
|
||||
pos = position;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (pos === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodePosition = pos;
|
||||
const tr = editor.state.tr.setMeta(nodeHighlightPluginKey, { nodeId });
|
||||
editor.view.dispatch(tr);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const domNode = editor.view.nodeDOM(nodePosition);
|
||||
if (domNode instanceof HTMLElement) {
|
||||
domNode.scrollIntoView({ behavior: "instant", block: "center" });
|
||||
}
|
||||
});
|
||||
|
||||
editor.once("focus", () => {
|
||||
const clearTr = editor.state.tr.setMeta(nodeHighlightPluginKey, { nodeId: null });
|
||||
editor.view.dispatch(clearTr);
|
||||
});
|
||||
|
||||
hasScrolledOnce.current = true;
|
||||
return true;
|
||||
},
|
||||
|
||||
[editor]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = window.location.href.split("#")[1];
|
||||
|
||||
const handleSynced = () => scrollToNode(nodeId);
|
||||
|
||||
if (nodeId && !hasScrolledOnce.current) {
|
||||
if (provider && state) {
|
||||
const { hasCachedContent } = state;
|
||||
// If the provider is synced or the cached content is available and the server is disconnected, scroll to the node
|
||||
if (hasCachedContent) {
|
||||
const hasScrolled = handleSynced();
|
||||
if (!hasScrolled) {
|
||||
provider.on("synced", handleSynced);
|
||||
}
|
||||
} else if (provider.isSynced) {
|
||||
handleSynced();
|
||||
} else {
|
||||
provider.on("synced", handleSynced);
|
||||
}
|
||||
} else {
|
||||
handleSynced();
|
||||
}
|
||||
return () => {
|
||||
if (provider) {
|
||||
provider.off("synced", handleSynced);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [scrollToNode, provider, state]);
|
||||
|
||||
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
|
|
@ -88,7 +165,6 @@ export function EditorContainer(props: Props) {
|
|||
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
|
||||
{
|
||||
"active-editor": editor?.isFocused && editor?.isEditable,
|
||||
"wide-layout": displayConfig.wideLayout,
|
||||
},
|
||||
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
|
||||
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
|
||||
|
|
@ -100,4 +176,4 @@ export function EditorContainer(props: Props) {
|
|||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,19 +3,24 @@ import type { Editor } from "@tiptap/react";
|
|||
import type { FC, ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
editor: Editor | null;
|
||||
id: string;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export function EditorContentWrapper(props: Props) {
|
||||
const { editor, children, tabIndex, id } = props;
|
||||
export const EditorContentWrapper: FC<Props> = (props) => {
|
||||
const { editor, className, children, tabIndex, id } = props;
|
||||
|
||||
return (
|
||||
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
|
||||
<div
|
||||
tabIndex={tabIndex}
|
||||
onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}
|
||||
className={className}
|
||||
>
|
||||
<EditorContent editor={editor} id={id} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
32
packages/editor/src/core/contexts/collaboration-context.tsx
Normal file
32
packages/editor/src/core/contexts/collaboration-context.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React, { createContext, useContext } from "react";
|
||||
// hooks
|
||||
import { useYjsSetup } from "@/hooks/use-yjs-setup";
|
||||
|
||||
export type TCollabValue = NonNullable<ReturnType<typeof useYjsSetup>>;
|
||||
|
||||
const CollabContext = createContext<TCollabValue | null>(null);
|
||||
|
||||
type CollabProviderProps = Parameters<typeof useYjsSetup>[0] & {
|
||||
fallback?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function CollaborationProvider({ fallback = null, children, ...args }: CollabProviderProps) {
|
||||
const setup = useYjsSetup(args);
|
||||
|
||||
// Only wait for provider setup, not content ready
|
||||
// Consumers can check state.isDocReady to gate content rendering
|
||||
if (!setup) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <CollabContext.Provider value={setup}>{children}</CollabContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCollaboration(): TCollabValue {
|
||||
const ctx = useContext(CollabContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useCollaboration must be used inside <CollaborationProvider>");
|
||||
}
|
||||
return ctx; // guaranteed non-null
|
||||
}
|
||||
1
packages/editor/src/core/contexts/index.ts
Normal file
1
packages/editor/src/core/contexts/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./collaboration-context";
|
||||
14
packages/editor/src/core/extensions/title-extension.ts
Normal file
14
packages/editor/src/core/extensions/title-extension.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { AnyExtension, Extensions } from "@tiptap/core";
|
||||
import Document from "@tiptap/extension-document";
|
||||
import Heading from "@tiptap/extension-heading";
|
||||
import Text from "@tiptap/extension-text";
|
||||
|
||||
export const TitleExtensions: Extensions = [
|
||||
Document.extend({
|
||||
content: "heading",
|
||||
}),
|
||||
Heading.configure({
|
||||
levels: [1],
|
||||
}) as AnyExtension,
|
||||
Text,
|
||||
];
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Buffer } from "buffer";
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
|
||||
|
|
@ -9,10 +10,13 @@ import {
|
|||
CoreEditorExtensionsWithoutProps,
|
||||
DocumentEditorExtensionsWithoutProps,
|
||||
} from "@/extensions/core-without-props";
|
||||
import { TitleExtensions } from "@/extensions/title-extension";
|
||||
import { sanitizeHTML } from "@plane/utils";
|
||||
|
||||
// editor extension configs
|
||||
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
|
||||
export const TITLE_EDITOR_EXTENSIONS: Extensions = TitleExtensions;
|
||||
// editor schemas
|
||||
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
|
@ -45,9 +49,10 @@ export const convertBinaryDataToBase64String = (document: Uint8Array): string =>
|
|||
/**
|
||||
* @description this function decodes base64 string to binary data
|
||||
* @param {string} document
|
||||
* @returns {ArrayBuffer}
|
||||
* @returns {Buffer<ArrayBuffer>}
|
||||
*/
|
||||
export const convertBase64StringToBinaryData = (document: string): ArrayBuffer => Buffer.from(document, "base64");
|
||||
export const convertBase64StringToBinaryData = (document: string): Buffer<ArrayBuffer> =>
|
||||
Buffer.from(document, "base64");
|
||||
|
||||
/**
|
||||
* @description this function generates the binary equivalent of html content for the rich text editor
|
||||
|
|
@ -114,11 +119,13 @@ export const getAllDocumentFormatsFromRichTextEditorBinaryData = (
|
|||
* @returns
|
||||
*/
|
||||
export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
|
||||
description: Uint8Array
|
||||
description: Uint8Array,
|
||||
updateTitle: boolean
|
||||
): {
|
||||
contentBinaryEncoded: string;
|
||||
contentJSON: object;
|
||||
contentHTML: string;
|
||||
titleHTML?: string;
|
||||
} => {
|
||||
// encode binary description data
|
||||
const base64Data = convertBinaryDataToBase64String(description);
|
||||
|
|
@ -130,11 +137,24 @@ export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
|
|||
// convert to HTML
|
||||
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
return {
|
||||
contentBinaryEncoded: base64Data,
|
||||
contentJSON,
|
||||
contentHTML,
|
||||
};
|
||||
if (updateTitle) {
|
||||
const title = yDoc.getXmlFragment("title");
|
||||
const titleJSON = yXmlFragmentToProseMirrorRootNode(title, documentEditorSchema).toJSON();
|
||||
const titleHTML = extractTextFromHTML(generateHTML(titleJSON, DOCUMENT_EDITOR_EXTENSIONS));
|
||||
|
||||
return {
|
||||
contentBinaryEncoded: base64Data,
|
||||
contentJSON,
|
||||
contentHTML,
|
||||
titleHTML,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
contentBinaryEncoded: base64Data,
|
||||
contentJSON,
|
||||
contentHTML,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
type TConvertHTMLDocumentToAllFormatsArgs = {
|
||||
|
|
@ -170,8 +190,10 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF
|
|||
// Convert HTML to binary format for document editor
|
||||
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
|
||||
// Generate all document formats from the binary data
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
|
||||
contentBinary,
|
||||
false
|
||||
);
|
||||
allFormats = {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
|
|
@ -183,3 +205,10 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF
|
|||
|
||||
return allFormats;
|
||||
};
|
||||
|
||||
export const extractTextFromHTML = (html: string): string => {
|
||||
// Use sanitizeHTML to safely extract text and remove all HTML tags
|
||||
// This is more secure than regex as it handles edge cases and prevents injection
|
||||
// Note: sanitizeHTML trims whitespace, which is acceptable for title extraction
|
||||
return sanitizeHTML(html) || "";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
// react
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
// extensions
|
||||
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
|
||||
// hooks
|
||||
|
|
@ -9,10 +11,29 @@ import { useEditor } from "@/hooks/use-editor";
|
|||
// plane editor extensions
|
||||
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import type { TCollaborativeEditorHookProps } from "@/types";
|
||||
import type {
|
||||
TCollaborativeEditorHookProps,
|
||||
ICollaborativeDocumentEditorProps,
|
||||
IEditorPropsExtended,
|
||||
IEditorProps,
|
||||
TEditorHookProps,
|
||||
EditorTitleRefApi,
|
||||
} from "@/types";
|
||||
// local imports
|
||||
import { useEditorNavigation } from "./use-editor-navigation";
|
||||
import { useTitleEditor } from "./use-title-editor";
|
||||
|
||||
export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => {
|
||||
type UseCollaborativeEditorArgs = Omit<TCollaborativeEditorHookProps, "realtimeConfig" | "serverHandler" | "user"> & {
|
||||
provider: HocuspocusProvider;
|
||||
user: TCollaborativeEditorHookProps["user"];
|
||||
actions: {
|
||||
signalForcedClose: (value: boolean) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export const useCollaborativeEditor = (props: UseCollaborativeEditorArgs) => {
|
||||
const {
|
||||
provider,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onTransaction,
|
||||
|
|
@ -24,70 +45,26 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
|||
extensions = [],
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
getEditorMetaData,
|
||||
forwardedRef,
|
||||
getEditorMetaData,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
dragDropEnabled = true,
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
onEditorFocus,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
tabIndex,
|
||||
titleRef,
|
||||
updatePageProperties,
|
||||
user,
|
||||
} = props;
|
||||
// states
|
||||
const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false);
|
||||
const [hasServerSynced, setHasServerSynced] = useState(false);
|
||||
// initialize Hocuspocus provider
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
new HocuspocusProvider({
|
||||
name: id,
|
||||
// using user id as a token to verify the user on the server
|
||||
token: JSON.stringify(user),
|
||||
url: realtimeConfig.url,
|
||||
onAuthenticationFailed: () => {
|
||||
serverHandler?.onServerError?.();
|
||||
setHasServerConnectionFailed(true);
|
||||
},
|
||||
onConnect: () => serverHandler?.onConnect?.(),
|
||||
onClose: (data) => {
|
||||
if (data.event.code === 1006) {
|
||||
serverHandler?.onServerError?.();
|
||||
setHasServerConnectionFailed(true);
|
||||
}
|
||||
},
|
||||
onSynced: () => setHasServerSynced(true),
|
||||
}),
|
||||
[id, realtimeConfig, serverHandler, user]
|
||||
);
|
||||
|
||||
const localProvider = useMemo(
|
||||
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
|
||||
[id, provider]
|
||||
);
|
||||
const { mainNavigationExtension, titleNavigationExtension, setMainEditor, setTitleEditor } = useEditorNavigation();
|
||||
|
||||
// destroy and disconnect all providers connection on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
provider?.destroy();
|
||||
localProvider?.destroy();
|
||||
},
|
||||
[provider, localProvider]
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
disabledExtensions,
|
||||
extendedEditorProps,
|
||||
id,
|
||||
editable,
|
||||
editorProps,
|
||||
editorClassName,
|
||||
enableHistory: false,
|
||||
extensions: [
|
||||
// Memoize extensions to avoid unnecessary editor recreations
|
||||
const editorExtensions = useMemo(
|
||||
() => [
|
||||
SideMenuExtension({
|
||||
aiEnabled: !disabledExtensions?.includes("ai"),
|
||||
dragDropEnabled,
|
||||
|
|
@ -95,6 +72,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
|||
HeadingListExtension,
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
field: "default",
|
||||
}),
|
||||
...extensions,
|
||||
...DocumentEditorAdditionalExtensions({
|
||||
|
|
@ -106,26 +84,120 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
|||
provider,
|
||||
userDetails: user,
|
||||
}),
|
||||
mainNavigationExtension,
|
||||
],
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
getEditorMetaData,
|
||||
handleEditorReady,
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
provider,
|
||||
tabIndex,
|
||||
});
|
||||
[
|
||||
provider,
|
||||
disabledExtensions,
|
||||
dragDropEnabled,
|
||||
extensions,
|
||||
extendedEditorProps,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
editable,
|
||||
user,
|
||||
mainNavigationExtension,
|
||||
]
|
||||
);
|
||||
|
||||
// Editor configuration
|
||||
const editorConfig = useMemo<TEditorHookProps>(
|
||||
() => ({
|
||||
disabledExtensions,
|
||||
extendedEditorProps,
|
||||
id,
|
||||
editable,
|
||||
editorProps,
|
||||
editorClassName,
|
||||
enableHistory: false,
|
||||
extensions: editorExtensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
getEditorMetaData,
|
||||
handleEditorReady,
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
provider,
|
||||
tabIndex,
|
||||
}),
|
||||
[
|
||||
provider,
|
||||
disabledExtensions,
|
||||
extendedEditorProps,
|
||||
id,
|
||||
editable,
|
||||
editorProps,
|
||||
editorClassName,
|
||||
editorExtensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
getEditorMetaData,
|
||||
handleEditorReady,
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
]
|
||||
);
|
||||
|
||||
const editor = useEditor(editorConfig);
|
||||
|
||||
const titleExtensions = useMemo(
|
||||
() => [
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
field: "title",
|
||||
}),
|
||||
titleNavigationExtension,
|
||||
],
|
||||
[provider, titleNavigationExtension]
|
||||
);
|
||||
|
||||
const titleEditorConfig = useMemo<{
|
||||
id: string;
|
||||
editable: boolean;
|
||||
provider: HocuspocusProvider;
|
||||
titleRef?: React.MutableRefObject<EditorTitleRefApi | null>;
|
||||
updatePageProperties?: ICollaborativeDocumentEditorProps["updatePageProperties"];
|
||||
extensions: Extensions;
|
||||
extendedEditorProps?: IEditorPropsExtended;
|
||||
getEditorMetaData?: IEditorProps["getEditorMetaData"];
|
||||
}>(
|
||||
() => ({
|
||||
id,
|
||||
editable,
|
||||
provider,
|
||||
titleRef,
|
||||
updatePageProperties,
|
||||
extensions: titleExtensions,
|
||||
extendedEditorProps,
|
||||
getEditorMetaData,
|
||||
}),
|
||||
[provider, id, editable, titleRef, updatePageProperties, titleExtensions, extendedEditorProps, getEditorMetaData]
|
||||
);
|
||||
|
||||
const titleEditor = useTitleEditor(titleEditorConfig as Parameters<typeof useTitleEditor>[0]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && titleEditor) {
|
||||
setMainEditor(editor);
|
||||
setTitleEditor(titleEditor);
|
||||
}
|
||||
}, [editor, titleEditor, setMainEditor, setTitleEditor]);
|
||||
|
||||
return {
|
||||
editor,
|
||||
hasServerConnectionFailed,
|
||||
hasServerSynced,
|
||||
titleEditor,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
169
packages/editor/src/core/hooks/use-editor-navigation.ts
Normal file
169
packages/editor/src/core/hooks/use-editor-navigation.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import type { Editor } from "@tiptap/core";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Creates a title editor extension that enables keyboard navigation to the main editor
|
||||
*
|
||||
* @param getMainEditor Function to get the main editor instance
|
||||
* @returns A Tiptap extension with keyboard shortcuts
|
||||
*/
|
||||
export const createTitleNavigationExtension = (getMainEditor: () => Editor | null) =>
|
||||
Extension.create({
|
||||
name: "titleEditorNavigation",
|
||||
priority: 10,
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// Arrow down at end of title - Move to main editor
|
||||
ArrowDown: () => {
|
||||
const mainEditor = getMainEditor();
|
||||
if (!mainEditor) return false;
|
||||
|
||||
// If cursor is at the end of the title
|
||||
mainEditor.commands.focus("start");
|
||||
return true;
|
||||
},
|
||||
|
||||
// Right arrow at end of title - Move to main editor
|
||||
ArrowRight: ({ editor: titleEditor }) => {
|
||||
const mainEditor = getMainEditor();
|
||||
if (!mainEditor) return false;
|
||||
|
||||
const { from, to } = titleEditor.state.selection;
|
||||
const documentLength = titleEditor.state.doc.content.size;
|
||||
|
||||
// If cursor is at the end of the title
|
||||
if (from === to && to === documentLength - 1) {
|
||||
mainEditor.commands.focus("start");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Enter - Create new line in main editor and focus
|
||||
Enter: () => {
|
||||
const mainEditor = getMainEditor();
|
||||
if (!mainEditor) return false;
|
||||
|
||||
// Focus at the start of the main editor
|
||||
mainEditor.chain().focus().insertContentAt(0, { type: "paragraph" }).run();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a main editor extension that enables keyboard navigation to the title editor
|
||||
*
|
||||
* @param getTitleEditor Function to get the title editor instance
|
||||
* @returns A Tiptap extension with keyboard shortcuts
|
||||
*/
|
||||
export const createMainNavigationExtension = (getTitleEditor: () => Editor | null) =>
|
||||
Extension.create({
|
||||
name: "mainEditorNavigation",
|
||||
priority: 10,
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// Arrow up at start of main editor - Move to title editor
|
||||
ArrowUp: ({ editor: mainEditor }) => {
|
||||
const titleEditor = getTitleEditor();
|
||||
if (!titleEditor) return false;
|
||||
|
||||
const { from, to } = mainEditor.state.selection;
|
||||
|
||||
// If cursor is at the start of the main editor
|
||||
if (from === 1 && to === 1) {
|
||||
titleEditor.commands.focus("end");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Left arrow at start of main editor - Move to title editor
|
||||
ArrowLeft: ({ editor: mainEditor }) => {
|
||||
const titleEditor = getTitleEditor();
|
||||
if (!titleEditor) return false;
|
||||
|
||||
const { from, to } = mainEditor.state.selection;
|
||||
|
||||
// If cursor is at the absolute start of the main editor
|
||||
if (from === 1 && to === 1) {
|
||||
titleEditor.commands.focus("end");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Backspace - Special handling for first paragraph
|
||||
Backspace: ({ editor }) => {
|
||||
const titleEditor = getTitleEditor();
|
||||
if (!titleEditor) return false;
|
||||
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
|
||||
// Only handle when cursor is at position 1 with empty selection
|
||||
if (from === 1 && to === 1 && empty) {
|
||||
const firstNode = editor.state.doc.firstChild;
|
||||
|
||||
// If first node is a paragraph
|
||||
if (firstNode && firstNode.type.name === "paragraph") {
|
||||
// If paragraph is already empty, delete it and focus title editor
|
||||
if (firstNode.content.size === 0) {
|
||||
editor.commands.deleteNode("paragraph");
|
||||
// Use setTimeout to ensure the node is deleted before changing focus
|
||||
setTimeout(() => titleEditor.commands.focus("end"), 0);
|
||||
return true;
|
||||
}
|
||||
// If paragraph is not empty, just move focus to title editor
|
||||
else {
|
||||
titleEditor.commands.focus("end");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Hook to manage navigation between title and main editors
|
||||
*
|
||||
* Creates extension factories for keyboard navigation between editors
|
||||
* and maintains references to both editors
|
||||
*
|
||||
* @returns Object with editor setters and extensions
|
||||
*/
|
||||
export const useEditorNavigation = () => {
|
||||
// Create refs to store editor instances
|
||||
const titleEditorRef = useRef<Editor | null>(null);
|
||||
const mainEditorRef = useRef<Editor | null>(null);
|
||||
|
||||
// Create stable getter functions
|
||||
const getTitleEditor = useCallback(() => titleEditorRef.current, []);
|
||||
const getMainEditor = useCallback(() => mainEditorRef.current, []);
|
||||
|
||||
// Create stable setter functions
|
||||
const setTitleEditor = useCallback((editor: Editor | null) => {
|
||||
titleEditorRef.current = editor;
|
||||
}, []);
|
||||
|
||||
const setMainEditor = useCallback((editor: Editor | null) => {
|
||||
mainEditorRef.current = editor;
|
||||
}, []);
|
||||
|
||||
// Create extension factories that access editor refs
|
||||
const titleNavigationExtension = createTitleNavigationExtension(getMainEditor);
|
||||
const mainNavigationExtension = createMainNavigationExtension(getTitleEditor);
|
||||
|
||||
return {
|
||||
setTitleEditor,
|
||||
setMainEditor,
|
||||
titleNavigationExtension,
|
||||
mainNavigationExtension,
|
||||
};
|
||||
};
|
||||
91
packages/editor/src/core/hooks/use-title-editor.ts
Normal file
91
packages/editor/src/core/hooks/use-title-editor.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { useImperativeHandle } from "react";
|
||||
// constants
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
// extensions
|
||||
import { TitleExtensions } from "@/extensions/title-extension";
|
||||
// helpers
|
||||
import { getEditorRefHelpers } from "@/helpers/editor-ref";
|
||||
// types
|
||||
import type { IEditorPropsExtended, IEditorProps } from "@/types";
|
||||
import type { EditorTitleRefApi, ICollaborativeDocumentEditorProps } from "@/types/editor";
|
||||
|
||||
type Props = {
|
||||
editable?: boolean;
|
||||
provider: HocuspocusProvider;
|
||||
titleRef?: React.MutableRefObject<EditorTitleRefApi | null>;
|
||||
extensions?: Extensions;
|
||||
initialValue?: string;
|
||||
field?: string;
|
||||
placeholder?: string;
|
||||
updatePageProperties?: ICollaborativeDocumentEditorProps["updatePageProperties"];
|
||||
id: string;
|
||||
extendedEditorProps?: IEditorPropsExtended;
|
||||
getEditorMetaData?: IEditorProps["getEditorMetaData"];
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that creates a title editor with collaboration features
|
||||
* Uses the same Y.Doc as the main editor but a different field
|
||||
*/
|
||||
export const useTitleEditor = (props: Props) => {
|
||||
const {
|
||||
editable = true,
|
||||
id,
|
||||
initialValue = "",
|
||||
extensions,
|
||||
provider,
|
||||
updatePageProperties,
|
||||
titleRef,
|
||||
getEditorMetaData,
|
||||
} = props;
|
||||
|
||||
// Force editor recreation when Y.Doc changes (provider.document.guid)
|
||||
const docKey = provider?.document?.guid ?? id;
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
onUpdate: ({ editor }) => {
|
||||
updatePageProperties?.(id, "property_updated", { name: editor?.getText() });
|
||||
},
|
||||
editable,
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: false,
|
||||
extensions: [
|
||||
...TitleExtensions,
|
||||
...(extensions ?? []),
|
||||
Placeholder.configure({
|
||||
placeholder: () => "Untitled",
|
||||
includeChildren: true,
|
||||
showOnlyWhenEditable: false,
|
||||
}),
|
||||
],
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<h1></h1>",
|
||||
},
|
||||
[editable, initialValue, docKey]
|
||||
);
|
||||
|
||||
useImperativeHandle(titleRef, () => ({
|
||||
...getEditorRefHelpers({
|
||||
editor,
|
||||
provider,
|
||||
getEditorMetaData: getEditorMetaData ?? (() => ({ file_assets: [], user_mentions: [] })),
|
||||
}),
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editor
|
||||
?.chain()
|
||||
.setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true)
|
||||
.setMeta(CORE_EDITOR_META.INTENTIONAL_DELETION, true)
|
||||
.clearContent(emitUpdate)
|
||||
.run();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editor?.commands.setContent(content, false);
|
||||
},
|
||||
}));
|
||||
|
||||
return editor;
|
||||
};
|
||||
369
packages/editor/src/core/hooks/use-yjs-setup.ts
Normal file
369
packages/editor/src/core/hooks/use-yjs-setup.ts
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
// react
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
// indexeddb
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
// yjs
|
||||
import type * as Y from "yjs";
|
||||
// types
|
||||
import type { CollaborationState, CollabStage, CollaborationError } from "@/types/collaboration";
|
||||
|
||||
// Helper to check if a close code indicates a forced close
|
||||
const isForcedCloseCode = (code: number | undefined): boolean => {
|
||||
if (!code) return false;
|
||||
// All custom close codes (4000-4003) are treated as forced closes
|
||||
return code >= 4000 && code <= 4003;
|
||||
};
|
||||
|
||||
type UseYjsSetupArgs = {
|
||||
docId: string;
|
||||
serverUrl: string;
|
||||
authToken: string;
|
||||
onStateChange?: (state: CollaborationState) => void;
|
||||
options?: {
|
||||
maxConnectionAttempts?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_RETRIES = 3;
|
||||
|
||||
export const useYjsSetup = ({ docId, serverUrl, authToken, onStateChange }: UseYjsSetupArgs) => {
|
||||
// Current collaboration stage
|
||||
const [stage, setStage] = useState<CollabStage>({ kind: "initial" });
|
||||
|
||||
// Cache readiness state
|
||||
const [hasCachedContent, setHasCachedContent] = useState(false);
|
||||
const [isCacheReady, setIsCacheReady] = useState(false);
|
||||
|
||||
// Provider and Y.Doc in state (nullable until effect runs)
|
||||
const [yjsSession, setYjsSession] = useState<{ provider: HocuspocusProvider; ydoc: Y.Doc } | null>(null);
|
||||
|
||||
// Use refs for values that need to be mutated from callbacks
|
||||
const retryCountRef = useRef(0);
|
||||
const forcedCloseSignalRef = useRef(false);
|
||||
const isDisposedRef = useRef(false);
|
||||
const stageRef = useRef<CollabStage>({ kind: "initial" });
|
||||
const lastReconnectTimeRef = useRef(0);
|
||||
|
||||
// Create/destroy provider in effect (not during render)
|
||||
useEffect(() => {
|
||||
// Reset refs when creating new provider (e.g., document switch)
|
||||
retryCountRef.current = 0;
|
||||
isDisposedRef.current = false;
|
||||
forcedCloseSignalRef.current = false;
|
||||
stageRef.current = { kind: "initial" };
|
||||
|
||||
const provider = new HocuspocusProvider({
|
||||
name: docId,
|
||||
token: authToken,
|
||||
url: serverUrl,
|
||||
onAuthenticationFailed: () => {
|
||||
if (isDisposedRef.current) return;
|
||||
const error: CollaborationError = { type: "auth-failed", message: "Authentication failed" };
|
||||
const newStage = { kind: "disconnected" as const, error };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
},
|
||||
onConnect: () => {
|
||||
if (isDisposedRef.current) {
|
||||
provider?.disconnect();
|
||||
return;
|
||||
}
|
||||
retryCountRef.current = 0;
|
||||
// After successful connection, transition to awaiting-sync (onSynced will move to synced)
|
||||
const newStage = { kind: "awaiting-sync" as const };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
},
|
||||
onStatus: ({ status: providerStatus }) => {
|
||||
if (isDisposedRef.current) return;
|
||||
if (providerStatus === "connecting") {
|
||||
// Derive whether this is initial connect or reconnection from retry count
|
||||
const isReconnecting = retryCountRef.current > 0;
|
||||
setStage(isReconnecting ? { kind: "reconnecting", attempt: retryCountRef.current } : { kind: "connecting" });
|
||||
} else if (providerStatus === "disconnected") {
|
||||
// Do not transition here; let handleClose decide the final stage
|
||||
} else if (providerStatus === "connected") {
|
||||
// Connection succeeded, move to awaiting-sync
|
||||
const newStage = { kind: "awaiting-sync" as const };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
}
|
||||
},
|
||||
onSynced: () => {
|
||||
if (isDisposedRef.current) return;
|
||||
retryCountRef.current = 0;
|
||||
// Document sync complete
|
||||
const newStage = { kind: "synced" as const };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
},
|
||||
});
|
||||
|
||||
const pauseProvider = () => {
|
||||
const wsProvider = provider.configuration.websocketProvider;
|
||||
if (wsProvider) {
|
||||
try {
|
||||
wsProvider.shouldConnect = false;
|
||||
wsProvider.disconnect();
|
||||
} catch (error) {
|
||||
console.error(`Error pausing websocketProvider:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const permanentlyStopProvider = () => {
|
||||
isDisposedRef.current = true;
|
||||
|
||||
const wsProvider = provider.configuration.websocketProvider;
|
||||
if (wsProvider) {
|
||||
try {
|
||||
wsProvider.shouldConnect = false;
|
||||
wsProvider.disconnect();
|
||||
wsProvider.destroy();
|
||||
} catch (error) {
|
||||
console.error(`Error tearing down websocketProvider:`, error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
provider.destroy();
|
||||
} catch (error) {
|
||||
console.error(`Error destroying provider:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (closeEvent: { event?: { code?: number; reason?: string } }) => {
|
||||
if (isDisposedRef.current) return;
|
||||
|
||||
const closeCode = closeEvent.event?.code;
|
||||
const wsProvider = provider.configuration.websocketProvider;
|
||||
const shouldConnect = wsProvider.shouldConnect;
|
||||
const isForcedClose = isForcedCloseCode(closeCode) || forcedCloseSignalRef.current || shouldConnect === false;
|
||||
|
||||
if (isForcedClose) {
|
||||
// Determine if this is a manual disconnect or a permanent error
|
||||
const isManualDisconnect = shouldConnect === false;
|
||||
|
||||
const error: CollaborationError = {
|
||||
type: "forced-close",
|
||||
code: closeCode || 0,
|
||||
message: isManualDisconnect ? "Manually disconnected" : "Server forced connection close",
|
||||
};
|
||||
const newStage = { kind: "disconnected" as const, error };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
|
||||
retryCountRef.current = 0;
|
||||
forcedCloseSignalRef.current = false;
|
||||
|
||||
// Only pause if it's a real forced close (not manual disconnect)
|
||||
// Manual disconnect leaves it as is (shouldConnect=false already set if manual)
|
||||
if (!isManualDisconnect) {
|
||||
pauseProvider();
|
||||
}
|
||||
} else {
|
||||
// Transient connection loss: attempt reconnection
|
||||
retryCountRef.current++;
|
||||
|
||||
if (retryCountRef.current >= DEFAULT_MAX_RETRIES) {
|
||||
// Exceeded max retry attempts
|
||||
const error: CollaborationError = {
|
||||
type: "max-retries",
|
||||
message: `Failed to connect after ${DEFAULT_MAX_RETRIES} attempts`,
|
||||
};
|
||||
const newStage = { kind: "disconnected" as const, error };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
|
||||
pauseProvider();
|
||||
} else {
|
||||
// Still have retries left, move to reconnecting
|
||||
const newStage = { kind: "reconnecting" as const, attempt: retryCountRef.current };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
provider.on("close", handleClose);
|
||||
|
||||
setYjsSession({ provider, ydoc: provider.document as Y.Doc });
|
||||
|
||||
// Handle page visibility changes (sleep/wake, tab switching)
|
||||
const handleVisibilityChange = (event?: Event) => {
|
||||
if (isDisposedRef.current) return;
|
||||
|
||||
const isVisible = document.visibilityState === "visible";
|
||||
const isFocus = event?.type === "focus";
|
||||
|
||||
if (isVisible || isFocus) {
|
||||
// Throttle reconnection attempts to avoid double-firing (visibility + focus)
|
||||
const now = Date.now();
|
||||
if (now - lastReconnectTimeRef.current < 1000) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wsProvider = provider.configuration.websocketProvider;
|
||||
if (!wsProvider) return;
|
||||
|
||||
const ws = wsProvider.webSocket;
|
||||
const isStale = ws?.readyState === WebSocket.CLOSED || ws?.readyState === WebSocket.CLOSING;
|
||||
|
||||
// If disconnected or stale, re-enable reconnection and force reconnect
|
||||
if (isStale || stageRef.current.kind === "disconnected") {
|
||||
lastReconnectTimeRef.current = now;
|
||||
|
||||
// Re-enable connection on tab focus (even if manually disconnected before sleep)
|
||||
wsProvider.shouldConnect = true;
|
||||
|
||||
// Reset retry count for fresh reconnection attempt
|
||||
retryCountRef.current = 0;
|
||||
|
||||
// Move to connecting state
|
||||
const newStage = { kind: "connecting" as const };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
|
||||
wsProvider.disconnect();
|
||||
wsProvider.connect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle online/offline events
|
||||
const handleOnline = () => {
|
||||
if (isDisposedRef.current) return;
|
||||
|
||||
const wsProvider = provider.configuration.websocketProvider;
|
||||
if (wsProvider) {
|
||||
wsProvider.shouldConnect = true;
|
||||
wsProvider.disconnect();
|
||||
wsProvider.connect();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.addEventListener("focus", handleVisibilityChange);
|
||||
window.addEventListener("online", handleOnline);
|
||||
|
||||
return () => {
|
||||
try {
|
||||
provider.off("close", handleClose);
|
||||
} catch (error) {
|
||||
console.error(`Error unregistering close handler:`, error);
|
||||
}
|
||||
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("focus", handleVisibilityChange);
|
||||
window.removeEventListener("online", handleOnline);
|
||||
|
||||
permanentlyStopProvider();
|
||||
};
|
||||
}, [docId, serverUrl, authToken]);
|
||||
|
||||
// IndexedDB persistence lifecycle
|
||||
useEffect(() => {
|
||||
if (!yjsSession) return;
|
||||
|
||||
const idbPersistence = new IndexeddbPersistence(docId, yjsSession.provider.document);
|
||||
|
||||
const onIdbSynced = () => {
|
||||
const yFragment = idbPersistence.doc.getXmlFragment("default");
|
||||
const docLength = yFragment?.length ?? 0;
|
||||
setIsCacheReady(true);
|
||||
setHasCachedContent(docLength > 0);
|
||||
};
|
||||
|
||||
idbPersistence.on("synced", onIdbSynced);
|
||||
|
||||
return () => {
|
||||
idbPersistence.off("synced", onIdbSynced);
|
||||
try {
|
||||
idbPersistence.destroy();
|
||||
} catch (error) {
|
||||
console.error(`Error destroying local provider:`, error);
|
||||
}
|
||||
};
|
||||
}, [docId, yjsSession]);
|
||||
|
||||
// Observe Y.Doc content changes to update hasCachedContent (catches fallback scenario)
|
||||
useEffect(() => {
|
||||
if (!yjsSession || !isCacheReady) return;
|
||||
|
||||
const fragment = yjsSession.ydoc.getXmlFragment("default");
|
||||
let lastHasContent = false;
|
||||
|
||||
const updateCachedContentFlag = () => {
|
||||
const len = fragment?.length ?? 0;
|
||||
const hasContent = len > 0;
|
||||
|
||||
// Only update state if the boolean value actually changed
|
||||
if (hasContent !== lastHasContent) {
|
||||
lastHasContent = hasContent;
|
||||
setHasCachedContent(hasContent);
|
||||
}
|
||||
};
|
||||
// Initial check (handles fallback content loaded before this effect runs)
|
||||
updateCachedContentFlag();
|
||||
|
||||
// Use observeDeep to catch nested changes (keystrokes modify Y.XmlText inside Y.XmlElement)
|
||||
fragment.observeDeep(updateCachedContentFlag);
|
||||
|
||||
return () => {
|
||||
try {
|
||||
fragment.unobserveDeep(updateCachedContentFlag);
|
||||
} catch (error) {
|
||||
console.error("Error unobserving fragment:", error);
|
||||
}
|
||||
};
|
||||
}, [yjsSession, isCacheReady]);
|
||||
|
||||
// Notify state changes callback (use ref to avoid dependency on handler)
|
||||
const stateChangeCallbackRef = useRef(onStateChange);
|
||||
stateChangeCallbackRef.current = onStateChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!stateChangeCallbackRef.current) return;
|
||||
|
||||
const isServerSynced = stage.kind === "synced";
|
||||
const isServerDisconnected = stage.kind === "disconnected";
|
||||
|
||||
const state: CollaborationState = {
|
||||
stage,
|
||||
isServerSynced,
|
||||
isServerDisconnected,
|
||||
};
|
||||
|
||||
stateChangeCallbackRef.current(state);
|
||||
}, [stage]);
|
||||
|
||||
// Derived values for convenience
|
||||
const isServerSynced = stage.kind === "synced";
|
||||
const isServerDisconnected = stage.kind === "disconnected";
|
||||
const isDocReady = isServerSynced || isServerDisconnected || (isCacheReady && hasCachedContent);
|
||||
|
||||
const signalForcedClose = useCallback((value: boolean) => {
|
||||
forcedCloseSignalRef.current = value;
|
||||
}, []);
|
||||
|
||||
// Don't return anything until provider is ready - guarantees non-null provider
|
||||
if (!yjsSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: yjsSession.provider,
|
||||
ydoc: yjsSession.ydoc,
|
||||
state: {
|
||||
stage,
|
||||
hasCachedContent,
|
||||
isCacheReady,
|
||||
isServerSynced,
|
||||
isServerDisconnected,
|
||||
isDocReady,
|
||||
},
|
||||
actions: {
|
||||
signalForcedClose,
|
||||
},
|
||||
};
|
||||
};
|
||||
92
packages/editor/src/core/plugins/highlight.ts
Normal file
92
packages/editor/src/core/plugins/highlight.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
|
||||
type NodeHighlightState = {
|
||||
highlightedNodeId: string | null;
|
||||
decorations: DecorationSet;
|
||||
};
|
||||
|
||||
type NodeHighlightMeta = {
|
||||
nodeId?: string | null;
|
||||
};
|
||||
|
||||
export const nodeHighlightPluginKey = new PluginKey<NodeHighlightState>("nodeHighlight");
|
||||
|
||||
const buildDecorations = (doc: Parameters<typeof DecorationSet.create>[0], highlightedNodeId: string | null) => {
|
||||
if (!highlightedNodeId) {
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
const decorations: Decoration[] = [];
|
||||
const highlightClassNames = ["bg-custom-primary-100/20", "transition-all", "duration-300", "rounded"];
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
// Check if this node has the id we're looking for
|
||||
if (node.attrs && node.attrs.id === highlightedNodeId) {
|
||||
const decorationAttrs: Record<string, string> = {
|
||||
"data-node-highlighted": "true",
|
||||
class: highlightClassNames.join(" "),
|
||||
};
|
||||
|
||||
// For text nodes, highlight the inline content
|
||||
if (node.isText) {
|
||||
decorations.push(
|
||||
Decoration.inline(pos, pos + node.nodeSize, decorationAttrs, {
|
||||
inclusiveStart: true,
|
||||
inclusiveEnd: true,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// For block nodes, add a node decoration
|
||||
decorations.push(Decoration.node(pos, pos + node.nodeSize, decorationAttrs));
|
||||
}
|
||||
|
||||
return false; // Stop searching once we found the node
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
};
|
||||
|
||||
export const NodeHighlightPlugin = () =>
|
||||
new Plugin<NodeHighlightState>({
|
||||
key: nodeHighlightPluginKey,
|
||||
state: {
|
||||
init: () => ({
|
||||
highlightedNodeId: null,
|
||||
decorations: DecorationSet.empty,
|
||||
}),
|
||||
apply: (tr, value, _oldState, newState) => {
|
||||
let highlightedNodeId = value.highlightedNodeId;
|
||||
let decorations = value.decorations;
|
||||
|
||||
const meta = tr.getMeta(nodeHighlightPluginKey) as NodeHighlightMeta | undefined;
|
||||
let shouldRecalculate = tr.docChanged;
|
||||
|
||||
if (meta) {
|
||||
if (meta.nodeId !== undefined) {
|
||||
highlightedNodeId = typeof meta.nodeId === "string" && meta.nodeId.length > 0 ? meta.nodeId : null;
|
||||
shouldRecalculate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRecalculate) {
|
||||
decorations = buildDecorations(newState.doc, highlightedNodeId);
|
||||
} else if (tr.docChanged) {
|
||||
decorations = decorations.map(tr.mapping, newState.doc);
|
||||
}
|
||||
|
||||
return {
|
||||
highlightedNodeId,
|
||||
decorations,
|
||||
};
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return nodeHighlightPluginKey.getState(state)?.decorations ?? DecorationSet.empty;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,4 +1,37 @@
|
|||
export type TServerHandler = {
|
||||
onConnect?: () => void;
|
||||
onServerError?: () => void;
|
||||
export type CollaborationError =
|
||||
| { type: "auth-failed"; message: string }
|
||||
| { type: "network-error"; message: string }
|
||||
| { type: "forced-close"; code: number; message: string }
|
||||
| { type: "max-retries"; message: string };
|
||||
|
||||
/**
|
||||
* Single-stage state machine for collaboration lifecycle.
|
||||
* Stages represent the sequential progression: initial → connecting → awaiting-sync → synced
|
||||
*
|
||||
* Invariants:
|
||||
* - "awaiting-sync" only occurs when connection is successful and sync is pending
|
||||
* - "synced" occurs only after connection success and onSynced callback
|
||||
* - "reconnecting" with attempt > 0 when retrying after a connection drop
|
||||
* - "disconnected" is terminal (connection failed or forced close)
|
||||
*/
|
||||
export type CollabStage =
|
||||
| { kind: "initial" }
|
||||
| { kind: "connecting" }
|
||||
| { kind: "awaiting-sync" }
|
||||
| { kind: "synced" }
|
||||
| { kind: "reconnecting"; attempt: number }
|
||||
| { kind: "disconnected"; error: CollaborationError };
|
||||
|
||||
/**
|
||||
* Public collaboration state exposed to consumers.
|
||||
* Contains the current stage and derived booleans for convenience.
|
||||
*/
|
||||
export type CollaborationState = {
|
||||
stage: CollabStage;
|
||||
isServerSynced: boolean;
|
||||
isServerDisconnected: boolean;
|
||||
};
|
||||
|
||||
export type TServerHandler = {
|
||||
onStateChange: (state: CollaborationState) => void;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import type {
|
|||
TRealtimeConfig,
|
||||
TServerHandler,
|
||||
TUserDetails,
|
||||
TExtendedEditorRefApi,
|
||||
EventToPayloadMap,
|
||||
} from "@/types";
|
||||
|
||||
export type TEditorCommands =
|
||||
|
|
@ -97,7 +99,7 @@ export type TDocumentInfo = {
|
|||
words: number;
|
||||
};
|
||||
|
||||
export type EditorRefApi = {
|
||||
export type CoreEditorRefApi = {
|
||||
blur: () => void;
|
||||
clearEditor: (emitUpdate?: boolean) => void;
|
||||
createSelectionAtCursorPosition: () => void;
|
||||
|
|
@ -138,6 +140,10 @@ export type EditorRefApi = {
|
|||
undo: () => void;
|
||||
};
|
||||
|
||||
export type EditorRefApi = CoreEditorRefApi & TExtendedEditorRefApi;
|
||||
|
||||
export type EditorTitleRefApi = EditorRefApi;
|
||||
|
||||
// editor props
|
||||
export type IEditorProps = {
|
||||
autofocus?: boolean;
|
||||
|
|
@ -185,6 +191,15 @@ export type ICollaborativeDocumentEditorProps = Omit<IEditorProps, "initialValue
|
|||
serverHandler?: TServerHandler;
|
||||
user: TUserDetails;
|
||||
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
|
||||
updatePageProperties?: <T extends keyof EventToPayloadMap>(
|
||||
pageIds: string | string[],
|
||||
actionType: T,
|
||||
data: EventToPayloadMap[T],
|
||||
performAction?: boolean
|
||||
) => void;
|
||||
pageRestorationInProgress?: boolean;
|
||||
titleRef?: React.MutableRefObject<EditorTitleRefApi | null>;
|
||||
isFetchingFallbackBinary?: boolean;
|
||||
};
|
||||
|
||||
export type IDocumentEditorProps = Omit<IEditorProps, "initialValue" | "onEnterKeyPress" | "value"> & {
|
||||
|
|
|
|||
|
|
@ -55,4 +55,7 @@ export type TCollaborativeEditorHookProps = TCoreHookProps &
|
|||
Pick<
|
||||
ICollaborativeDocumentEditorProps,
|
||||
"dragDropEnabled" | "extendedDocumentEditorProps" | "realtimeConfig" | "serverHandler" | "user"
|
||||
>;
|
||||
> & {
|
||||
titleRef?: ICollaborativeDocumentEditorProps["titleRef"];
|
||||
updatePageProperties?: ICollaborativeDocumentEditorProps["updatePageProperties"];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@
|
|||
@import "./table.css";
|
||||
@import "./github-dark.css";
|
||||
@import "./drag-drop.css";
|
||||
@import "./title-editor.css";
|
||||
|
|
|
|||
49
packages/editor/src/styles/title-editor.css
Normal file
49
packages/editor/src/styles/title-editor.css
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/* Title editor styles */
|
||||
.page-title-editor {
|
||||
width: 100%;
|
||||
outline: none;
|
||||
resize: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.page-title-editor .ProseMirror {
|
||||
background-color: transparent;
|
||||
font-weight: bold;
|
||||
letter-spacing: -2%;
|
||||
padding: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Handle font sizes */
|
||||
.page-title-editor.small-font .ProseMirror h1 {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.9rem;
|
||||
}
|
||||
|
||||
.page-title-editor.large-font .ProseMirror h1 {
|
||||
font-size: 2rem;
|
||||
line-height: 2.375rem;
|
||||
}
|
||||
|
||||
/* Focus state */
|
||||
.page-title-editor.active-editor .ProseMirror {
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.page-title-editor .ProseMirror h1.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: var(--color-placeholder);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.page-title-editor .ProseMirror h1.is-empty::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: var(--color-placeholder);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue