diff --git a/admin/styles/globals.css b/admin/styles/globals.css index d5554ce2f27..bdd91161bab 100644 --- a/admin/styles/globals.css +++ b/admin/styles/globals.css @@ -332,6 +332,7 @@ text-rendering: optimizeLegibility; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; + scroll-behavior: smooth; } body { diff --git a/packages/editor/src/ce/constants/assets.ts b/packages/editor/src/ce/constants/assets.ts new file mode 100644 index 00000000000..12b89b3abaa --- /dev/null +++ b/packages/editor/src/ce/constants/assets.ts @@ -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> = {}; diff --git a/packages/editor/src/ce/constants/extensions.ts b/packages/editor/src/ce/constants/extensions.ts new file mode 100644 index 00000000000..8787ec0c1bb --- /dev/null +++ b/packages/editor/src/ce/constants/extensions.ts @@ -0,0 +1 @@ +export enum ADDITIONAL_EXTENSIONS {} diff --git a/packages/editor/src/ce/types/asset.ts b/packages/editor/src/ce/types/asset.ts new file mode 100644 index 00000000000..4410c0f2cd9 --- /dev/null +++ b/packages/editor/src/ce/types/asset.ts @@ -0,0 +1 @@ +export type TAdditionalEditorAsset = never; diff --git a/packages/editor/src/ce/types/storage.ts b/packages/editor/src/ce/types/storage.ts index 84eee65f982..da90d529ea1 100644 --- a/packages/editor/src/ce/types/storage.ts +++ b/packages/editor/src/ce/types/storage.ts @@ -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; diff --git a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx index 8bbf2e7cea5..e20ac3f5f9d 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx @@ -17,8 +17,6 @@ import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types"; const CollaborativeDocumentEditor: React.FC = (props) => { const { - onChange, - onTransaction, aiHandler, bubbleMenuEnabled = true, containerClassName, @@ -33,6 +31,9 @@ const CollaborativeDocumentEditor: React.FC = handleEditorReady, id, mentionHandler, + onAssetChange, + onChange, + onTransaction, placeholder, realtimeConfig, serverHandler, @@ -63,6 +64,7 @@ const CollaborativeDocumentEditor: React.FC = handleEditorReady, id, mentionHandler, + onAssetChange, onChange, onTransaction, placeholder, diff --git a/packages/editor/src/core/extensions/custom-image/components/block.tsx b/packages/editor/src/core/extensions/custom-image/components/block.tsx index 1ff36abca77..c895d19ccf4 100644 --- a/packages/editor/src/core/extensions/custom-image/components/block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/block.tsx @@ -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 = (props) => { return (
{ } return "Add an image"; - }, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]); + }, [draggedInside, editor.isEditable, failedToLoadImage, isImageBeingUploaded]); return (
( return value; }; + +export const getImageBlockId = (id: string) => `editor-image-block-${id}`; diff --git a/packages/editor/src/core/extensions/utility.ts b/packages/editor/src/core/extensions/utility.ts index 758c742411a..571a66da0ba 100644 --- a/packages/editor/src/core/extensions/utility.ts +++ b/packages/editor/src/core/extensions/utility.ts @@ -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); + }, }; }, }); diff --git a/packages/editor/src/core/helpers/assets.ts b/packages/editor/src/core/helpers/assets.ts new file mode 100644 index 00000000000..74179f6c451 --- /dev/null +++ b/packages/editor/src/core/helpers/assets.ts @@ -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> = { + [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, +}; diff --git a/packages/editor/src/core/helpers/editor-ref.ts b/packages/editor/src/core/helpers/editor-ref.ts new file mode 100644 index 00000000000..1b9843df975 --- /dev/null +++ b/packages/editor/src/core/helpers/editor-ref.ts @@ -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() ?? "

"; + 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 }); + }, + }; +}; diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts index 9c436dff21f..3b4b333e6af 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -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, diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 4c1b93d84aa..1979d46b14b 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -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,6 +143,42 @@ export const useEditor = (props: TEditorHookProps) => { console.warn(`No command found for item: ${itemKey}`); } }, + getCurrentCursorPosition: () => editor?.state.selection.from, + getSelectedText: () => { + if (!editor) return null; + + const { state } = editor; + const { from, to, empty } = state.selection; + + if (empty) return null; + + const nodesArray: string[] = []; + state.doc.nodesBetween(from, to, (node, _pos, parent) => { + if (parent === state.doc && editor) { + const serializer = DOMSerializer.fromSchema(editor.schema); + const dom = serializer.serializeNode(node); + const tempDiv = document.createElement("div"); + tempDiv.appendChild(dom); + nodesArray.push(tempDiv.innerHTML); + } + }); + const selection = nodesArray.join(""); + return selection; + }, + insertText: (contentHTML, insertOnNextLine) => { + if (!editor) return; + const { from, to, empty } = editor.state.selection; + if (empty) return; + if (insertOnNextLine) { + // move cursor to the end of the selection and insert a new line + editor.chain().focus().setTextSelection(to).insertContent("
").insertContent(contentHTML).run(); + } else { + // replace selected text with the content provided + editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run(); + } + }, + isEditorReadyToDiscard: () => + !!editor && getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress === false, isMenuItemActive: (props) => { const { itemKey } = props; const editorItems = getEditorMenuItems(editor); @@ -153,57 +189,66 @@ export const useEditor = (props: TEditorHookProps) => { return item.isActive(props); }, - onHeadingChange: (callback: (headings: IMarking[]) => void) => { - // Subscribe to update event emitted from headers extension - editor?.on("update", () => { + 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"); + editor?.off("update", handleHeadingChange); }; }, - getHeadings: () => (editor ? getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings : []), - onStateChange: (callback: () => void) => { + onStateChange: (callback) => { // Subscribe to editor state changes - editor?.on("transaction", () => { - callback(); - }); + 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"); + editor?.off("transaction", callback); }; }, - getMarkDown: (): string => { - const markdownOutput = editor?.storage.markdown.getMarkdown(); - return markdownOutput; - }, - getDocument: () => { - const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null; - const documentHTML = editor?.getHTML() ?? "

"; - const documentJSON = editor?.getJSON() ?? null; - - return { - binary: documentBinary, - html: documentHTML, - json: documentJSON, - }; + scrollToNodeViaDOMCoordinates(behavior, pos) { + const resolvedPos = pos ?? editor?.state.selection.from; + if (!editor || !resolvedPos) return; + scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior); }, - scrollSummary: (marking: IMarking): void => { - if (!editor) return; - scrollSummary(editor, marking); + setEditorValueAtCursorPosition: (content) => { + if (editor?.state.selection) { + insertContentAtSavedSelection(editor, content); + } }, - isEditorReadyToDiscard: () => - !!editor && getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress === false, - setFocusAtPosition: (position: number) => { + setFocusAtPosition: (position) => { if (!editor || editor.isDestroyed) { console.error("Editor reference is not available or has been destroyed."); return; @@ -220,51 +265,11 @@ export const useEditor = (props: TEditorHookProps) => { console.error("An error occurred while setting focus at position:", error); } }, - getSelectedText: () => { - if (!editor) return null; - - const { state } = editor; - const { from, to, empty } = state.selection; - - if (empty) return null; - - const nodesArray: string[] = []; - state.doc.nodesBetween(from, to, (node, _pos, parent) => { - if (parent === state.doc && editor) { - const serializer = DOMSerializer.fromSchema(editor.schema); - const dom = serializer.serializeNode(node); - const tempDiv = document.createElement("div"); - tempDiv.appendChild(dom); - nodesArray.push(tempDiv.innerHTML); - } - }); - const selection = nodesArray.join(""); - return selection; - }, - insertText: (contentHTML, insertOnNextLine) => { - if (!editor) return; - const { from, to, empty } = editor.state.selection; - if (empty) return; - if (insertOnNextLine) { - // move cursor to the end of the selection and insert a new line - editor.chain().focus().setTextSelection(to).insertContent("
").insertContent(contentHTML).run(); - } else { - // replace selected text with the content provided - 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, - }), 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] ); diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index d259470ac96..43e9c958134 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -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 : "

", 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() ?? "

"; - 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; diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 4a534bc4cd1..e04bbaba47e 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -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; diff --git a/packages/editor/src/core/plugins/file/delete.ts b/packages/editor/src/core/plugins/file/delete.ts index ac69b18194b..427b100b75c 100644 --- a/packages/editor/src/core/plugins/file/delete.ts +++ b/packages/editor/src/core/plugins/file/delete.ts @@ -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); diff --git a/packages/editor/src/core/plugins/file/restore.ts b/packages/editor/src/core/plugins/file/restore.ts index 04a4c295ccd..bb4eb2afb25 100644 --- a/packages/editor/src/core/plugins/file/restore.ts +++ b/packages/editor/src/core/plugins/file/restore.ts @@ -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; diff --git a/packages/editor/src/core/types/asset.ts b/packages/editor/src/core/types/asset.ts new file mode 100644 index 00000000000..5760da1572c --- /dev/null +++ b/packages/editor/src/core/types/asset.ts @@ -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; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index cf3d7d2c7f8..68d8424ab39 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -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 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: (props: TCommandWithPropsWithItemKey) => void; + getCurrentCursorPosition: () => number | undefined; + getSelectedText: () => string | null; + insertText: (contentHTML: string, insertOnNextLine?: boolean) => void; + isEditorReadyToDiscard: () => boolean; isMenuItemActive: (props: TCommandWithPropsWithItemKey) => 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; - isEditorReadyToDiscard: () => boolean; - 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; - listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined; } // 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; diff --git a/packages/editor/src/core/types/hook.ts b/packages/editor/src/core/types/hook.ts index 2224935ca91..40974981b7d 100644 --- a/packages/editor/src/core/types/hook.ts +++ b/packages/editor/src/core/types/hook.ts @@ -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" diff --git a/packages/editor/src/core/types/index.ts b/packages/editor/src/core/types/index.ts index 619fa0c784c..cfa67ba9732 100644 --- a/packages/editor/src/core/types/index.ts +++ b/packages/editor/src/core/types/index.ts @@ -1,4 +1,5 @@ export * from "./ai"; +export * from "./asset"; export * from "./collaboration"; export * from "./config"; export * from "./editor"; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index fec933f910b..43b295647a1 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -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"; diff --git a/packages/i18n/src/locales/cs/translations.json b/packages/i18n/src/locales/cs/translations.json index 396ca03e507..b392788a0dd 100644 --- a/packages/i18n/src/locales/cs/translations.json +++ b/packages/i18n/src/locales/cs/translations.json @@ -2470,5 +2470,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane se nespustil. To může být způsobeno tím, že se jeden nebo více služeb Plane nepodařilo spustit.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Vyberte View Logs z setup.sh a Docker logů, abyste si byli jisti." + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Osnova", + "empty_state": { + "title": "Chybí nadpisy", + "description": "Přidejte na tuto stránku nějaké nadpisy, aby se zde zobrazily." + } + }, + "info": { + "label": "Info", + "document_info": { + "words": "Slova", + "characters": "Znaky", + "paragraphs": "Odstavce", + "read_time": "Doba čtení" + }, + "actors_info": { + "edited_by": "Upravil", + "created_by": "Vytvořil" + }, + "version_history": { + "label": "Historie verzí", + "current_version": "Aktuální verze" + } + }, + "assets": { + "label": "Přílohy", + "download_button": "Stáhnout", + "empty_state": { + "title": "Chybí obrázky", + "description": "Přidejte obrázky, aby se zde zobrazily." + } + } + }, + "open_button": "Otevřít navigační panel", + "close_button": "Zavřít navigační panel", + "outline_floating_button": "Otevřít osnovu" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/de/translations.json b/packages/i18n/src/locales/de/translations.json index 1b6e4778e19..a6c73e845ee 100644 --- a/packages/i18n/src/locales/de/translations.json +++ b/packages/i18n/src/locales/de/translations.json @@ -2469,5 +2469,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane ist nicht gestartet. Dies könnte daran liegen, dass einer oder mehrere Plane-Services nicht starten konnten.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Wählen Sie View Logs aus setup.sh und Docker-Logs, um sicherzugehen." + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Gliederung", + "empty_state": { + "title": "Fehlende Überschriften", + "description": "Fügen Sie einige Überschriften zu dieser Seite hinzu, um sie hier zu sehen." + } + }, + "info": { + "label": "Info", + "document_info": { + "words": "Wörter", + "characters": "Zeichen", + "paragraphs": "Absätze", + "read_time": "Lesezeit" + }, + "actors_info": { + "edited_by": "Bearbeitet von", + "created_by": "Erstellt von" + }, + "version_history": { + "label": "Versionsverlauf", + "current_version": "Aktuelle Version" + } + }, + "assets": { + "label": "Assets", + "download_button": "Herunterladen", + "empty_state": { + "title": "Fehlende Bilder", + "description": "Fügen Sie Bilder hinzu, um sie hier zu sehen." + } + } + }, + "open_button": "Navigationsbereich öffnen", + "close_button": "Navigationsbereich schließen", + "outline_floating_button": "Gliederung öffnen" } } diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index fafed9c7766..da650cbbe45 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -2346,5 +2346,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane didn't start up. This could be because one or more Plane services failed to start.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Choose View Logs from setup.sh and Docker logs to be sure." + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Outline", + "empty_state": { + "title": "Missing headings", + "description": "Let's put some headings in this page to see them here." + } + }, + "info": { + "label": "Info", + "document_info": { + "words": "Words", + "characters": "Characters", + "paragraphs": "Paragraphs", + "read_time": "Read time" + }, + "actors_info": { + "edited_by": "Edited by", + "created_by": "Created by" + }, + "version_history": { + "label": "Version history", + "current_version": "Current version" + } + }, + "assets": { + "label": "Assets", + "download_button": "Download", + "empty_state": { + "title": "Missing images", + "description": "Add images to see them here." + } + } + }, + "open_button": "Open navigation pane", + "close_button": "Close navigation pane", + "outline_floating_button": "Open outline" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json index 49ca53ea1ed..d8a350ce30e 100644 --- a/packages/i18n/src/locales/es/translations.json +++ b/packages/i18n/src/locales/es/translations.json @@ -2472,5 +2472,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane no se inició. Esto podría deberse a que uno o más servicios de Plane fallaron al iniciar.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Selecciona View Logs desde setup.sh y los logs de Docker para estar seguro." + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Esquema", + "empty_state": { + "title": "Faltan encabezados", + "description": "Añade algunos encabezados a esta página para verlos aquí." + } + }, + "info": { + "label": "Info", + "document_info": { + "words": "Palabras", + "characters": "Caracteres", + "paragraphs": "Párrafos", + "read_time": "Tiempo de lectura" + }, + "actors_info": { + "edited_by": "Editado por", + "created_by": "Creado por" + }, + "version_history": { + "label": "Historial de versiones", + "current_version": "Versión actual" + } + }, + "assets": { + "label": "Recursos", + "download_button": "Descargar", + "empty_state": { + "title": "Faltan imágenes", + "description": "Añade imágenes para verlas aquí." + } + } + }, + "open_button": "Abrir panel de navegación", + "close_button": "Cerrar panel de navegación", + "outline_floating_button": "Abrir esquema" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json index e42db2c52e9..a1f4c3cec35 100644 --- a/packages/i18n/src/locales/fr/translations.json +++ b/packages/i18n/src/locales/fr/translations.json @@ -2470,5 +2470,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane n'a pas démarré. Cela pourrait être dû au fait qu'un ou plusieurs services Plane ont échoué à démarrer.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Choisissez View Logs depuis setup.sh et les logs Docker pour en être sûr." + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Plan", + "empty_state": { + "title": "Titres manquants", + "description": "Ajoutons quelques titres à cette page pour les voir ici." + } + }, + "info": { + "label": "Info", + "document_info": { + "words": "Mots", + "characters": "Caractères", + "paragraphs": "Paragraphes", + "read_time": "Temps de lecture" + }, + "actors_info": { + "edited_by": "Modifié par", + "created_by": "Créé par" + }, + "version_history": { + "label": "Historique des versions", + "current_version": "Version actuelle" + } + }, + "assets": { + "label": "Ressources", + "download_button": "Télécharger", + "empty_state": { + "title": "Images manquantes", + "description": "Ajoutez des images pour les voir ici." + } + } + }, + "open_button": "Ouvrir le panneau de navigation", + "close_button": "Fermer le panneau de navigation", + "outline_floating_button": "Ouvrir le plan" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/id/translations.json b/packages/i18n/src/locales/id/translations.json index 372aefde9a7..0f388db3949 100644 --- a/packages/i18n/src/locales/id/translations.json +++ b/packages/i18n/src/locales/id/translations.json @@ -2465,5 +2465,45 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane tidak berhasil dimulai. Ini bisa karena satu atau lebih layanan Plane gagal untuk dimulai.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Pilih View Logs dari setup.sh dan log Docker untuk memastikan." }, - "no_of": "Jumlah {entity}" -} \ No newline at end of file + "no_of": "Jumlah {entity}", + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Garis Besar", + "empty_state": { + "title": "Judul hilang", + "description": "Mari tambahkan beberapa judul di halaman ini untuk melihatnya di sini." + } + }, + "info": { + "label": "Info", + "document_info": { + "words": "Kata", + "characters": "Karakter", + "paragraphs": "Paragraf", + "read_time": "Waktu baca" + }, + "actors_info": { + "edited_by": "Disunting oleh", + "created_by": "Dibuat oleh" + }, + "version_history": { + "label": "Riwayat versi", + "current_version": "Versi saat ini" + } + }, + "assets": { + "label": "Aset", + "download_button": "Unduh", + "empty_state": { + "title": "Gambar hilang", + "description": "Tambahkan gambar untuk melihatnya di sini." + } + } + }, + "open_button": "Buka panel navigasi", + "close_button": "Tutup panel navigasi", + "outline_floating_button": "Buka garis besar" + } +} diff --git a/packages/i18n/src/locales/it/translations.json b/packages/i18n/src/locales/it/translations.json index b859ce21767..a6275f53d70 100644 --- a/packages/i18n/src/locales/it/translations.json +++ b/packages/i18n/src/locales/it/translations.json @@ -2469,5 +2469,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane non si è avviato. Questo potrebbe essere dovuto al fatto che uno o più servizi Plane non sono riusciti ad avviarsi.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Scegli View Logs da setup.sh e dai log Docker per essere sicuro." + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Schema", + "empty_state": { + "title": "Intestazioni mancanti", + "description": "Aggiungiamo alcune intestazioni a questa pagina per vederle qui." + } + }, + "info": { + "label": "Info", + "document_info": { + "words": "Parole", + "characters": "Caratteri", + "paragraphs": "Paragrafi", + "read_time": "Tempo di lettura" + }, + "actors_info": { + "edited_by": "Modificato da", + "created_by": "Creato da" + }, + "version_history": { + "label": "Cronologia versioni", + "current_version": "Versione corrente" + } + }, + "assets": { + "label": "Risorse", + "download_button": "Scarica", + "empty_state": { + "title": "Immagini mancanti", + "description": "Aggiungi immagini per vederle qui." + } + } + }, + "open_button": "Apri pannello di navigazione", + "close_button": "Chiudi pannello di navigazione", + "outline_floating_button": "Apri schema" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json index 4c6f27a6efb..8147451c759 100644 --- a/packages/i18n/src/locales/ja/translations.json +++ b/packages/i18n/src/locales/ja/translations.json @@ -2470,5 +2470,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Planeが起動しませんでした。これは1つまたは複数のPlaneサービスの起動に失敗したことが原因である可能性があります。", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "setup.shとDockerログからView Logsを選択して確認してください。" + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "アウトライン", + "empty_state": { + "title": "見出しがありません", + "description": "このページに見出しを追加してここで確認しましょう。" + } + }, + "info": { + "label": "情報", + "document_info": { + "words": "単語数", + "characters": "文字数", + "paragraphs": "段落数", + "read_time": "読了時間" + }, + "actors_info": { + "edited_by": "編集者", + "created_by": "作成者" + }, + "version_history": { + "label": "バージョン履歴", + "current_version": "現在のバージョン" + } + }, + "assets": { + "label": "アセット", + "download_button": "ダウンロード", + "empty_state": { + "title": "画像がありません", + "description": "画像を追加してここで確認してください。" + } + } + }, + "open_button": "ナビゲーションパネルを開く", + "close_button": "ナビゲーションパネルを閉じる", + "outline_floating_button": "アウトラインを開く" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/ko/translations.json b/packages/i18n/src/locales/ko/translations.json index ee1f61adcd9..4a984b4c3e6 100644 --- a/packages/i18n/src/locales/ko/translations.json +++ b/packages/i18n/src/locales/ko/translations.json @@ -2472,5 +2472,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane이 시작되지 않았습니다. 이는 하나 이상의 Plane 서비스가 시작에 실패했기 때문일 수 있습니다.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "확실히 하려면 setup.sh와 Docker 로그에서 View Logs를 선택하세요." + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "개요", + "empty_state": { + "title": "제목이 없습니다", + "description": "이 페이지에 제목을 추가하여 여기에서 확인해보세요." + } + }, + "info": { + "label": "정보", + "document_info": { + "words": "단어", + "characters": "문자", + "paragraphs": "단락", + "read_time": "읽기 시간" + }, + "actors_info": { + "edited_by": "편집자", + "created_by": "작성자" + }, + "version_history": { + "label": "버전 기록", + "current_version": "현재 버전" + } + }, + "assets": { + "label": "자산", + "download_button": "다운로드", + "empty_state": { + "title": "이미지가 없습니다", + "description": "이미지를 추가하여 여기에서 확인하세요." + } + } + }, + "open_button": "네비게이션 패널 열기", + "close_button": "네비게이션 패널 닫기", + "outline_floating_button": "개요 열기" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/pl/translations.json b/packages/i18n/src/locales/pl/translations.json index b26e6e2f42f..38a564e3e53 100644 --- a/packages/i18n/src/locales/pl/translations.json +++ b/packages/i18n/src/locales/pl/translations.json @@ -2471,5 +2471,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane nie uruchomił się. Może to być spowodowane tym, że jedna lub więcej usług Plane nie mogła się uruchomić.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Wybierz View Logs z setup.sh i logów Docker, aby mieć pewność." + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Konspekt", + "empty_state": { + "title": "Brakuje nagłówków", + "description": "Dodajmy kilka nagłówków na tej stronie, aby je tutaj zobaczyć." + } + }, + "info": { + "label": "Info", + "document_info": { + "words": "Słowa", + "characters": "Znaki", + "paragraphs": "Akapity", + "read_time": "Czas czytania" + }, + "actors_info": { + "edited_by": "Edytowane przez", + "created_by": "Utworzone przez" + }, + "version_history": { + "label": "Historia wersji", + "current_version": "Bieżąca wersja" + } + }, + "assets": { + "label": "Zasoby", + "download_button": "Pobierz", + "empty_state": { + "title": "Brakuje obrazów", + "description": "Dodaj obrazy, aby je tutaj zobaczyć." + } + } + }, + "open_button": "Otwórz panel nawigacji", + "close_button": "Zamknij panel nawigacji", + "outline_floating_button": "Otwórz konspekt" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/pt-BR/translations.json b/packages/i18n/src/locales/pt-BR/translations.json index 6e7f216abfa..e8fd5ee283e 100644 --- a/packages/i18n/src/locales/pt-BR/translations.json +++ b/packages/i18n/src/locales/pt-BR/translations.json @@ -2466,5 +2466,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "O Plane não inicializou. Isso pode ser porque um ou mais serviços do Plane falharam ao iniciar.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Escolha View Logs do setup.sh e logs do Docker para ter certeza." + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Estrutura", + "empty_state": { + "title": "Cabeçalhos ausentes", + "description": "Vamos adicionar alguns cabeçalhos nesta página para vê-los aqui." + } + }, + "info": { + "label": "Info", + "document_info": { + "words": "Palavras", + "characters": "Caracteres", + "paragraphs": "Parágrafos", + "read_time": "Tempo de leitura" + }, + "actors_info": { + "edited_by": "Editado por", + "created_by": "Criado por" + }, + "version_history": { + "label": "Histórico de versões", + "current_version": "Versão atual" + } + }, + "assets": { + "label": "Recursos", + "download_button": "Baixar", + "empty_state": { + "title": "Imagens ausentes", + "description": "Adicione imagens para vê-las aqui." + } + } + }, + "open_button": "Abrir painel de navegação", + "close_button": "Fechar painel de navegação", + "outline_floating_button": "Abrir estrutura" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/ro/translations.json b/packages/i18n/src/locales/ro/translations.json index 8f40c0a2273..55b4ae2c52a 100644 --- a/packages/i18n/src/locales/ro/translations.json +++ b/packages/i18n/src/locales/ro/translations.json @@ -2464,5 +2464,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane nu a pornit. Aceasta ar putea fi din cauza că unul sau mai multe servicii Plane au eșuat să pornească.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Alegeți View Logs din setup.sh și logurile Docker pentru a fi siguri." + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Contur", + "empty_state": { + "title": "Titluri lipsă", + "description": "Să punem câteva titluri în această pagină pentru a le vedea aici." + } + }, + "info": { + "label": "Info", + "document_info": { + "words": "Cuvinte", + "characters": "Caractere", + "paragraphs": "Paragrafe", + "read_time": "Timp de citire" + }, + "actors_info": { + "edited_by": "Editat de", + "created_by": "Creat de" + }, + "version_history": { + "label": "Istoricul versiunilor", + "current_version": "Versiunea curentă" + } + }, + "assets": { + "label": "Resurse", + "download_button": "Descarcă", + "empty_state": { + "title": "Imagini lipsă", + "description": "Adăugați imagini pentru a le vedea aici." + } + } + }, + "open_button": "Deschide panoul de navigare", + "close_button": "Închide panoul de navigare", + "outline_floating_button": "Deschide conturul" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json index 1981999b7b4..bbe979e8973 100644 --- a/packages/i18n/src/locales/ru/translations.json +++ b/packages/i18n/src/locales/ru/translations.json @@ -2473,5 +2473,45 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane не запустился. Это может быть из-за того, что один или несколько сервисов Plane не смогли запуститься.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Выберите View Logs из setup.sh и логов Docker, чтобы убедиться." }, - "no_of": "Количество {entity}" -} \ No newline at end of file + "no_of": "Количество {entity}", + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Структура", + "empty_state": { + "title": "Отсутствуют заголовки", + "description": "Давайте добавим несколько заголовков на эту страницу, чтобы увидеть их здесь." + } + }, + "info": { + "label": "Информация", + "document_info": { + "words": "Слова", + "characters": "Символы", + "paragraphs": "Абзацы", + "read_time": "Время чтения" + }, + "actors_info": { + "edited_by": "Отредактировано", + "created_by": "Создано" + }, + "version_history": { + "label": "История версий", + "current_version": "Текущая версия" + } + }, + "assets": { + "label": "Ресурсы", + "download_button": "Скачать", + "empty_state": { + "title": "Отсутствуют изображения", + "description": "Добавьте изображения, чтобы увидеть их здесь." + } + } + }, + "open_button": "Открыть панель навигации", + "close_button": "Закрыть панель навигации", + "outline_floating_button": "Открыть структуру" + } +} diff --git a/packages/i18n/src/locales/sk/translations.json b/packages/i18n/src/locales/sk/translations.json index af6971aaef7..5cc3e360fff 100644 --- a/packages/i18n/src/locales/sk/translations.json +++ b/packages/i18n/src/locales/sk/translations.json @@ -2471,5 +2471,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane sa nespustil. Toto môže byť spôsobené tým, že sa jedna alebo viac služieb Plane nepodarilo spustiť.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Vyberte View Logs z setup.sh a Docker logov, aby ste si boli istí." + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Osnova", + "empty_state": { + "title": "Chýbajú nadpisy", + "description": "Pridajme na túto stránku nejaké nadpisy, aby sa tu zobrazili." + } + }, + "info": { + "label": "Info", + "document_info": { + "words": "Slová", + "characters": "Znaky", + "paragraphs": "Odseky", + "read_time": "Čas čítania" + }, + "actors_info": { + "edited_by": "Upravil", + "created_by": "Vytvoril" + }, + "version_history": { + "label": "História verzií", + "current_version": "Aktuálna verzia" + } + }, + "assets": { + "label": "Prílohy", + "download_button": "Stiahnuť", + "empty_state": { + "title": "Chýbajú obrázky", + "description": "Pridajte obrázky, aby sa tu zobrazili." + } + } + }, + "open_button": "Otvoriť navigačný panel", + "close_button": "Zavrieť navigačný panel", + "outline_floating_button": "Otvoriť osnovu" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/tr-TR/translations.json b/packages/i18n/src/locales/tr-TR/translations.json index a4ae0067023..d088aa835a6 100644 --- a/packages/i18n/src/locales/tr-TR/translations.json +++ b/packages/i18n/src/locales/tr-TR/translations.json @@ -2450,5 +2450,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane başlatılamadı. Bu, bir veya daha fazla Plane servisinin başlatılamaması nedeniyle olabilir.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Emin olmak için setup.sh ve Docker loglarından View Logs'u seçin." + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Ana Hat", + "empty_state": { + "title": "Eksik başlıklar", + "description": "Bu sayfaya bazı başlıklar ekleyelim ki burada görebilelim." + } + }, + "info": { + "label": "Bilgi", + "document_info": { + "words": "Kelimeler", + "characters": "Karakterler", + "paragraphs": "Paragraflar", + "read_time": "Okuma süresi" + }, + "actors_info": { + "edited_by": "Düzenleyen", + "created_by": "Oluşturan" + }, + "version_history": { + "label": "Sürüm geçmişi", + "current_version": "Mevcut sürüm" + } + }, + "assets": { + "label": "Varlıklar", + "download_button": "İndir", + "empty_state": { + "title": "Eksik görseller", + "description": "Burada görmek için görseller ekleyin." + } + } + }, + "open_button": "Navigasyon panelini aç", + "close_button": "Navigasyon panelini kapat", + "outline_floating_button": "Ana hatları aç" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/ua/translations.json b/packages/i18n/src/locales/ua/translations.json index bfa6c328167..04695f9b1d2 100644 --- a/packages/i18n/src/locales/ua/translations.json +++ b/packages/i18n/src/locales/ua/translations.json @@ -2471,5 +2471,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane не запустився. Це може бути через те, що один або декілька сервісів Plane не змогли запуститися.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Виберіть View Logs з setup.sh та логів Docker, щоб переконатися." + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Структура", + "empty_state": { + "title": "Відсутні заголовки", + "description": "Давайте додамо кілька заголовків на цю сторінку, щоб побачити їх тут." + } + }, + "info": { + "label": "Інформація", + "document_info": { + "words": "Слова", + "characters": "Символи", + "paragraphs": "Абзаци", + "read_time": "Час читання" + }, + "actors_info": { + "edited_by": "Відредаговано", + "created_by": "Створено" + }, + "version_history": { + "label": "Історія версій", + "current_version": "Поточна версія" + } + }, + "assets": { + "label": "Ресурси", + "download_button": "Завантажити", + "empty_state": { + "title": "Відсутні зображення", + "description": "Додайте зображення, щоб побачити їх тут." + } + } + }, + "open_button": "Відкрити панель навігації", + "close_button": "Закрити панель навігації", + "outline_floating_button": "Відкрити структуру" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/vi-VN/translations.json b/packages/i18n/src/locales/vi-VN/translations.json index 3b31f81fe98..37ceaeda83c 100644 --- a/packages/i18n/src/locales/vi-VN/translations.json +++ b/packages/i18n/src/locales/vi-VN/translations.json @@ -2469,5 +2469,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane không khởi động được. Điều này có thể do một hoặc nhiều dịch vụ Plane không khởi động được.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Chọn View Logs từ setup.sh và log Docker để chắc chắn." + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "Phác thảo", + "empty_state": { + "title": "Thiếu tiêu đề", + "description": "Hãy thêm một số tiêu đề vào trang này để xem chúng ở đây." + } + }, + "info": { + "label": "Thông tin", + "document_info": { + "words": "Từ", + "characters": "Ký tự", + "paragraphs": "Đoạn văn", + "read_time": "Thời gian đọc" + }, + "actors_info": { + "edited_by": "Được chỉnh sửa bởi", + "created_by": "Được tạo bởi" + }, + "version_history": { + "label": "Lịch sử phiên bản", + "current_version": "Phiên bản hiện tại" + } + }, + "assets": { + "label": "Tài sản", + "download_button": "Tải xuống", + "empty_state": { + "title": "Thiếu hình ảnh", + "description": "Thêm hình ảnh để xem chúng ở đây." + } + } + }, + "open_button": "Mở bảng điều hướng", + "close_button": "Đóng bảng điều hướng", + "outline_floating_button": "Mở phác thảo" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json index 304b435a8a0..3f08eec17cc 100644 --- a/packages/i18n/src/locales/zh-CN/translations.json +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -2451,5 +2451,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane 未能启动。这可能是因为一个或多个 Plane 服务启动失败。", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "请选择“查看日志”来查看 setup.sh 和 Docker 日志,以确认问题。" + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "大纲", + "empty_state": { + "title": "缺少标题", + "description": "让我们在这个页面添加一些标题来在这里查看它们。" + } + }, + "info": { + "label": "信息", + "document_info": { + "words": "字数", + "characters": "字符数", + "paragraphs": "段落数", + "read_time": "阅读时间" + }, + "actors_info": { + "edited_by": "编辑者", + "created_by": "创建者" + }, + "version_history": { + "label": "版本历史", + "current_version": "当前版本" + } + }, + "assets": { + "label": "资源", + "download_button": "下载", + "empty_state": { + "title": "缺少图片", + "description": "添加图片以在这里查看它们。" + } + } + }, + "open_button": "打开导航面板", + "close_button": "关闭导航面板", + "outline_floating_button": "打开大纲" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/zh-TW/translations.json b/packages/i18n/src/locales/zh-TW/translations.json index 5f3165ecb81..df2a4ba4a7b 100644 --- a/packages/i18n/src/locales/zh-TW/translations.json +++ b/packages/i18n/src/locales/zh-TW/translations.json @@ -2472,5 +2472,45 @@ "self_hosted_maintenance_message": { "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane 未能啟動。這可能是因為一個或多個 Plane 服務啟動失敗。", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "從 setup.sh 和 Docker 日誌中選擇 View Logs 來確認。" + }, + + "page_navigation_pane": { + "tabs": { + "outline": { + "label": "大綱", + "empty_state": { + "title": "缺少標題", + "description": "讓我們在這個頁面添加一些標題來在這裡查看它們。" + } + }, + "info": { + "label": "資訊", + "document_info": { + "words": "字數", + "characters": "字元數", + "paragraphs": "段落數", + "read_time": "閱讀時間" + }, + "actors_info": { + "edited_by": "編輯者", + "created_by": "建立者" + }, + "version_history": { + "label": "版本歷史", + "current_version": "目前版本" + } + }, + "assets": { + "label": "資源", + "download_button": "下載", + "empty_state": { + "title": "缺少圖片", + "description": "添加圖片以在這裡查看它們。" + } + } + }, + "open_button": "打開導航面板", + "close_button": "關閉導航面板", + "outline_floating_button": "打開大綱" } -} \ No newline at end of file +} diff --git a/packages/utils/src/editor.ts b/packages/utils/src/editor.ts index 1bdf3a50419..fb15edd078f 100644 --- a/packages/utils/src/editor.ts +++ b/packages/utils/src/editor.ts @@ -22,6 +22,21 @@ export const getEditorAssetSrc = (args: TEditorSrcArgs): string | undefined => { return url; }; +/** + * @description generate the file source using assetId + * @param {TEditorSrcArgs} args + */ +export const getEditorAssetDownloadSrc = (args: TEditorSrcArgs): string | undefined => { + const { assetId, projectId, workspaceSlug } = args; + let url: string | undefined = ""; + if (projectId) { + url = getFileURL(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/download/${assetId}/`); + } else { + url = getFileURL(`/api/assets/v2/workspaces/${workspaceSlug}/download/${assetId}/`); + } + return url; +}; + export const getTextContent = (jsx: JSX.Element | React.ReactNode | null | undefined): string => { if (!jsx) return ""; diff --git a/space/styles/globals.css b/space/styles/globals.css index 5d27de67493..783a157929a 100644 --- a/space/styles/globals.css +++ b/space/styles/globals.css @@ -47,23 +47,31 @@ --color-border-300: 212, 212, 212; /* strong border- 1 */ --color-border-400: 185, 185, 185; /* strong border- 2 */ - --color-shadow-2xs: 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06), + --color-shadow-2xs: + 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.14); - --color-shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12), + --color-shadow-xs: + 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12), 0px 1px 8px -1px rgba(16, 24, 40, 0.1); - --color-shadow-sm: 0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), - 0px 1px 12px 0px rgba(0, 0, 0, 0.12); - --color-shadow-rg: 0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08), + --color-shadow-sm: + 0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12); + --color-shadow-rg: + 0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08), 0px 1px 12px 0px rgba(16, 24, 40, 0.04); - --color-shadow-md: 0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), + --color-shadow-md: + 0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12); - --color-shadow-lg: 0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12), + --color-shadow-lg: + 0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 1px 24px 0px rgba(16, 24, 40, 0.12); - --color-shadow-xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16), + --color-shadow-xl: + 0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16), 0px 0px 52px 0px rgba(16, 24, 40, 0.16); - --color-shadow-2xl: 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12), + --color-shadow-2xl: + 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12), 0px 1px 32px 0px rgba(16, 24, 40, 0.12); - --color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12), + --color-shadow-3xl: + 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12), 0px 1px 48px 0px rgba(16, 24, 40, 0.12); --color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05); @@ -359,6 +367,7 @@ text-rendering: optimizeLegibility; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; + scroll-behavior: smooth; } body { diff --git a/web/ce/components/pages/navigation-pane/index.ts b/web/ce/components/pages/navigation-pane/index.ts new file mode 100644 index 00000000000..79ee20c26dd --- /dev/null +++ b/web/ce/components/pages/navigation-pane/index.ts @@ -0,0 +1,31 @@ +export type TPageNavigationPaneTab = "outline" | "info" | "assets"; + +export const PAGE_NAVIGATION_PANE_TABS_LIST: Record< + TPageNavigationPaneTab, + { + key: TPageNavigationPaneTab; + i18n_label: string; + } +> = { + outline: { + key: "outline", + i18n_label: "page_navigation_pane.tabs.outline.label", + }, + info: { + key: "info", + i18n_label: "page_navigation_pane.tabs.info.label", + }, + assets: { + key: "assets", + i18n_label: "page_navigation_pane.tabs.assets.label", + }, +}; + +export const ORDERED_PAGE_NAVIGATION_TABS_LIST: { + key: TPageNavigationPaneTab; + i18n_label: string; +}[] = [ + PAGE_NAVIGATION_PANE_TABS_LIST.outline, + PAGE_NAVIGATION_PANE_TABS_LIST.info, + PAGE_NAVIGATION_PANE_TABS_LIST.assets, +]; diff --git a/web/ce/components/pages/navigation-pane/tab-panels/assets.tsx b/web/ce/components/pages/navigation-pane/tab-panels/assets.tsx new file mode 100644 index 00000000000..960f0653ccf --- /dev/null +++ b/web/ce/components/pages/navigation-pane/tab-panels/assets.tsx @@ -0,0 +1,11 @@ +// plane imports +import { TEditorAsset } from "@plane/editor"; +// store +import { TPageInstance } from "@/store/pages/base-page"; + +export type TAdditionalPageNavigationPaneAssetItemProps = { + asset: TEditorAsset; + page: TPageInstance; +}; + +export const AdditionalPageNavigationPaneAssetItem: React.FC = () => null; diff --git a/web/ce/components/pages/navigation-pane/tab-panels/empty-states/assets.tsx b/web/ce/components/pages/navigation-pane/tab-panels/empty-states/assets.tsx new file mode 100644 index 00000000000..e0bf49ad153 --- /dev/null +++ b/web/ce/components/pages/navigation-pane/tab-panels/empty-states/assets.tsx @@ -0,0 +1,26 @@ +import Image from "next/image"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// hooks +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +export const PageNavigationPaneAssetsTabEmptyState = () => { + // asset resolved path + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/pages/navigation-pane/assets" }); + // translation + const { t } = useTranslation(); + + return ( +
+
+ An image depicting the assets of a page +
+

{t("page_navigation_pane.tabs.assets.empty_state.title")}

+

+ {t("page_navigation_pane.tabs.assets.empty_state.description")} +

+
+
+
+ ); +}; diff --git a/web/ce/components/pages/navigation-pane/tab-panels/empty-states/outline.tsx b/web/ce/components/pages/navigation-pane/tab-panels/empty-states/outline.tsx new file mode 100644 index 00000000000..dd71bf3c1b4 --- /dev/null +++ b/web/ce/components/pages/navigation-pane/tab-panels/empty-states/outline.tsx @@ -0,0 +1,26 @@ +import Image from "next/image"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// hooks +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +export const PageNavigationPaneOutlineTabEmptyState = () => { + // asset resolved path + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/pages/navigation-pane/outline" }); + // translation + const { t } = useTranslation(); + + return ( +
+
+ An image depicting the outline of a page +
+

{t("page_navigation_pane.tabs.outline.empty_state.title")}

+

+ {t("page_navigation_pane.tabs.outline.empty_state.description")} +

+
+
+
+ ); +}; diff --git a/web/ce/components/pages/navigation-pane/tab-panels/root.tsx b/web/ce/components/pages/navigation-pane/tab-panels/root.tsx new file mode 100644 index 00000000000..93419437a96 --- /dev/null +++ b/web/ce/components/pages/navigation-pane/tab-panels/root.tsx @@ -0,0 +1,13 @@ +// store +import type { TPageInstance } from "@/store/pages/base-page"; +// local imports +import { TPageNavigationPaneTab } from ".."; + +export type TPageNavigationPaneAdditionalTabPanelsRootProps = { + activeTab: TPageNavigationPaneTab; + page: TPageInstance; +}; + +export const PageNavigationPaneAdditionalTabPanelsRoot: React.FC< + TPageNavigationPaneAdditionalTabPanelsRootProps +> = () => null; diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index a06f1131266..bbcc596f8e3 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -10,7 +10,7 @@ import { TRealtimeConfig, TServerHandler, } from "@plane/editor"; -// plane imports +import { useTranslation } from "@plane/i18n"; import { TSearchEntityRequestPayload, TSearchResponse, TWebhookConnectionQueryParams } from "@plane/types"; import { ERowVariant, Row } from "@plane/ui"; import { cn, generateRandomColor, hslToHex } from "@plane/utils"; @@ -46,7 +46,9 @@ type Props = { editorForwardRef: React.RefObject; handleConnectionStatus: Dispatch>; handleEditorReady: (status: boolean) => void; + handleOpenNavigationPane: () => void; handlers: TEditorBodyHandlers; + isNavigationPaneOpen: boolean; page: TPageInstance; webhookConnectionParams: TWebhookConnectionQueryParams; workspaceSlug: string; @@ -58,7 +60,9 @@ export const PageEditorBody: React.FC = observer((props) => { editorForwardRef, handleConnectionStatus, handleEditorReady, + handleOpenNavigationPane, handlers, + isNavigationPaneOpen, page, webhookConnectionParams, workspaceSlug, @@ -67,9 +71,14 @@ export const PageEditorBody: React.FC = observer((props) => { const { data: currentUser } = useUser(); const { getWorkspaceBySlug } = useWorkspace(); const { getUserDetails } = useMember(); - // derived values - const { id: pageId, name: pageTitle, isContentEditable, updateTitle, editorRef } = page; + const { + id: pageId, + name: pageTitle, + isContentEditable, + updateTitle, + editor: { editorRef, updateAssetsList }, + } = page; const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? ""; // issue-embed const { issueEmbedProps } = useIssueEmbed({ @@ -84,6 +93,8 @@ export const PageEditorBody: React.FC = observer((props) => { const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug); // page filters const { fontSize, fontStyle, isFullWidth } = usePageFilters(); + // translation + const { t } = useTranslation(); // derived values const displayConfig: TDisplayConfig = useMemo( () => ({ @@ -167,18 +178,25 @@ export const PageEditorBody: React.FC = observer((props) => { >
{/* table of content */} -
-
-
-
- -
-
- + {!isNavigationPaneOpen && ( +
+
+
+
+ +
+
+ +
-
+ )}
@@ -218,6 +236,7 @@ export const PageEditorBody: React.FC = observer((props) => { aiHandler={{ menu: getAIMenu, }} + onAssetChange={updateAssetsList} />
diff --git a/web/core/components/pages/editor/page-root.tsx b/web/core/components/pages/editor/page-root.tsx index 2f1595e3307..e0e2fa9f4b5 100644 --- a/web/core/components/pages/editor/page-root.tsx +++ b/web/core/components/pages/editor/page-root.tsx @@ -1,9 +1,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; -// editor +// plane imports import { EditorRefApi } from "@plane/editor"; -// types import { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types"; // components import { @@ -18,8 +17,17 @@ import { import { useAppRouter } from "@/hooks/use-app-router"; import { usePageFallback } from "@/hooks/use-page-fallback"; import { useQueryParams } from "@/hooks/use-query-params"; +// plane web import +import { TPageNavigationPaneTab } from "@/plane-web/components/pages/navigation-pane"; // store import { TPageInstance } from "@/store/pages/base-page"; +// local imports +import { + PAGE_NAVIGATION_PANE_TAB_KEYS, + PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, + PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, + PageNavigationPaneRoot, +} from "../navigation-pane"; export type TPageRootHandlers = { create: (payload: Partial) => Promise | undefined>; @@ -45,7 +53,6 @@ export const PageRoot = observer((props: TPageRootProps) => { // states const [editorReady, setEditorReady] = useState(false); const [hasConnectionFailed, setHasConnectionFailed] = useState(false); - const [isVersionsOverlayOpen, setIsVersionsOverlayOpen] = useState(false); // refs const editorRef = useRef(null); // router @@ -53,7 +60,10 @@ export const PageRoot = observer((props: TPageRootProps) => { // search params const searchParams = useSearchParams(); // derived values - const { isContentEditable, setEditorRef } = page; + const { + isContentEditable, + editor: { setEditorRef }, + } = page; // page fallback usePageFallback({ editorRef, @@ -67,11 +77,11 @@ export const PageRoot = observer((props: TPageRootProps) => { const handleEditorReady = useCallback( (status: boolean) => { setEditorReady(status); - if (editorRef.current && !page.editorRef) { + if (editorRef.current && !page.editor.editorRef) { setEditorRef(editorRef.current); } }, - [page.editorRef, setEditorRef] + [page.editor.editorRef, setEditorRef] ); useEffect(() => { @@ -80,27 +90,10 @@ export const PageRoot = observer((props: TPageRootProps) => { }, 0); }, [isContentEditable, setEditorRef]); - const version = searchParams.get("version"); - useEffect(() => { - if (!version) { - setIsVersionsOverlayOpen(false); - return; - } - setIsVersionsOverlayOpen(true); - }, [version]); - - const handleCloseVersionsOverlay = () => { - const updatedRoute = updateQueryParams({ - paramsToRemove: ["version"], - }); - router.push(updatedRoute); - }; - - const handleRestoreVersion = async (descriptionHTML: string) => { + const handleRestoreVersion = useCallback(async (descriptionHTML: string) => { editorRef.current?.clearEditor(); editorRef.current?.setEditorValue(descriptionHTML); - }; - const currentVersionDescription = editorRef.current?.getDocument().html; + }, []); // reset editor ref on unmount useEffect( @@ -110,32 +103,64 @@ export const PageRoot = observer((props: TPageRootProps) => { [setEditorRef] ); + const navigationPaneQueryParam = searchParams.get( + PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM + ) as TPageNavigationPaneTab | null; + const isValidNavigationPaneTab = + !!navigationPaneQueryParam && PAGE_NAVIGATION_PANE_TAB_KEYS.includes(navigationPaneQueryParam); + + const handleOpenNavigationPane = useCallback(() => { + const updatedRoute = updateQueryParams({ + paramsToAdd: { [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM]: "outline" }, + }); + router.push(updatedRoute); + }, [router, updateQueryParams]); + + const handleCloseNavigationPane = useCallback(() => { + const updatedRoute = updateQueryParams({ + paramsToRemove: [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM], + }); + router.push(updatedRoute); + }, [router, updateQueryParams]); + return ( - <> - - - +
+ + + +
+ - +
); }); diff --git a/web/core/components/pages/editor/summary/content-browser.tsx b/web/core/components/pages/editor/summary/content-browser.tsx index e0ef271168c..d22a1ec2cc5 100644 --- a/web/core/components/pages/editor/summary/content-browser.tsx +++ b/web/core/components/pages/editor/summary/content-browser.tsx @@ -1,17 +1,20 @@ import { useState, useEffect, useCallback } from "react"; -// plane editor +// plane imports import { EditorRefApi, IMarking } from "@plane/editor"; +import { cn } from "@plane/utils"; // components -import { OutlineHeading1, OutlineHeading2, OutlineHeading3 } from "./heading-components"; +import { OutlineHeading1, OutlineHeading2, OutlineHeading3, THeadingComponentProps } from "./heading-components"; type Props = { + className?: string; + emptyState?: React.ReactNode; editorRef: EditorRefApi | null; setSidePeekVisible?: (sidePeekState: boolean) => void; showOutline?: boolean; }; export const PageContentBrowser: React.FC = (props) => { - const { editorRef, setSidePeekVisible, showOutline = false } = props; + const { className, editorRef, emptyState, setSidePeekVisible, showOutline = false } = props; // states const [headings, setHeadings] = useState([]); @@ -20,7 +23,7 @@ export const PageContentBrowser: React.FC = (props) => { // for initial render of this component to get the editor headings setHeadings(editorRef?.getHeadings() ?? []); return () => { - if (unsubscribe) unsubscribe(); + unsubscribe?.(); }; }, [editorRef]); @@ -33,15 +36,25 @@ export const PageContentBrowser: React.FC = (props) => { ); const HeadingComponent: { - [key: number]: React.FC<{ marking: IMarking; onClick: () => void }>; + [key: number]: React.FC; } = { 1: OutlineHeading1, 2: OutlineHeading2, 3: OutlineHeading3, }; + if (headings.length === 0) return emptyState ?? null; + return ( -
+
{headings.map((marking) => { const Component = HeadingComponent[marking.level]; if (!Component) return null; diff --git a/web/core/components/pages/editor/summary/heading-components.tsx b/web/core/components/pages/editor/summary/heading-components.tsx index c2e78dd67f1..d06eaded41c 100644 --- a/web/core/components/pages/editor/summary/heading-components.tsx +++ b/web/core/components/pages/editor/summary/heading-components.tsx @@ -1,37 +1,29 @@ -// plane editor +// plane imports import type { IMarking } from "@plane/editor"; +import { cn } from "@plane/utils"; export type THeadingComponentProps = { marking: IMarking; onClick: (event: React.MouseEvent) => void; }; +const COMMON_CLASSNAME = + "w-full py-1 text-left font-medium text-custom-text-300 hover:text-custom-primary-100 truncate transition-colors"; + export const OutlineHeading1 = ({ marking, onClick }: THeadingComponentProps) => ( - ); export const OutlineHeading2 = ({ marking, onClick }: THeadingComponentProps) => ( - ); export const OutlineHeading3 = ({ marking, onClick }: THeadingComponentProps) => ( - ); diff --git a/web/core/components/pages/editor/toolbar/index.ts b/web/core/components/pages/editor/toolbar/index.ts index 66652b2dbd0..2c36785bd3e 100644 --- a/web/core/components/pages/editor/toolbar/index.ts +++ b/web/core/components/pages/editor/toolbar/index.ts @@ -1,5 +1,4 @@ export * from "./color-dropdown"; -export * from "./info-popover"; export * from "./options-dropdown"; export * from "./root"; export * from "./toolbar"; diff --git a/web/core/components/pages/editor/toolbar/info-popover.tsx b/web/core/components/pages/editor/toolbar/info-popover.tsx deleted file mode 100644 index 49ad06b9b0b..00000000000 --- a/web/core/components/pages/editor/toolbar/info-popover.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useState } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { usePopper } from "react-popper"; -import { Info } from "lucide-react"; -// plane imports -import { Avatar } from "@plane/ui"; -import { calculateTimeAgoShort, getFileURL, getReadTimeFromWordsCount, renderFormattedDate } from "@plane/utils"; -// hooks -import { useMember } from "@/hooks/store"; -// store -import { TPageInstance } from "@/store/pages/base-page"; - -type Props = { - page: TPageInstance; -}; - -export const PageInfoPopover: React.FC = observer((props) => { - const { page } = props; - // states - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - // refs - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - // router - const { workspaceSlug } = useParams(); - // popper-js - const { styles: infoPopoverStyles, attributes: infoPopoverAttributes } = usePopper(referenceElement, popperElement, { - placement: "bottom-start", - }); - // store hooks - const { getUserDetails } = useMember(); - // derived values - const editorInformation = page.updated_by ? getUserDetails(page.updated_by) : undefined; - const creatorInformation = page.created_by ? getUserDetails(page.created_by) : undefined; - - const documentsInfo = page.editorRef?.getDocumentInfo() || { words: 0, characters: 0, paragraphs: 0 }; - - const secondsToReadableTime = () => { - const wordsCount = documentsInfo.words; - const readTimeInSeconds = Number(getReadTimeFromWordsCount(wordsCount).toFixed(0)); - return readTimeInSeconds < 60 ? `${readTimeInSeconds}s` : `${Math.ceil(readTimeInSeconds / 60)}m`; - }; - - const documentInfoCards = [ - { - key: "words-count", - title: "Words", - info: documentsInfo.words, - }, - { - key: "characters-count", - title: "Characters", - info: documentsInfo.characters, - }, - { - key: "paragraphs-count", - title: "Paragraphs", - info: documentsInfo.paragraphs, - }, - { - key: "read-time", - title: "Read time", - info: secondsToReadableTime(), - }, - ]; - - return ( -
setIsPopoverOpen(true)} - onMouseLeave={() => setIsPopoverOpen(false)} - > - - {isPopoverOpen && ( -
-
- {documentInfoCards.map((card) => ( -
-
{card.info}
-

{card.title}

-
- ))} -
-
-
-

Edited by

- - - - {editorInformation?.display_name}{" "} - {calculateTimeAgoShort(page.updated_at ?? "")} ago - - -
-
-

Created by

- - - - {creatorInformation?.display_name}{" "} - {renderFormattedDate(page.created_at)} - - -
-
-
- )} -
- ); -}); diff --git a/web/core/components/pages/editor/toolbar/options-dropdown.tsx b/web/core/components/pages/editor/toolbar/options-dropdown.tsx index 407ee03c451..8ff97657125 100644 --- a/web/core/components/pages/editor/toolbar/options-dropdown.tsx +++ b/web/core/components/pages/editor/toolbar/options-dropdown.tsx @@ -2,18 +2,15 @@ import { useMemo, useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/navigation"; -import { ArrowUpToLine, Clipboard, History } from "lucide-react"; +import { ArrowUpToLine, Clipboard } from "lucide-react"; // plane imports import { TContextMenuItem, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; -// components import { copyTextToClipboard } from "@plane/utils"; +// components import { ExportPageModal, PageActions, TPageActions } from "@/components/pages"; -// helpers // hooks import { usePageFilters } from "@/hooks/use-page-filters"; -import { useQueryParams } from "@/hooks/use-query-params"; -// plane web hooks +// plane web imports import { EPageStoreType } from "@/plane-web/hooks/store"; // store import { TPageInstance } from "@/store/pages/base-page"; @@ -27,14 +24,14 @@ export const PageOptionsDropdown: React.FC = observer((props) => { const { page, storeType } = props; // states const [isExportModalOpen, setIsExportModalOpen] = useState(false); - // router - const router = useRouter(); // store values - const { name, isContentEditable, editorRef } = page; + const { + name, + isContentEditable, + editor: { editorRef }, + } = page; // page filters const { isFullWidth, handleFullWidth, isStickyToolbarEnabled, handleStickyToolbar } = usePageFilters(); - // update query params - const { updateQueryParams } = useQueryParams(); // menu items list const EXTRA_MENU_OPTIONS: (TContextMenuItem & { key: TPageActions })[] = useMemo( () => [ @@ -77,19 +74,6 @@ export const PageOptionsDropdown: React.FC = observer((props) => { icon: Clipboard, shouldRender: true, }, - { - key: "version-history", - action: () => { - // add query param, version=current to the route - const updatedRoute = updateQueryParams({ - paramsToAdd: { version: "current" }, - }); - router.push(updatedRoute); - }, - title: "Version history", - icon: History, - shouldRender: true, - }, { key: "export", action: () => setIsExportModalOpen(true), @@ -98,16 +82,7 @@ export const PageOptionsDropdown: React.FC = observer((props) => { shouldRender: true, }, ], - [ - editorRef, - handleFullWidth, - handleStickyToolbar, - isContentEditable, - isFullWidth, - isStickyToolbarEnabled, - router, - updateQueryParams, - ] + [editorRef, handleFullWidth, handleStickyToolbar, isContentEditable, isFullWidth, isStickyToolbarEnabled] ); return ( diff --git a/web/core/components/pages/editor/toolbar/root.tsx b/web/core/components/pages/editor/toolbar/root.tsx index 72c9da3d488..e779c618e58 100644 --- a/web/core/components/pages/editor/toolbar/root.tsx +++ b/web/core/components/pages/editor/toolbar/root.tsx @@ -1,6 +1,10 @@ import { observer } from "mobx-react"; -// components +import { PanelRight } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/ui"; import { cn } from "@plane/utils"; +// components import { PageToolbar } from "@/components/pages"; // helpers // hooks @@ -11,38 +15,74 @@ import { PageCollaboratorsList } from "@/plane-web/components/pages/header/colla import { TPageInstance } from "@/store/pages/base-page"; type Props = { + handleOpenNavigationPane: () => void; + isNavigationPaneOpen: boolean; page: TPageInstance; }; export const PageEditorToolbarRoot: React.FC = observer((props) => { - const { page } = props; + const { handleOpenNavigationPane, isNavigationPaneOpen, page } = props; + // translation + const { t } = useTranslation(); // derived values - const { isContentEditable, editorRef } = page; + const { + isContentEditable, + editor: { editorRef }, + } = page; // page filters const { isFullWidth, isStickyToolbarEnabled } = usePageFilters(); // derived values const shouldHideToolbar = !isStickyToolbarEnabled || !isContentEditable; return ( -
+ <>
-
- {editorRef && } - +
+
+ {editorRef && } +
+ + {!isNavigationPaneOpen && ( + + )} +
+
-
+ {shouldHideToolbar && ( +
+ {!isNavigationPaneOpen && ( + + + + )} +
+ )} + ); }); diff --git a/web/core/components/pages/header/actions.tsx b/web/core/components/pages/header/actions.tsx index 6c6cb2f6c75..ccf07191a3a 100644 --- a/web/core/components/pages/header/actions.tsx +++ b/web/core/components/pages/header/actions.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; // components -import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages"; +import { PageOptionsDropdown } from "@/components/pages"; // plane web components import { PageLockControl } from "@/plane-web/components/pages/header/lock-control"; import { PageMoveControl } from "@/plane-web/components/pages/header/move-control"; @@ -31,7 +31,6 @@ export const PageHeaderActions: React.FC = observer((props) => { - diff --git a/web/core/components/pages/navigation-pane/index.ts b/web/core/components/pages/navigation-pane/index.ts new file mode 100644 index 00000000000..52026510632 --- /dev/null +++ b/web/core/components/pages/navigation-pane/index.ts @@ -0,0 +1,11 @@ +// plane web imports +import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/plane-web/components/pages/navigation-pane"; + +export * from "./root"; + +export const PAGE_NAVIGATION_PANE_WIDTH = 294; + +export const PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM = "sidebarTab"; +export const PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM = "version"; + +export const PAGE_NAVIGATION_PANE_TAB_KEYS = ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => tab.key); diff --git a/web/core/components/pages/navigation-pane/root.tsx b/web/core/components/pages/navigation-pane/root.tsx new file mode 100644 index 00000000000..a2497d3856f --- /dev/null +++ b/web/core/components/pages/navigation-pane/root.tsx @@ -0,0 +1,88 @@ +import React, { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { ArrowRightCircle } from "lucide-react"; +import { Tab } from "@headlessui/react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/ui"; +// hooks +import { useQueryParams } from "@/hooks/use-query-params"; +// plane web components +import { TPageNavigationPaneTab } from "@/plane-web/components/pages/navigation-pane"; +// store +import { TPageInstance } from "@/store/pages/base-page"; +// local imports +import { TPageRootHandlers } from "../editor"; +import { PageNavigationPaneTabPanelsRoot } from "./tab-panels/root"; +import { PageNavigationPaneTabsList } from "./tabs-list"; +import { + PAGE_NAVIGATION_PANE_TAB_KEYS, + PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, + PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, + PAGE_NAVIGATION_PANE_WIDTH, +} from "./index"; + +type Props = { + handleClose: () => void; + isNavigationPaneOpen: boolean; + page: TPageInstance; + versionHistory: Pick; +}; + +export const PageNavigationPaneRoot: React.FC = observer((props) => { + const { handleClose, isNavigationPaneOpen, page, versionHistory } = props; + // navigation + const router = useRouter(); + const searchParams = useSearchParams(); + // query params + const { updateQueryParams } = useQueryParams(); + // derived values + const navigationPaneQueryParam = searchParams.get( + PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM + ) as TPageNavigationPaneTab | null; + const activeTab: TPageNavigationPaneTab = navigationPaneQueryParam || "outline"; + const selectedIndex = PAGE_NAVIGATION_PANE_TAB_KEYS.indexOf(activeTab); + // translation + const { t } = useTranslation(); + + const handleTabChange = useCallback( + (index: number) => { + const updatedTab = PAGE_NAVIGATION_PANE_TAB_KEYS[index]; + const isUpdatedTabInfo = updatedTab === "info"; + const updatedRoute = updateQueryParams({ + paramsToAdd: { [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM]: updatedTab }, + paramsToRemove: !isUpdatedTabInfo ? [PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM] : undefined, + }); + router.push(updatedRoute); + }, + [router, updateQueryParams] + ); + + return ( + + ); +}); diff --git a/web/core/components/pages/navigation-pane/tab-panels/assets.tsx b/web/core/components/pages/navigation-pane/tab-panels/assets.tsx new file mode 100644 index 00000000000..f770ae7b40e --- /dev/null +++ b/web/core/components/pages/navigation-pane/tab-panels/assets.tsx @@ -0,0 +1,109 @@ +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { Download } from "lucide-react"; +// plane imports +import { CORE_EXTENSIONS, type TEditorAsset } from "@plane/editor"; +import { useTranslation } from "@plane/i18n"; +import { getEditorAssetDownloadSrc, getEditorAssetSrc } from "@plane/utils"; +// plane web imports +import { AdditionalPageNavigationPaneAssetItem } from "@/plane-web/components/pages/navigation-pane/tab-panels/assets"; +import { PageNavigationPaneAssetsTabEmptyState } from "@/plane-web/components/pages/navigation-pane/tab-panels/empty-states/assets"; +// store +import { TPageInstance } from "@/store/pages/base-page"; + +type Props = { + page: TPageInstance; +}; + +type AssetItemProps = { + asset: TEditorAsset; + page: TPageInstance; +}; + +const AssetItem = observer((props: AssetItemProps) => { + const { asset, page } = props; + // navigation + const { workspaceSlug } = useParams(); + // derived values + const { project_ids } = page; + // translation + const { t } = useTranslation(); + + const getAssetSrc = (path: string) => { + if (!path || !workspaceSlug) return ""; + if (path.startsWith("http")) { + return path; + } else { + return ( + getEditorAssetSrc({ + assetId: path, + projectId: project_ids?.[0], + workspaceSlug: workspaceSlug.toString(), + }) ?? "" + ); + } + }; + + const getAssetDownloadSrc = (path: string) => { + if (!path || !workspaceSlug) return ""; + if (path.startsWith("http")) { + return path; + } else { + return ( + getEditorAssetDownloadSrc({ + assetId: path, + projectId: project_ids?.[0], + workspaceSlug: workspaceSlug.toString(), + }) ?? "" + ); + } + }; + + if ([CORE_EXTENSIONS.IMAGE, CORE_EXTENSIONS.CUSTOM_IMAGE].includes(asset.type)) + return ( + +
+ + + ); + + return ; +}); + +export const PageNavigationPaneAssetsTabPanel: React.FC = observer((props) => { + const { page } = props; + // derived values + const { + editor: { assetsList }, + } = page; + + if (assetsList.length === 0) return ; + + return ( +
+ {assetsList?.map((asset) => )} +
+ ); +}); diff --git a/web/core/components/pages/navigation-pane/tab-panels/info/actors-info.tsx b/web/core/components/pages/navigation-pane/tab-panels/info/actors-info.tsx new file mode 100644 index 00000000000..d4c166ddff0 --- /dev/null +++ b/web/core/components/pages/navigation-pane/tab-panels/info/actors-info.tsx @@ -0,0 +1,68 @@ +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Avatar } from "@plane/ui"; +import { calculateTimeAgoShort, getFileURL, renderFormattedDate } from "@plane/utils"; +// hooks +import { useMember } from "@/hooks/store"; +// store +import { TPageInstance } from "@/store/pages/base-page"; + +type Props = { + page: TPageInstance; +}; + +export const PageNavigationPaneInfoTabActorsInfo: React.FC = observer((props) => { + const { page } = props; + // navigation + const { workspaceSlug } = useParams(); + // store hooks + const { getUserDetails } = useMember(); + // derived values + const { created_by, updated_by } = page; + const editorInformation = updated_by ? getUserDetails(updated_by) : undefined; + const creatorInformation = created_by ? getUserDetails(created_by) : undefined; + // translation + const { t } = useTranslation(); + + return ( +
+
+

+ {t("page_navigation_pane.tabs.info.actors_info.edited_by")} +

+
+ + + {editorInformation?.display_name ?? t("common.deactivated_user")} + + {calculateTimeAgoShort(page.updated_at ?? "")} ago +
+
+
+

+ {t("page_navigation_pane.tabs.info.actors_info.created_by")} +

+
+ + + {creatorInformation?.display_name ?? t("common.deactivated_user")} + + {renderFormattedDate(page.created_at)} +
+
+
+ ); +}); diff --git a/web/core/components/pages/navigation-pane/tab-panels/info/document-info.tsx b/web/core/components/pages/navigation-pane/tab-panels/info/document-info.tsx new file mode 100644 index 00000000000..b301e9cbe77 --- /dev/null +++ b/web/core/components/pages/navigation-pane/tab-panels/info/document-info.tsx @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { TDocumentInfo } from "@plane/editor"; +import { useTranslation } from "@plane/i18n"; +import { getReadTimeFromWordsCount } from "@plane/utils"; +// store +import { TPageInstance } from "@/store/pages/base-page"; + +type Props = { + page: TPageInstance; +}; + +const DEFAULT_DOCUMENT_INFO: TDocumentInfo = { + words: 0, + characters: 0, + paragraphs: 0, +}; + +export const PageNavigationPaneInfoTabDocumentInfo: React.FC = observer((props) => { + const { page } = props; + // states + const [documentInfo, setDocumentInfo] = useState(DEFAULT_DOCUMENT_INFO); + // derived values + const { + editor: { editorRef }, + } = page; + // translation + const { t } = useTranslation(); + // subscribe to asset changes + useEffect(() => { + const unsubscribe = editorRef?.onDocumentInfoChange(setDocumentInfo); + // for initial render of this component to get the editor assets + setDocumentInfo(editorRef?.getDocumentInfo() ?? DEFAULT_DOCUMENT_INFO); + return () => { + unsubscribe?.(); + }; + }, [editorRef]); + + const secondsToReadableTime = useCallback(() => { + const wordsCount = documentInfo.words; + const readTimeInSeconds = Number(getReadTimeFromWordsCount(wordsCount).toFixed(0)); + return readTimeInSeconds < 60 ? `${readTimeInSeconds}s` : `${Math.ceil(readTimeInSeconds / 60)}m`; + }, [documentInfo.words]); + + const documentInfoCards = useMemo( + () => [ + { + key: "words-count", + title: t("page_navigation_pane.tabs.info.document_info.words"), + info: documentInfo.words, + }, + { + key: "characters-count", + title: t("page_navigation_pane.tabs.info.document_info.characters"), + info: documentInfo.characters, + }, + { + key: "paragraphs-count", + title: t("page_navigation_pane.tabs.info.document_info.paragraphs"), + info: documentInfo.paragraphs, + }, + { + key: "read-time", + title: t("page_navigation_pane.tabs.info.document_info.read_time"), + info: secondsToReadableTime(), + }, + ], + [documentInfo, secondsToReadableTime, t] + ); + + return ( +
+ {documentInfoCards.map((card) => ( +
+
{card.info}
+

{card.title}

+
+ ))} +
+ ); +}); diff --git a/web/core/components/pages/navigation-pane/tab-panels/info/root.tsx b/web/core/components/pages/navigation-pane/tab-panels/info/root.tsx new file mode 100644 index 00000000000..77edc24e046 --- /dev/null +++ b/web/core/components/pages/navigation-pane/tab-panels/info/root.tsx @@ -0,0 +1,27 @@ +import { observer } from "mobx-react"; +// components +import { TPageRootHandlers } from "@/components/pages/editor"; +// store +import { TPageInstance } from "@/store/pages/base-page"; +// local imports +import { PageNavigationPaneInfoTabActorsInfo } from "./actors-info"; +import { PageNavigationPaneInfoTabDocumentInfo } from "./document-info"; +import { PageNavigationPaneInfoTabVersionHistory } from "./version-history"; + +type Props = { + page: TPageInstance; + versionHistory: Pick; +}; + +export const PageNavigationPaneInfoTabPanel: React.FC = observer((props) => { + const { page, versionHistory } = props; + + return ( +
+ + +
+ +
+ ); +}); diff --git a/web/core/components/pages/navigation-pane/tab-panels/info/version-history.tsx b/web/core/components/pages/navigation-pane/tab-panels/info/version-history.tsx new file mode 100644 index 00000000000..31069299f30 --- /dev/null +++ b/web/core/components/pages/navigation-pane/tab-panels/info/version-history.tsx @@ -0,0 +1,142 @@ +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import useSWR from "swr"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { TPageVersion } from "@plane/types"; +import { Avatar } from "@plane/ui"; +import { cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils"; +// components +import { TPageRootHandlers } from "@/components/pages/editor"; +// hooks +import { useMember } from "@/hooks/store"; +import { useQueryParams } from "@/hooks/use-query-params"; +// store +import { TPageInstance } from "@/store/pages/base-page"; +// local imports +import { PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM } from "../.."; + +type Props = { + page: TPageInstance; + versionHistory: Pick; +}; + +type VersionHistoryItemProps = { + getVersionLink: (versionID: string) => string; + isVersionActive: boolean; + version: TPageVersion; +}; + +const VersionHistoryItem = observer((props: VersionHistoryItemProps) => { + const { getVersionLink, isVersionActive, version } = props; + // store hooks + const { getUserDetails } = useMember(); + // derived values + const versionCreator = getUserDetails(version.created_by); + // translation + const { t } = useTranslation(); + + return ( +
  • + {/* timeline icon */} +
    +
    +
    + {/* end timeline icon */} + +

    + {renderFormattedDate(version.last_saved_at)}, {renderFormattedTime(version.last_saved_at)} +

    +

    + + {versionCreator?.display_name ?? t("common.deactivated_user")} +

    + +
  • + ); +}); + +export const PageNavigationPaneInfoTabVersionHistory: React.FC = observer((props) => { + const { page, versionHistory } = props; + // navigation + const searchParams = useSearchParams(); + const activeVersion = searchParams.get(PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM); + // derived values + const { id } = page; + // translation + const { t } = useTranslation(); + // query params + const { updateQueryParams } = useQueryParams(); + // fetch all versions + const { data: versionsList } = useSWR( + id ? `PAGE_VERSIONS_LIST_${id}` : null, + id ? () => versionHistory.fetchAllVersions(id) : null + ); + + const getVersionLink = useCallback( + (versionID?: string) => { + if (versionID) { + return updateQueryParams({ + paramsToAdd: { [PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM]: versionID }, + }); + } else { + return updateQueryParams({ + paramsToRemove: [PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM], + }); + } + }, + [updateQueryParams] + ); + + return ( +
    +

    + {t("page_navigation_pane.tabs.info.version_history.label")} +

    +
    +
      + {/* timeline line */} +
      +
      +
      + {/* end timeline line */} +
    • + {/* timeline icon */} +
      +
      +
      + {/* end timeline icon */} + + {t("page_navigation_pane.tabs.info.version_history.current_version")} + +
    • + {versionsList?.map((version) => ( + + ))} +
    +
    +
    + ); +}); diff --git a/web/core/components/pages/navigation-pane/tab-panels/outline.tsx b/web/core/components/pages/navigation-pane/tab-panels/outline.tsx new file mode 100644 index 00000000000..d563e52ebfa --- /dev/null +++ b/web/core/components/pages/navigation-pane/tab-panels/outline.tsx @@ -0,0 +1,28 @@ +// plane web imports +import { PageNavigationPaneOutlineTabEmptyState } from "@/plane-web/components/pages/navigation-pane/tab-panels/empty-states/outline"; +// store +import { TPageInstance } from "@/store/pages/base-page"; +// local imports +import { PageContentBrowser } from "../../editor"; + +type Props = { + page: TPageInstance; +}; + +export const PageNavigationPaneOutlineTabPanel: React.FC = (props) => { + const { page } = props; + // derived values + const { + editor: { editorRef }, + } = page; + + return ( +
    + } + /> +
    + ); +}; diff --git a/web/core/components/pages/navigation-pane/tab-panels/root.tsx b/web/core/components/pages/navigation-pane/tab-panels/root.tsx new file mode 100644 index 00000000000..c9880f0d58c --- /dev/null +++ b/web/core/components/pages/navigation-pane/tab-panels/root.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Tab } from "@headlessui/react"; +// components +import { TPageRootHandlers } from "@/components/pages/editor"; +// plane web imports +import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/plane-web/components/pages/navigation-pane"; +import { PageNavigationPaneAdditionalTabPanelsRoot } from "@/plane-web/components/pages/navigation-pane/tab-panels/root"; +// store +import { TPageInstance } from "@/store/pages/base-page"; +// local imports +import { PageNavigationPaneAssetsTabPanel } from "./assets"; +import { PageNavigationPaneInfoTabPanel } from "./info/root"; +import { PageNavigationPaneOutlineTabPanel } from "./outline"; + +type Props = { + page: TPageInstance; + versionHistory: Pick; +}; + +export const PageNavigationPaneTabPanelsRoot: React.FC = (props) => { + const { page, versionHistory } = props; + + return ( + + {ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => ( + + {tab.key === "outline" && } + {tab.key === "info" && } + {tab.key === "assets" && } + + + ))} + + ); +}; diff --git a/web/core/components/pages/navigation-pane/tabs-list.tsx b/web/core/components/pages/navigation-pane/tabs-list.tsx new file mode 100644 index 00000000000..bf438321683 --- /dev/null +++ b/web/core/components/pages/navigation-pane/tabs-list.tsx @@ -0,0 +1,37 @@ +import { Tab } from "@headlessui/react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// plane web components +import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/plane-web/components/pages/navigation-pane"; + +export const PageNavigationPaneTabsList = () => { + // translation + const { t } = useTranslation(); + + return ( + + {({ selectedIndex }) => ( + <> + {ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => ( + + {t(tab.i18n_label)} + + ))} + {/* active tab indicator */} +
    + + )} + + ); +}; diff --git a/web/core/components/pages/version/editor.tsx b/web/core/components/pages/version/editor.tsx index 8491408ac8c..1a2c23e2872 100644 --- a/web/core/components/pages/version/editor.tsx +++ b/web/core/components/pages/version/editor.tsx @@ -16,13 +16,11 @@ import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed"; export type TVersionEditorProps = { activeVersion: string | null; - currentVersionDescription: string | null; - isCurrentVersionActive: boolean; versionDetails: TPageVersion | undefined; }; export const PagesVersionEditor: React.FC = observer((props) => { - const { activeVersion, currentVersionDescription, isCurrentVersionActive, versionDetails } = props; + const { activeVersion, versionDetails } = props; // store hooks const { getUserDetails } = useMember(); // params @@ -49,7 +47,7 @@ export const PagesVersionEditor: React.FC = observer((props wideLayout: true, }; - if (!isCurrentVersionActive && !versionDetails) + if (!versionDetails) return (
    @@ -91,7 +89,7 @@ export const PagesVersionEditor: React.FC = observer((props
    ); - const description = isCurrentVersionActive ? currentVersionDescription : versionDetails?.description_html; + const description = versionDetails?.description_html; if (description === undefined || description?.trim() === "") return null; return ( diff --git a/web/core/components/pages/version/index.ts b/web/core/components/pages/version/index.ts index 8e04e4de9e0..5da43e95910 100644 --- a/web/core/components/pages/version/index.ts +++ b/web/core/components/pages/version/index.ts @@ -1,6 +1,3 @@ export * from "./editor"; export * from "./main-content"; export * from "./root"; -export * from "./sidebar-list-item"; -export * from "./sidebar-list"; -export * from "./sidebar-root"; diff --git a/web/core/components/pages/version/main-content.tsx b/web/core/components/pages/version/main-content.tsx index e94bfefa780..ab05ba25669 100644 --- a/web/core/components/pages/version/main-content.tsx +++ b/web/core/components/pages/version/main-content.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; -import { TriangleAlert } from "lucide-react"; +import { EyeIcon, TriangleAlert } from "lucide-react"; // plane types import { TPageVersion } from "@plane/types"; // plane ui @@ -13,7 +13,6 @@ import { TVersionEditorProps } from "@/components/pages"; type Props = { activeVersion: string | null; - currentVersionDescription: string | null; editorComponent: React.FC; fetchVersionDetails: (pageId: string, versionId: string) => Promise; handleClose: () => void; @@ -23,16 +22,8 @@ type Props = { }; export const PageVersionsMainContent: React.FC = observer((props) => { - const { - activeVersion, - currentVersionDescription, - editorComponent, - fetchVersionDetails, - handleClose, - handleRestore, - pageId, - restoreEnabled, - } = props; + const { activeVersion, editorComponent, fetchVersionDetails, handleClose, handleRestore, pageId, restoreEnabled } = + props; // states const [isRestoring, setIsRestoring] = useState(false); const [isRetrying, setIsRetrying] = useState(false); @@ -42,12 +33,10 @@ export const PageVersionsMainContent: React.FC = observer((props) => { error: versionDetailsError, mutate: mutateVersionDetails, } = useSWR( - pageId && activeVersion && activeVersion !== "current" ? `PAGE_VERSION_${activeVersion}` : null, - pageId && activeVersion && activeVersion !== "current" ? () => fetchVersionDetails(pageId, activeVersion) : null + pageId && activeVersion ? `PAGE_VERSION_${activeVersion}` : null, + pageId && activeVersion ? () => fetchVersionDetails(pageId, activeVersion) : null ); - const isCurrentVersionActive = activeVersion === "current"; - const handleRestoreVersion = async () => { if (!restoreEnabled) return; setIsRestoring(true); @@ -96,14 +85,18 @@ export const PageVersionsMainContent: React.FC = observer((props) => { ) : ( <>
    -
    - {isCurrentVersionActive - ? "Current version" - : versionDetails +
    +
    + {versionDetails ? `${renderFormattedDate(versionDetails.last_saved_at)} ${renderFormattedTime(versionDetails.last_saved_at)}` : "Loading version details"} -
    - {!isCurrentVersionActive && restoreEnabled && ( +
    + + + View only + +
    + {restoreEnabled && (
    - +
    )} diff --git a/web/core/components/pages/version/root.tsx b/web/core/components/pages/version/root.tsx index f1dd0248b42..64b4f43dae4 100644 --- a/web/core/components/pages/version/root.tsx +++ b/web/core/components/pages/version/root.tsx @@ -1,54 +1,56 @@ +import { useCallback } from "react"; import { observer } from "mobx-react"; -// plane types +import { useRouter, useSearchParams } from "next/navigation"; +// plane imports import { TPageVersion } from "@plane/types"; -// components import { cn } from "@plane/utils"; -import { PageVersionsMainContent, PageVersionsSidebarRoot, TVersionEditorProps } from "@/components/pages"; -// helpers +// components +import { PageVersionsMainContent, TVersionEditorProps } from "@/components/pages"; +// hooks +import { useQueryParams } from "@/hooks/use-query-params"; +// local imports +import { PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, PAGE_NAVIGATION_PANE_WIDTH } from "../navigation-pane"; type Props = { - activeVersion: string | null; - currentVersionDescription: string | null; editorComponent: React.FC; - fetchAllVersions: (pageId: string) => Promise; fetchVersionDetails: (pageId: string, versionId: string) => Promise; handleRestore: (descriptionHTML: string) => Promise; - isOpen: boolean; - onClose: () => void; pageId: string; restoreEnabled: boolean; }; export const PageVersionsOverlay: React.FC = observer((props) => { - const { - activeVersion, - currentVersionDescription, - editorComponent, - fetchAllVersions, - fetchVersionDetails, - handleRestore, - isOpen, - onClose, - pageId, - restoreEnabled, - } = props; + const { editorComponent, fetchVersionDetails, handleRestore, pageId, restoreEnabled } = props; + // navigation + const router = useRouter(); + const searchParams = useSearchParams(); + // query params + const { updateQueryParams } = useQueryParams(); + // derived values + const activeVersion = searchParams.get(PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM); + const isOpen = !!activeVersion; - const handleClose = () => { - onClose(); - }; + const handleClose = useCallback(() => { + const updatedRoute = updateQueryParams({ + paramsToRemove: [PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM], + }); + router.push(updatedRoute); + }, [router, updateQueryParams]); return (
    = observer((props) => { pageId={pageId} restoreEnabled={restoreEnabled} /> -
    ); }); diff --git a/web/core/components/pages/version/sidebar-list-item.tsx b/web/core/components/pages/version/sidebar-list-item.tsx deleted file mode 100644 index c5df2c0d246..00000000000 --- a/web/core/components/pages/version/sidebar-list-item.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { observer } from "mobx-react"; -import Link from "next/link"; -// plane imports -import { useTranslation } from "@plane/i18n"; -import { TPageVersion } from "@plane/types"; -import { Avatar } from "@plane/ui"; -import { cn, renderFormattedDate, renderFormattedTime, getFileURL } from "@plane/utils"; -// helpers -// hooks -import { useMember } from "@/hooks/store"; - -type Props = { - href: string; - isActive: boolean; - version: TPageVersion; -}; - -export const PlaneVersionsSidebarListItem: React.FC = observer((props) => { - const { href, isActive, version } = props; - // store hooks - const { getUserDetails } = useMember(); - // derived values - const ownerDetails = getUserDetails(version.owned_by); - // translation - const { t } = useTranslation(); - - return ( - -

    - {renderFormattedDate(version.last_saved_at)} {renderFormattedTime(version.last_saved_at)} -

    -

    - - {ownerDetails?.display_name ?? t("common.deactivated_user")} -

    - - ); -}); diff --git a/web/core/components/pages/version/sidebar-list.tsx b/web/core/components/pages/version/sidebar-list.tsx deleted file mode 100644 index bff9c36988b..00000000000 --- a/web/core/components/pages/version/sidebar-list.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useState } from "react"; -import Link from "next/link"; -import useSWR from "swr"; -import { TriangleAlert } from "lucide-react"; -// plane types -import { TPageVersion } from "@plane/types"; -// plane ui -import { Button, Loader } from "@plane/ui"; -// components -import { cn } from "@plane/utils"; -import { PlaneVersionsSidebarListItem } from "@/components/pages"; -// helpers -// hooks -import { useQueryParams } from "@/hooks/use-query-params"; - -type Props = { - activeVersion: string | null; - fetchAllVersions: (pageId: string) => Promise; - isOpen: boolean; - pageId: string; -}; - -export const PageVersionsSidebarList: React.FC = (props) => { - const { activeVersion, fetchAllVersions, isOpen, pageId } = props; - // states - const [isRetrying, setIsRetrying] = useState(false); - // update query params - const { updateQueryParams } = useQueryParams(); - - const { - data: versionsList, - error: versionsListError, - mutate: mutateVersionsList, - } = useSWR( - pageId && isOpen ? `PAGE_VERSIONS_LIST_${pageId}` : null, - pageId && isOpen ? () => fetchAllVersions(pageId) : null - ); - - const handleRetry = async () => { - setIsRetrying(true); - await mutateVersionsList(); - setIsRetrying(false); - }; - - const getVersionLink = (versionID: string) => - updateQueryParams({ - paramsToAdd: { version: versionID }, - }); - - return ( -
    - -

    Current version

    - - {versionsListError ? ( -
    -
    - - - -
    -
    Something went wrong!
    -

    - There was a problem while loading previous -
    - versions, please try again. -

    -
    - -
    -
    - ) : versionsList ? ( - versionsList.map((version) => ( - - )) - ) : ( - - - - - - - - )} -
    - ); -}; diff --git a/web/core/components/pages/version/sidebar-root.tsx b/web/core/components/pages/version/sidebar-root.tsx deleted file mode 100644 index 793d7fed90f..00000000000 --- a/web/core/components/pages/version/sidebar-root.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { X } from "lucide-react"; -// plane types -import { TPageVersion } from "@plane/types"; -// components -import { PageVersionsSidebarList } from "@/components/pages"; - -type Props = { - activeVersion: string | null; - fetchAllVersions: (pageId: string) => Promise; - handleClose: () => void; - isOpen: boolean; - pageId: string; -}; - -export const PageVersionsSidebarRoot: React.FC = (props) => { - const { activeVersion, fetchAllVersions, handleClose, isOpen, pageId } = props; - - return ( -
    -
    -
    Version history
    - -
    - -
    - ); -}; diff --git a/web/core/hooks/use-query-params.ts b/web/core/hooks/use-query-params.ts index 8b689f0cbe0..84d65bebb30 100644 --- a/web/core/hooks/use-query-params.ts +++ b/web/core/hooks/use-query-params.ts @@ -1,3 +1,4 @@ +import { useCallback } from "react"; import { useSearchParams, usePathname } from "next/navigation"; type TParamsToAdd = { @@ -9,29 +10,27 @@ export const useQueryParams = () => { const searchParams = useSearchParams(); const pathname = usePathname(); - const updateQueryParams = ({ - paramsToAdd = {}, - paramsToRemove = [], - }: { - paramsToAdd?: TParamsToAdd; - paramsToRemove?: string[]; - }) => { - const currentParams = new URLSearchParams(searchParams.toString()); + const updateQueryParams = useCallback( + ({ paramsToAdd = {}, paramsToRemove = [] }: { paramsToAdd?: TParamsToAdd; paramsToRemove?: string[] }) => { + const currentParams = new URLSearchParams(searchParams.toString()); - // add or update query parameters - Object.keys(paramsToAdd).forEach((key) => { - currentParams.set(key, paramsToAdd[key]); - }); + // add or update query parameters + Object.keys(paramsToAdd).forEach((key) => { + currentParams.set(key, paramsToAdd[key]); + }); - // remove specified query parameters - paramsToRemove.forEach((key) => { - currentParams.delete(key); - }); + // remove specified query parameters + paramsToRemove.forEach((key) => { + currentParams.delete(key); + }); - // construct the new route with the updated query parameters - const newRoute = `${pathname}?${currentParams.toString()}`; - return newRoute; - }; + // construct the new route with the updated query parameters + const query = currentParams.toString(); + const newRoute = query ? `${pathname}?${query}` : pathname; + return newRoute; + }, + [pathname, searchParams] + ); return { updateQueryParams, diff --git a/web/core/store/pages/base-page.ts b/web/core/store/pages/base-page.ts index 294c370e7ec..e5ca4e4d5da 100644 --- a/web/core/store/pages/base-page.ts +++ b/web/core/store/pages/base-page.ts @@ -2,18 +2,18 @@ import set from "lodash/set"; import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx"; // plane imports import { EPageAccess } from "@plane/constants"; -import { EditorRefApi } from "@plane/editor"; import { TDocumentPayload, TLogoProps, TNameDescriptionLoader, TPage } from "@plane/types"; import { TChangeHandlerProps } from "@plane/ui"; import { convertHexEmojiToDecimal } from "@plane/utils"; // plane web store import { ExtendedBasePage } from "@/plane-web/store/pages/extended-base-page"; import { RootStore } from "@/plane-web/store/root.store"; +// local imports +import { PageEditorInstance } from "./page-editor-info"; export type TBasePage = TPage & { // observables isSubmitting: TNameDescriptionLoader; - editorRef: EditorRefApi | null; // computed asJSON: TPage | undefined; isCurrentUserOwner: boolean; @@ -36,7 +36,8 @@ export type TBasePage = TPage & { removePageFromFavorites: () => Promise; duplicate: () => Promise; mutateProperties: (data: Partial, shouldUpdateName?: boolean) => void; - setEditorRef: (editorRef: EditorRefApi | null) => void; + // sub-store + editor: PageEditorInstance; }; export type TBasePagePermissions = { @@ -73,7 +74,6 @@ export type TPageInstance = TBasePage & export class BasePage extends ExtendedBasePage implements TBasePage { // loaders isSubmitting: TNameDescriptionLoader = "saved"; - editorRef: EditorRefApi | null = null; // page properties id: string | undefined; name: string | undefined; @@ -100,6 +100,9 @@ export class BasePage extends ExtendedBasePage implements TBasePage { disposers: Array<() => void> = []; // root store rootStore: RootStore; + // sub-store + editor: PageEditorInstance; + constructor( private store: RootStore, page: TPage, @@ -129,7 +132,6 @@ export class BasePage extends ExtendedBasePage implements TBasePage { makeObservable(this, { // loaders isSubmitting: observable.ref, - editorRef: observable.ref, // page properties id: observable.ref, name: observable.ref, @@ -170,11 +172,12 @@ export class BasePage extends ExtendedBasePage implements TBasePage { removePageFromFavorites: action, duplicate: action, mutateProperties: action, - setEditorRef: action, }); - this.rootStore = store; + // init this.services = services; + this.rootStore = store; + this.editor = new PageEditorInstance(); const titleDisposer = reaction( () => this.name, @@ -524,10 +527,4 @@ export class BasePage extends ExtendedBasePage implements TBasePage { set(this, key, value); }); }; - - setEditorRef = (editorRef: EditorRefApi | null) => { - runInAction(() => { - this.editorRef = editorRef; - }); - }; } diff --git a/web/core/store/pages/page-editor-info.ts b/web/core/store/pages/page-editor-info.ts new file mode 100644 index 00000000000..442b534f36d --- /dev/null +++ b/web/core/store/pages/page-editor-info.ts @@ -0,0 +1,41 @@ +import { action, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { EditorRefApi, TEditorAsset } from "@plane/editor"; + +export type TPageEditorInstance = { + // observables + assetsList: TEditorAsset[]; + editorRef: EditorRefApi | null; + // actions + setEditorRef: (editorRef: EditorRefApi | null) => void; + updateAssetsList: (assets: TEditorAsset[]) => void; +}; + +export class PageEditorInstance implements TPageEditorInstance { + // observables + editorRef: EditorRefApi | null = null; + assetsList: TEditorAsset[] = []; + + constructor() { + makeObservable(this, { + // observables + editorRef: observable.ref, + assetsList: observable, + // actions + setEditorRef: action, + updateAssetsList: action, + }); + } + + setEditorRef: TPageEditorInstance["setEditorRef"] = (editorRef) => { + runInAction(() => { + this.editorRef = editorRef; + }); + }; + + updateAssetsList: TPageEditorInstance["updateAssetsList"] = (assets) => { + runInAction(() => { + this.assetsList = assets; + }); + }; +} diff --git a/web/public/empty-state/pages/navigation-pane/assets-dark.webp b/web/public/empty-state/pages/navigation-pane/assets-dark.webp new file mode 100644 index 00000000000..e454d6dc17d Binary files /dev/null and b/web/public/empty-state/pages/navigation-pane/assets-dark.webp differ diff --git a/web/public/empty-state/pages/navigation-pane/assets-light.webp b/web/public/empty-state/pages/navigation-pane/assets-light.webp new file mode 100644 index 00000000000..c3c4585d061 Binary files /dev/null and b/web/public/empty-state/pages/navigation-pane/assets-light.webp differ diff --git a/web/public/empty-state/pages/navigation-pane/outline-dark.webp b/web/public/empty-state/pages/navigation-pane/outline-dark.webp new file mode 100644 index 00000000000..dcd26c3950b Binary files /dev/null and b/web/public/empty-state/pages/navigation-pane/outline-dark.webp differ diff --git a/web/public/empty-state/pages/navigation-pane/outline-light.webp b/web/public/empty-state/pages/navigation-pane/outline-light.webp new file mode 100644 index 00000000000..e92cc6ae7bb Binary files /dev/null and b/web/public/empty-state/pages/navigation-pane/outline-light.webp differ diff --git a/web/styles/globals.css b/web/styles/globals.css index c6e4654d077..51c4419b86f 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -497,6 +497,7 @@ text-rendering: optimizeLegibility; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; + scroll-behavior: smooth; } body {