[WIKI-400] feat: page navigation pane (#7206)
* init: page navigation pane * chore: outline and info tabs * chore: asset download endpoint * chore: realtime document info updates * chore: add support for code splitting * fix: formatting * refactor: image block id generation * chore: implement translation * refactor: assets list storage logic * fix: build errors * fix: image extension name * refactor: add support for additional asset items * refactor: asset extraction logic * chore: add translations * fix: merge conflicts resolved from preview * chore: remove version history option from the dropdown * chore: query params handling * chore: remove unnecessary logic * refactor: empty state components * fix: empty state asset path
This commit is contained in:
parent
cfe169c6d7
commit
0b159c4963
83 changed files with 2185 additions and 767 deletions
6
packages/editor/src/ce/constants/assets.ts
Normal file
6
packages/editor/src/ce/constants/assets.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// helpers
|
||||
import { TAssetMetaDataRecord } from "@/helpers/assets";
|
||||
// local imports
|
||||
import { ADDITIONAL_EXTENSIONS } from "./extensions";
|
||||
|
||||
export const ADDITIONAL_ASSETS_META_DATA_RECORD: Partial<Record<ADDITIONAL_EXTENSIONS, TAssetMetaDataRecord>> = {};
|
||||
1
packages/editor/src/ce/constants/extensions.ts
Normal file
1
packages/editor/src/ce/constants/extensions.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export enum ADDITIONAL_EXTENSIONS {}
|
||||
1
packages/editor/src/ce/types/asset.ts
Normal file
1
packages/editor/src/ce/types/asset.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type TAdditionalEditorAsset = never;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { CharacterCountStorage } from "@tiptap/extension-character-count";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
|
|
@ -15,6 +16,7 @@ export type ExtensionStorageMap = {
|
|||
[CORE_EXTENSIONS.HEADINGS_LIST]: HeadingExtensionStorage;
|
||||
[CORE_EXTENSIONS.MENTION]: MentionExtensionStorage;
|
||||
[CORE_EXTENSIONS.UTILITY]: UtilityExtensionStorage;
|
||||
[CORE_EXTENSIONS.CHARACTER_COUNT]: CharacterCountStorage;
|
||||
};
|
||||
|
||||
export type ExtensionFileSetStorageKey = Extract<keyof ImageExtensionStorage, "deletedImageSet">;
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
|
|||
|
||||
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
|
||||
const {
|
||||
onChange,
|
||||
onTransaction,
|
||||
aiHandler,
|
||||
bubbleMenuEnabled = true,
|
||||
containerClassName,
|
||||
|
|
@ -33,6 +31,9 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
|||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
|
|
@ -63,6 +64,7 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
|||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from
|
|||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
|
||||
import { ensurePixelString } from "../utils";
|
||||
import { ensurePixelString, getImageBlockId } from "../utils";
|
||||
import type { CustomImageNodeViewProps } from "./node-view";
|
||||
import { ImageToolbarRoot } from "./toolbar";
|
||||
import { ImageUploadStatus } from "./upload-status";
|
||||
|
|
@ -196,6 +196,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
|||
|
||||
return (
|
||||
<div
|
||||
id={getImageBlockId(node.attrs.id ?? "")}
|
||||
ref={containerRef}
|
||||
className="group/image-component relative inline-block max-w-full"
|
||||
onMouseDown={handleImageMouseDown}
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
|||
}
|
||||
|
||||
return "Add an image";
|
||||
}, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]);
|
||||
}, [draggedInside, editor.isEditable, failedToLoadImage, isImageBeingUploaded]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -31,3 +31,5 @@ export const ensurePixelString = <TDefault>(
|
|||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const getImageBlockId = (id: string) => `editor-image-block-${id}`;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Extension } from "@tiptap/core";
|
||||
// prosemirror plugins
|
||||
import codemark from "prosemirror-codemark";
|
||||
// helpers
|
||||
import { restorePublicImages } from "@/helpers/image-helpers";
|
||||
|
|
@ -8,17 +7,27 @@ import { DropHandlerPlugin } from "@/plugins/drop";
|
|||
import { FilePlugins } from "@/plugins/file/root";
|
||||
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
|
||||
// types
|
||||
import type { IEditorProps, TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
import type { IEditorProps, TEditorAsset, TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands {
|
||||
utility: {
|
||||
updateAssetsUploadStatus: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
|
||||
updateAssetsList: (
|
||||
args:
|
||||
| {
|
||||
asset: TEditorAsset;
|
||||
}
|
||||
| {
|
||||
idToRemove: string;
|
||||
}
|
||||
) => () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface UtilityExtensionStorage {
|
||||
assetsList: TEditorAsset[];
|
||||
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
|
||||
uploadInProgress: boolean;
|
||||
}
|
||||
|
|
@ -58,6 +67,7 @@ export const UtilityExtension = (props: Props) => {
|
|||
|
||||
addStorage() {
|
||||
return {
|
||||
assetsList: [],
|
||||
assetsUploadStatus: isEditable && "assetsUploadStatus" in fileHandler ? fileHandler.assetsUploadStatus : {},
|
||||
uploadInProgress: false,
|
||||
};
|
||||
|
|
@ -68,6 +78,21 @@ export const UtilityExtension = (props: Props) => {
|
|||
updateAssetsUploadStatus: (updatedStatus) => () => {
|
||||
this.storage.assetsUploadStatus = updatedStatus;
|
||||
},
|
||||
updateAssetsList: (args) => () => {
|
||||
const uniqueAssets = new Set(this.storage.assetsList);
|
||||
if ("asset" in args) {
|
||||
const alreadyExists = this.storage.assetsList.find((asset) => asset.id === args.asset.id);
|
||||
if (!alreadyExists) {
|
||||
uniqueAssets.add(args.asset);
|
||||
}
|
||||
} else if ("idToRemove" in args) {
|
||||
const asset = this.storage.assetsList.find((asset) => asset.id === args.idToRemove);
|
||||
if (asset) {
|
||||
uniqueAssets.delete(asset);
|
||||
}
|
||||
}
|
||||
this.storage.assetsList = Array.from(uniqueAssets);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
37
packages/editor/src/core/helpers/assets.ts
Normal file
37
packages/editor/src/core/helpers/assets.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { getImageBlockId } from "@/extensions/custom-image/utils";
|
||||
// plane editor imports
|
||||
import { ADDITIONAL_ASSETS_META_DATA_RECORD } from "@/plane-editor/constants/assets";
|
||||
// types
|
||||
import { TEditorAsset } from "@/types";
|
||||
|
||||
export type TAssetMetaDataRecord = (attrs: ProseMirrorNode["attrs"]) => TEditorAsset | undefined;
|
||||
|
||||
export const CORE_ASSETS_META_DATA_RECORD: Partial<Record<CORE_EXTENSIONS, TAssetMetaDataRecord>> = {
|
||||
[CORE_EXTENSIONS.IMAGE]: (attrs) => {
|
||||
if (!attrs?.src) return;
|
||||
return {
|
||||
href: `#${getImageBlockId(attrs?.id ?? "")}`,
|
||||
id: attrs?.id,
|
||||
name: `image-${attrs?.id}`,
|
||||
size: 0,
|
||||
src: attrs?.src,
|
||||
type: CORE_EXTENSIONS.IMAGE,
|
||||
};
|
||||
},
|
||||
[CORE_EXTENSIONS.CUSTOM_IMAGE]: (attrs) => {
|
||||
if (!attrs?.src) return;
|
||||
return {
|
||||
href: `#${getImageBlockId(attrs?.id ?? "")}`,
|
||||
id: attrs?.id,
|
||||
name: `image-${attrs?.id}`,
|
||||
size: 0,
|
||||
src: attrs?.src,
|
||||
type: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
};
|
||||
},
|
||||
...ADDITIONAL_ASSETS_META_DATA_RECORD,
|
||||
};
|
||||
55
packages/editor/src/core/helpers/editor-ref.ts
Normal file
55
packages/editor/src/core/helpers/editor-ref.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import * as Y from "yjs";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi } from "@/types";
|
||||
// local imports
|
||||
import { getParagraphCount } from "./common";
|
||||
import { getExtensionStorage } from "./get-extension-storage";
|
||||
import { scrollSummary } from "./scroll-to-node";
|
||||
|
||||
type TArgs = {
|
||||
editor: Editor | null;
|
||||
provider: HocuspocusProvider | undefined;
|
||||
};
|
||||
|
||||
export const getEditorRefHelpers = (args: TArgs): EditorReadOnlyRefApi => {
|
||||
const { editor, provider } = args;
|
||||
|
||||
return {
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
|
||||
},
|
||||
getDocument: () => {
|
||||
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
||||
const documentHTML = editor?.getHTML() ?? "<p></p>";
|
||||
const documentJSON = editor?.getJSON() ?? null;
|
||||
|
||||
return {
|
||||
binary: documentBinary,
|
||||
html: documentHTML,
|
||||
json: documentJSON,
|
||||
};
|
||||
},
|
||||
getDocumentInfo: () => ({
|
||||
characters: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.characters?.() : 0,
|
||||
paragraphs: getParagraphCount(editor?.state),
|
||||
words: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.words?.() : 0,
|
||||
}),
|
||||
getHeadings: () => (editor ? getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings : []),
|
||||
getMarkDown: () => {
|
||||
const markdownOutput = editor?.storage?.markdown?.getMarkdown?.();
|
||||
return markdownOutput;
|
||||
},
|
||||
scrollSummary: (marking) => {
|
||||
if (!editor) return;
|
||||
scrollSummary(editor, marking);
|
||||
},
|
||||
setEditorValue: (content, emitUpdate = false) => {
|
||||
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -13,6 +13,7 @@ import { TCollaborativeEditorHookProps } from "@/types";
|
|||
|
||||
export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => {
|
||||
const {
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onTransaction,
|
||||
disabledExtensions,
|
||||
|
|
@ -106,6 +107,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
|||
forwardedRef,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import { useEditor as useTiptapEditor } from "@tiptap/react";
|
||||
import { useEditorState, useEditor as useTiptapEditor } from "@tiptap/react";
|
||||
import { useImperativeHandle, useEffect } from "react";
|
||||
import * as Y from "yjs";
|
||||
// components
|
||||
import { getEditorMenuItems } from "@/components/menus";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
// extensions
|
||||
import { CoreEditorExtensions } from "@/extensions";
|
||||
// helpers
|
||||
import { getParagraphCount } from "@/helpers/common";
|
||||
import { getEditorRefHelpers } from "@/helpers/editor-ref";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position";
|
||||
import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helpers/scroll-to-node";
|
||||
import { scrollToNodeViaDOMCoordinates } from "@/helpers/scroll-to-node";
|
||||
// props
|
||||
import { CoreEditorProps } from "@/props";
|
||||
// types
|
||||
import type { TDocumentEventsServer, TEditorCommands, TEditorHookProps } from "@/types";
|
||||
import type { TEditorCommands, TEditorHookProps } from "@/types";
|
||||
|
||||
export const useEditor = (props: TEditorHookProps) => {
|
||||
const {
|
||||
|
|
@ -35,6 +35,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
|||
id = "",
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
|
|
@ -109,27 +110,26 @@ export const useEditor = (props: TEditorHookProps) => {
|
|||
editor.commands.updateAssetsUploadStatus?.(assetsUploadStatus);
|
||||
}, [editor, fileHandler.assetsUploadStatus]);
|
||||
|
||||
// subscribe to assets list changes
|
||||
const assetsList = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) => ({
|
||||
assets: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.assetsList : [],
|
||||
}),
|
||||
});
|
||||
// trigger callback when assets list changes
|
||||
useEffect(() => {
|
||||
const assets = assetsList?.assets;
|
||||
if (!assets || !onAssetChange) return;
|
||||
onAssetChange(assets);
|
||||
}, [assetsList?.assets, onAssetChange]);
|
||||
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
...getEditorRefHelpers({ editor, provider }),
|
||||
blur: () => editor?.commands.blur(),
|
||||
scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) {
|
||||
const resolvedPos = pos ?? editor?.state.selection.from;
|
||||
if (!editor || !resolvedPos) return;
|
||||
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
|
||||
},
|
||||
getCurrentCursorPosition: () => editor?.state.selection.from,
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
|
||||
},
|
||||
setEditorValue: (content: string, emitUpdate = false) => {
|
||||
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
|
||||
},
|
||||
setEditorValueAtCursorPosition: (content: string) => {
|
||||
if (editor?.state.selection) {
|
||||
insertContentAtSavedSelection(editor, content);
|
||||
}
|
||||
},
|
||||
emitRealTimeUpdate: (message) => provider?.sendStateless(message),
|
||||
executeMenuItemCommand: (props) => {
|
||||
const { itemKey } = props;
|
||||
const editorItems = getEditorMenuItems(editor);
|
||||
|
|
@ -143,83 +143,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
|||
console.warn(`No command found for item: ${itemKey}`);
|
||||
}
|
||||
},
|
||||
isMenuItemActive: (props) => {
|
||||
const { itemKey } = props;
|
||||
const editorItems = getEditorMenuItems(editor);
|
||||
|
||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||
const item = getEditorMenuItem(itemKey);
|
||||
if (!item) return false;
|
||||
|
||||
return item.isActive(props);
|
||||
},
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
||||
// Subscribe to update event emitted from headers extension
|
||||
editor?.on("update", () => {
|
||||
const headings = getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings;
|
||||
if (headings) {
|
||||
callback(headings);
|
||||
}
|
||||
});
|
||||
// Return a function to unsubscribe to the continuous transactions of
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editor?.off("update");
|
||||
};
|
||||
},
|
||||
getHeadings: () => (editor ? getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings : []),
|
||||
onStateChange: (callback: () => void) => {
|
||||
// Subscribe to editor state changes
|
||||
editor?.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
|
||||
return () => {
|
||||
editor?.off("transaction");
|
||||
};
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||
return markdownOutput;
|
||||
},
|
||||
getDocument: () => {
|
||||
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
||||
const documentHTML = editor?.getHTML() ?? "<p></p>";
|
||||
const documentJSON = editor?.getJSON() ?? null;
|
||||
|
||||
return {
|
||||
binary: documentBinary,
|
||||
html: documentHTML,
|
||||
json: documentJSON,
|
||||
};
|
||||
},
|
||||
scrollSummary: (marking: IMarking): void => {
|
||||
if (!editor) return;
|
||||
scrollSummary(editor, marking);
|
||||
},
|
||||
isEditorReadyToDiscard: () =>
|
||||
!!editor && getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress === false,
|
||||
setFocusAtPosition: (position: number) => {
|
||||
if (!editor || editor.isDestroyed) {
|
||||
console.error("Editor reference is not available or has been destroyed.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const docSize = editor.state.doc.content.size;
|
||||
const safePosition = Math.max(0, Math.min(position, docSize));
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(safePosition, [{ type: CORE_EXTENSIONS.PARAGRAPH }])
|
||||
.focus()
|
||||
.run();
|
||||
} catch (error) {
|
||||
console.error("An error occurred while setting focus at position:", error);
|
||||
}
|
||||
},
|
||||
getCurrentCursorPosition: () => editor?.state.selection.from,
|
||||
getSelectedText: () => {
|
||||
if (!editor) return null;
|
||||
|
||||
|
|
@ -253,18 +177,99 @@ export const useEditor = (props: TEditorHookProps) => {
|
|||
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
|
||||
}
|
||||
},
|
||||
getDocumentInfo: () => ({
|
||||
characters: editor?.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editor?.state),
|
||||
words: editor?.storage?.characterCount?.words?.() ?? 0,
|
||||
}),
|
||||
isEditorReadyToDiscard: () =>
|
||||
!!editor && getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress === false,
|
||||
isMenuItemActive: (props) => {
|
||||
const { itemKey } = props;
|
||||
const editorItems = getEditorMenuItems(editor);
|
||||
|
||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||
const item = getEditorMenuItem(itemKey);
|
||||
if (!item) return false;
|
||||
|
||||
return item.isActive(props);
|
||||
},
|
||||
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
||||
onDocumentInfoChange: (callback) => {
|
||||
const handleDocumentInfoChange = () => {
|
||||
if (!editor) return;
|
||||
callback({
|
||||
characters: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.characters?.() : 0,
|
||||
paragraphs: getParagraphCount(editor?.state),
|
||||
words: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.words?.() : 0,
|
||||
});
|
||||
};
|
||||
|
||||
// Subscribe to update event emitted from character count extension
|
||||
editor?.on("update", handleDocumentInfoChange);
|
||||
// Return a function to unsubscribe to the continuous transactions of
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editor?.off("update", handleDocumentInfoChange);
|
||||
};
|
||||
},
|
||||
onHeadingChange: (callback) => {
|
||||
const handleHeadingChange = () => {
|
||||
if (!editor) return;
|
||||
const headings = getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings;
|
||||
if (headings) {
|
||||
callback(headings);
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to update event emitted from headers extension
|
||||
editor?.on("update", handleHeadingChange);
|
||||
// Return a function to unsubscribe to the continuous transactions of
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editor?.off("update", handleHeadingChange);
|
||||
};
|
||||
},
|
||||
onStateChange: (callback) => {
|
||||
// Subscribe to editor state changes
|
||||
editor?.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
|
||||
return () => {
|
||||
editor?.off("transaction", callback);
|
||||
};
|
||||
},
|
||||
scrollToNodeViaDOMCoordinates(behavior, pos) {
|
||||
const resolvedPos = pos ?? editor?.state.selection.from;
|
||||
if (!editor || !resolvedPos) return;
|
||||
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
|
||||
},
|
||||
setEditorValueAtCursorPosition: (content) => {
|
||||
if (editor?.state.selection) {
|
||||
insertContentAtSavedSelection(editor, content);
|
||||
}
|
||||
},
|
||||
setFocusAtPosition: (position) => {
|
||||
if (!editor || editor.isDestroyed) {
|
||||
console.error("Editor reference is not available or has been destroyed.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const docSize = editor.state.doc.content.size;
|
||||
const safePosition = Math.max(0, Math.min(position, docSize));
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(safePosition, [{ type: CORE_EXTENSIONS.PARAGRAPH }])
|
||||
.focus()
|
||||
.run();
|
||||
} catch (error) {
|
||||
console.error("An error occurred while setting focus at position:", error);
|
||||
}
|
||||
},
|
||||
setProviderDocument: (value) => {
|
||||
const document = provider?.document;
|
||||
if (!document) return;
|
||||
Y.applyUpdate(document, value);
|
||||
},
|
||||
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
|
||||
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
||||
}),
|
||||
[editor]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
import { useEditor as useTiptapEditor } from "@tiptap/react";
|
||||
import { useImperativeHandle, useEffect } from "react";
|
||||
import * as Y from "yjs";
|
||||
// constants
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
// extensions
|
||||
import { CoreReadOnlyEditorExtensions } from "@/extensions";
|
||||
// helpers
|
||||
import { getParagraphCount } from "@/helpers/common";
|
||||
import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
||||
import { getEditorRefHelpers } from "@/helpers/editor-ref";
|
||||
// props
|
||||
import { CoreReadOnlyEditorProps } from "@/props";
|
||||
// types
|
||||
|
|
@ -30,7 +26,7 @@ export const useReadOnlyEditor = (props: TReadOnlyEditorHookProps) => {
|
|||
|
||||
const editor = useTiptapEditor({
|
||||
editable: false,
|
||||
immediatelyRender: true,
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: false,
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
parseOptions: { preserveWhitespace: true },
|
||||
|
|
@ -63,38 +59,7 @@ export const useReadOnlyEditor = (props: TReadOnlyEditorHookProps) => {
|
|||
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: true });
|
||||
}, [editor, initialValue]);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
|
||||
},
|
||||
setEditorValue: (content: string, emitUpdate = false) => {
|
||||
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||
return markdownOutput;
|
||||
},
|
||||
getDocument: () => {
|
||||
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
||||
const documentHTML = editor?.getHTML() ?? "<p></p>";
|
||||
const documentJSON = editor?.getJSON() ?? null;
|
||||
|
||||
return {
|
||||
binary: documentBinary,
|
||||
html: documentHTML,
|
||||
json: documentJSON,
|
||||
};
|
||||
},
|
||||
scrollSummary: (marking: IMarking): void => {
|
||||
if (!editor) return;
|
||||
scrollSummary(editor, marking);
|
||||
},
|
||||
getDocumentInfo: () => ({
|
||||
characters: editor.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editor.state),
|
||||
words: editor.storage?.characterCount?.words?.() ?? 0,
|
||||
}),
|
||||
}));
|
||||
useImperativeHandle(forwardedRef, () => getEditorRefHelpers({ editor, provider }));
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
|||
return;
|
||||
}
|
||||
|
||||
const scrollableParent = getScrollParent(dragHandleElement);
|
||||
const scrollableParent = getScrollParent(dragHandleElement!);
|
||||
if (!scrollableParent) return;
|
||||
|
||||
const scrollRegionUp = options.scrollThreshold.up;
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand
|
|||
if (!nodeFileSetDetails || !src) return;
|
||||
try {
|
||||
editor.storage[nodeType][nodeFileSetDetails.fileSetName]?.set(src, true);
|
||||
// update assets list storage value
|
||||
editor.commands.updateAssetsList?.({
|
||||
idToRemove: node.attrs.id,
|
||||
});
|
||||
await deleteHandler(src);
|
||||
} catch (error) {
|
||||
console.error("Error deleting file via delete utility plugin:", error);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { Editor } from "@tiptap/core";
|
|||
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { CORE_ASSETS_META_DATA_RECORD } from "@/helpers/assets";
|
||||
// plane editor imports
|
||||
import { NODE_FILE_MAP } from "@/plane-editor/constants/utility";
|
||||
// types
|
||||
|
|
@ -42,6 +44,13 @@ export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFile
|
|||
if (!isAValidNode) return;
|
||||
if (pos < 0 || pos > newState.doc.content.size) return;
|
||||
if (oldFileSources[nodeType]?.has(node.attrs.src)) return;
|
||||
// update assets list storage value
|
||||
const assetMetaData = CORE_ASSETS_META_DATA_RECORD[nodeType]?.(node.attrs);
|
||||
if (assetMetaData) {
|
||||
editor.commands.updateAssetsList?.({
|
||||
asset: assetMetaData,
|
||||
});
|
||||
}
|
||||
// if the src is just a id (private bucket), then we don't need to handle restore from here but
|
||||
// only while it fails to load
|
||||
if (nodeType === CORE_EXTENSIONS.CUSTOM_IMAGE && !node.attrs.src?.startsWith("http")) return;
|
||||
|
|
|
|||
14
packages/editor/src/core/types/asset.ts
Normal file
14
packages/editor/src/core/types/asset.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// plane editor imports
|
||||
import { TAdditionalEditorAsset } from "@/plane-editor/types/asset";
|
||||
|
||||
export type TEditorImageAsset = {
|
||||
href: string;
|
||||
id: string;
|
||||
name: string;
|
||||
src: string;
|
||||
type: CORE_EXTENSIONS.IMAGE | CORE_EXTENSIONS.CUSTOM_IMAGE;
|
||||
};
|
||||
|
||||
export type TEditorAsset = TEditorImageAsset | TAdditionalEditorAsset;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Extensions, JSONContent } from "@tiptap/core";
|
||||
import type { Selection } from "@tiptap/pm/state";
|
||||
import { Extensions, JSONContent } from "@tiptap/core";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
// extension types
|
||||
import type { TTextAlign } from "@/extensions";
|
||||
// helpers
|
||||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
TDisplayConfig,
|
||||
TDocumentEventEmitter,
|
||||
TDocumentEventsServer,
|
||||
TEditorAsset,
|
||||
TEmbedConfig,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
|
|
@ -75,41 +76,44 @@ type TCommandWithPropsWithItemKey<T extends TEditorCommands> = T extends keyof T
|
|||
? { itemKey: T } & TCommandExtraProps[T]
|
||||
: { itemKey: T };
|
||||
|
||||
export type TDocumentInfo = {
|
||||
characters: number;
|
||||
paragraphs: number;
|
||||
words: number;
|
||||
};
|
||||
|
||||
// editor refs
|
||||
export type EditorReadOnlyRefApi = {
|
||||
getMarkDown: () => string;
|
||||
clearEditor: (emitUpdate?: boolean) => void;
|
||||
getDocument: () => {
|
||||
binary: Uint8Array | null;
|
||||
html: string;
|
||||
json: JSONContent | null;
|
||||
};
|
||||
clearEditor: (emitUpdate?: boolean) => void;
|
||||
setEditorValue: (content: string, emitUpdate?: boolean) => void;
|
||||
getDocumentInfo: () => TDocumentInfo;
|
||||
getHeadings: () => IMarking[];
|
||||
getMarkDown: () => string;
|
||||
scrollSummary: (marking: IMarking) => void;
|
||||
getDocumentInfo: () => {
|
||||
characters: number;
|
||||
paragraphs: number;
|
||||
words: number;
|
||||
};
|
||||
setEditorValue: (content: string, emitUpdate?: boolean) => void;
|
||||
};
|
||||
|
||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
blur: () => void;
|
||||
scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void;
|
||||
getCurrentCursorPosition: () => number | undefined;
|
||||
setEditorValueAtCursorPosition: (content: string) => void;
|
||||
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
|
||||
executeMenuItemCommand: <T extends TEditorCommands>(props: TCommandWithPropsWithItemKey<T>) => void;
|
||||
isMenuItemActive: <T extends TEditorCommands>(props: TCommandWithPropsWithItemKey<T>) => boolean;
|
||||
onStateChange: (callback: () => void) => () => void;
|
||||
setFocusAtPosition: (position: number) => void;
|
||||
isEditorReadyToDiscard: () => boolean;
|
||||
getCurrentCursorPosition: () => number | undefined;
|
||||
getSelectedText: () => string | null;
|
||||
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
|
||||
setProviderDocument: (value: Uint8Array) => void;
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
|
||||
getHeadings: () => IMarking[];
|
||||
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
|
||||
isEditorReadyToDiscard: () => boolean;
|
||||
isMenuItemActive: <T extends TEditorCommands>(props: TCommandWithPropsWithItemKey<T>) => boolean;
|
||||
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
|
||||
onDocumentInfoChange: (callback: (documentInfo: TDocumentInfo) => void) => () => void;
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
|
||||
onStateChange: (callback: () => void) => () => void;
|
||||
scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void;
|
||||
setEditorValueAtCursorPosition: (content: string) => void;
|
||||
setFocusAtPosition: (position: number) => void;
|
||||
setProviderDocument: (value: Uint8Array) => void;
|
||||
}
|
||||
|
||||
// editor props
|
||||
|
|
@ -128,6 +132,7 @@ export interface IEditorProps {
|
|||
id: string;
|
||||
initialValue: string;
|
||||
mentionHandler: TMentionHandler;
|
||||
onAssetChange?: (assets: TEditorAsset[]) => void;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onEnterKeyPress?: (e?: any) => void;
|
||||
onTransaction?: () => void;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export type TEditorHookProps = TCoreHookProps &
|
|||
| "forwardedRef"
|
||||
| "id"
|
||||
| "mentionHandler"
|
||||
| "onAssetChange"
|
||||
| "onChange"
|
||||
| "onTransaction"
|
||||
| "placeholder"
|
||||
|
|
@ -38,6 +39,7 @@ export type TCollaborativeEditorHookProps = TCoreHookProps &
|
|||
| "forwardedRef"
|
||||
| "id"
|
||||
| "mentionHandler"
|
||||
| "onAssetChange"
|
||||
| "onChange"
|
||||
| "onTransaction"
|
||||
| "placeholder"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export * from "./ai";
|
||||
export * from "./asset";
|
||||
export * from "./collaboration";
|
||||
export * from "./config";
|
||||
export * from "./editor";
|
||||
|
|
|
|||
|
|
@ -35,5 +35,8 @@ export { useEditor } from "@/hooks/use-editor";
|
|||
export { type IMarking, useEditorMarkings } from "@/hooks/use-editor-markings";
|
||||
export { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
|
||||
export { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
export { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions";
|
||||
|
||||
// types
|
||||
export * from "@/types";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue