[WIKI-400] feat: page navigation pane (#7206)
* init: page navigation pane * chore: outline and info tabs * chore: asset download endpoint * chore: realtime document info updates * chore: add support for code splitting * fix: formatting * refactor: image block id generation * chore: implement translation * refactor: assets list storage logic * fix: build errors * fix: image extension name * refactor: add support for additional asset items * refactor: asset extraction logic * chore: add translations * fix: merge conflicts resolved from preview * chore: remove version history option from the dropdown * chore: query params handling * chore: remove unnecessary logic * refactor: empty state components * fix: empty state asset path
This commit is contained in:
parent
cfe169c6d7
commit
0b159c4963
83 changed files with 2185 additions and 767 deletions
|
|
@ -332,6 +332,7 @@
|
|||
text-rendering: optimizeLegibility;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
|
|||
6
packages/editor/src/ce/constants/assets.ts
Normal file
6
packages/editor/src/ce/constants/assets.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// helpers
|
||||
import { TAssetMetaDataRecord } from "@/helpers/assets";
|
||||
// local imports
|
||||
import { ADDITIONAL_EXTENSIONS } from "./extensions";
|
||||
|
||||
export const ADDITIONAL_ASSETS_META_DATA_RECORD: Partial<Record<ADDITIONAL_EXTENSIONS, TAssetMetaDataRecord>> = {};
|
||||
1
packages/editor/src/ce/constants/extensions.ts
Normal file
1
packages/editor/src/ce/constants/extensions.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export enum ADDITIONAL_EXTENSIONS {}
|
||||
1
packages/editor/src/ce/types/asset.ts
Normal file
1
packages/editor/src/ce/types/asset.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type TAdditionalEditorAsset = never;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { CharacterCountStorage } from "@tiptap/extension-character-count";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
|
|
@ -15,6 +16,7 @@ export type ExtensionStorageMap = {
|
|||
[CORE_EXTENSIONS.HEADINGS_LIST]: HeadingExtensionStorage;
|
||||
[CORE_EXTENSIONS.MENTION]: MentionExtensionStorage;
|
||||
[CORE_EXTENSIONS.UTILITY]: UtilityExtensionStorage;
|
||||
[CORE_EXTENSIONS.CHARACTER_COUNT]: CharacterCountStorage;
|
||||
};
|
||||
|
||||
export type ExtensionFileSetStorageKey = Extract<keyof ImageExtensionStorage, "deletedImageSet">;
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
|
|||
|
||||
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
|
||||
const {
|
||||
onChange,
|
||||
onTransaction,
|
||||
aiHandler,
|
||||
bubbleMenuEnabled = true,
|
||||
containerClassName,
|
||||
|
|
@ -33,6 +31,9 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
|||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
|
|
@ -63,6 +64,7 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
|||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from
|
|||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
|
||||
import { ensurePixelString } from "../utils";
|
||||
import { ensurePixelString, getImageBlockId } from "../utils";
|
||||
import type { CustomImageNodeViewProps } from "./node-view";
|
||||
import { ImageToolbarRoot } from "./toolbar";
|
||||
import { ImageUploadStatus } from "./upload-status";
|
||||
|
|
@ -196,6 +196,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
|||
|
||||
return (
|
||||
<div
|
||||
id={getImageBlockId(node.attrs.id ?? "")}
|
||||
ref={containerRef}
|
||||
className="group/image-component relative inline-block max-w-full"
|
||||
onMouseDown={handleImageMouseDown}
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
|||
}
|
||||
|
||||
return "Add an image";
|
||||
}, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]);
|
||||
}, [draggedInside, editor.isEditable, failedToLoadImage, isImageBeingUploaded]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -31,3 +31,5 @@ export const ensurePixelString = <TDefault>(
|
|||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const getImageBlockId = (id: string) => `editor-image-block-${id}`;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Extension } from "@tiptap/core";
|
||||
// prosemirror plugins
|
||||
import codemark from "prosemirror-codemark";
|
||||
// helpers
|
||||
import { restorePublicImages } from "@/helpers/image-helpers";
|
||||
|
|
@ -8,17 +7,27 @@ import { DropHandlerPlugin } from "@/plugins/drop";
|
|||
import { FilePlugins } from "@/plugins/file/root";
|
||||
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
|
||||
// types
|
||||
import type { IEditorProps, TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
import type { IEditorProps, TEditorAsset, TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands {
|
||||
utility: {
|
||||
updateAssetsUploadStatus: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
|
||||
updateAssetsList: (
|
||||
args:
|
||||
| {
|
||||
asset: TEditorAsset;
|
||||
}
|
||||
| {
|
||||
idToRemove: string;
|
||||
}
|
||||
) => () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface UtilityExtensionStorage {
|
||||
assetsList: TEditorAsset[];
|
||||
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
|
||||
uploadInProgress: boolean;
|
||||
}
|
||||
|
|
@ -58,6 +67,7 @@ export const UtilityExtension = (props: Props) => {
|
|||
|
||||
addStorage() {
|
||||
return {
|
||||
assetsList: [],
|
||||
assetsUploadStatus: isEditable && "assetsUploadStatus" in fileHandler ? fileHandler.assetsUploadStatus : {},
|
||||
uploadInProgress: false,
|
||||
};
|
||||
|
|
@ -68,6 +78,21 @@ export const UtilityExtension = (props: Props) => {
|
|||
updateAssetsUploadStatus: (updatedStatus) => () => {
|
||||
this.storage.assetsUploadStatus = updatedStatus;
|
||||
},
|
||||
updateAssetsList: (args) => () => {
|
||||
const uniqueAssets = new Set(this.storage.assetsList);
|
||||
if ("asset" in args) {
|
||||
const alreadyExists = this.storage.assetsList.find((asset) => asset.id === args.asset.id);
|
||||
if (!alreadyExists) {
|
||||
uniqueAssets.add(args.asset);
|
||||
}
|
||||
} else if ("idToRemove" in args) {
|
||||
const asset = this.storage.assetsList.find((asset) => asset.id === args.idToRemove);
|
||||
if (asset) {
|
||||
uniqueAssets.delete(asset);
|
||||
}
|
||||
}
|
||||
this.storage.assetsList = Array.from(uniqueAssets);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
37
packages/editor/src/core/helpers/assets.ts
Normal file
37
packages/editor/src/core/helpers/assets.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { getImageBlockId } from "@/extensions/custom-image/utils";
|
||||
// plane editor imports
|
||||
import { ADDITIONAL_ASSETS_META_DATA_RECORD } from "@/plane-editor/constants/assets";
|
||||
// types
|
||||
import { TEditorAsset } from "@/types";
|
||||
|
||||
export type TAssetMetaDataRecord = (attrs: ProseMirrorNode["attrs"]) => TEditorAsset | undefined;
|
||||
|
||||
export const CORE_ASSETS_META_DATA_RECORD: Partial<Record<CORE_EXTENSIONS, TAssetMetaDataRecord>> = {
|
||||
[CORE_EXTENSIONS.IMAGE]: (attrs) => {
|
||||
if (!attrs?.src) return;
|
||||
return {
|
||||
href: `#${getImageBlockId(attrs?.id ?? "")}`,
|
||||
id: attrs?.id,
|
||||
name: `image-${attrs?.id}`,
|
||||
size: 0,
|
||||
src: attrs?.src,
|
||||
type: CORE_EXTENSIONS.IMAGE,
|
||||
};
|
||||
},
|
||||
[CORE_EXTENSIONS.CUSTOM_IMAGE]: (attrs) => {
|
||||
if (!attrs?.src) return;
|
||||
return {
|
||||
href: `#${getImageBlockId(attrs?.id ?? "")}`,
|
||||
id: attrs?.id,
|
||||
name: `image-${attrs?.id}`,
|
||||
size: 0,
|
||||
src: attrs?.src,
|
||||
type: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
};
|
||||
},
|
||||
...ADDITIONAL_ASSETS_META_DATA_RECORD,
|
||||
};
|
||||
55
packages/editor/src/core/helpers/editor-ref.ts
Normal file
55
packages/editor/src/core/helpers/editor-ref.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import * as Y from "yjs";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi } from "@/types";
|
||||
// local imports
|
||||
import { getParagraphCount } from "./common";
|
||||
import { getExtensionStorage } from "./get-extension-storage";
|
||||
import { scrollSummary } from "./scroll-to-node";
|
||||
|
||||
type TArgs = {
|
||||
editor: Editor | null;
|
||||
provider: HocuspocusProvider | undefined;
|
||||
};
|
||||
|
||||
export const getEditorRefHelpers = (args: TArgs): EditorReadOnlyRefApi => {
|
||||
const { editor, provider } = args;
|
||||
|
||||
return {
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
|
||||
},
|
||||
getDocument: () => {
|
||||
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
||||
const documentHTML = editor?.getHTML() ?? "<p></p>";
|
||||
const documentJSON = editor?.getJSON() ?? null;
|
||||
|
||||
return {
|
||||
binary: documentBinary,
|
||||
html: documentHTML,
|
||||
json: documentJSON,
|
||||
};
|
||||
},
|
||||
getDocumentInfo: () => ({
|
||||
characters: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.characters?.() : 0,
|
||||
paragraphs: getParagraphCount(editor?.state),
|
||||
words: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.words?.() : 0,
|
||||
}),
|
||||
getHeadings: () => (editor ? getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings : []),
|
||||
getMarkDown: () => {
|
||||
const markdownOutput = editor?.storage?.markdown?.getMarkdown?.();
|
||||
return markdownOutput;
|
||||
},
|
||||
scrollSummary: (marking) => {
|
||||
if (!editor) return;
|
||||
scrollSummary(editor, marking);
|
||||
},
|
||||
setEditorValue: (content, emitUpdate = false) => {
|
||||
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -13,6 +13,7 @@ import { TCollaborativeEditorHookProps } from "@/types";
|
|||
|
||||
export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => {
|
||||
const {
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onTransaction,
|
||||
disabledExtensions,
|
||||
|
|
@ -106,6 +107,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
|||
forwardedRef,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import { useEditor as useTiptapEditor } from "@tiptap/react";
|
||||
import { useEditorState, useEditor as useTiptapEditor } from "@tiptap/react";
|
||||
import { useImperativeHandle, useEffect } from "react";
|
||||
import * as Y from "yjs";
|
||||
// components
|
||||
import { getEditorMenuItems } from "@/components/menus";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
// extensions
|
||||
import { CoreEditorExtensions } from "@/extensions";
|
||||
// helpers
|
||||
import { getParagraphCount } from "@/helpers/common";
|
||||
import { getEditorRefHelpers } from "@/helpers/editor-ref";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position";
|
||||
import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helpers/scroll-to-node";
|
||||
import { scrollToNodeViaDOMCoordinates } from "@/helpers/scroll-to-node";
|
||||
// props
|
||||
import { CoreEditorProps } from "@/props";
|
||||
// types
|
||||
import type { TDocumentEventsServer, TEditorCommands, TEditorHookProps } from "@/types";
|
||||
import type { TEditorCommands, TEditorHookProps } from "@/types";
|
||||
|
||||
export const useEditor = (props: TEditorHookProps) => {
|
||||
const {
|
||||
|
|
@ -35,6 +35,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
|||
id = "",
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
|
|
@ -109,27 +110,26 @@ export const useEditor = (props: TEditorHookProps) => {
|
|||
editor.commands.updateAssetsUploadStatus?.(assetsUploadStatus);
|
||||
}, [editor, fileHandler.assetsUploadStatus]);
|
||||
|
||||
// subscribe to assets list changes
|
||||
const assetsList = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) => ({
|
||||
assets: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.assetsList : [],
|
||||
}),
|
||||
});
|
||||
// trigger callback when assets list changes
|
||||
useEffect(() => {
|
||||
const assets = assetsList?.assets;
|
||||
if (!assets || !onAssetChange) return;
|
||||
onAssetChange(assets);
|
||||
}, [assetsList?.assets, onAssetChange]);
|
||||
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
...getEditorRefHelpers({ editor, provider }),
|
||||
blur: () => editor?.commands.blur(),
|
||||
scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) {
|
||||
const resolvedPos = pos ?? editor?.state.selection.from;
|
||||
if (!editor || !resolvedPos) return;
|
||||
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
|
||||
},
|
||||
getCurrentCursorPosition: () => editor?.state.selection.from,
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
|
||||
},
|
||||
setEditorValue: (content: string, emitUpdate = false) => {
|
||||
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
|
||||
},
|
||||
setEditorValueAtCursorPosition: (content: string) => {
|
||||
if (editor?.state.selection) {
|
||||
insertContentAtSavedSelection(editor, content);
|
||||
}
|
||||
},
|
||||
emitRealTimeUpdate: (message) => provider?.sendStateless(message),
|
||||
executeMenuItemCommand: (props) => {
|
||||
const { itemKey } = props;
|
||||
const editorItems = getEditorMenuItems(editor);
|
||||
|
|
@ -143,83 +143,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
|||
console.warn(`No command found for item: ${itemKey}`);
|
||||
}
|
||||
},
|
||||
isMenuItemActive: (props) => {
|
||||
const { itemKey } = props;
|
||||
const editorItems = getEditorMenuItems(editor);
|
||||
|
||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||
const item = getEditorMenuItem(itemKey);
|
||||
if (!item) return false;
|
||||
|
||||
return item.isActive(props);
|
||||
},
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
||||
// Subscribe to update event emitted from headers extension
|
||||
editor?.on("update", () => {
|
||||
const headings = getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings;
|
||||
if (headings) {
|
||||
callback(headings);
|
||||
}
|
||||
});
|
||||
// Return a function to unsubscribe to the continuous transactions of
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editor?.off("update");
|
||||
};
|
||||
},
|
||||
getHeadings: () => (editor ? getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings : []),
|
||||
onStateChange: (callback: () => void) => {
|
||||
// Subscribe to editor state changes
|
||||
editor?.on("transaction", () => {
|
||||
callback();
|
||||
});
|
||||
|
||||
// Return a function to unsubscribe to the continuous transactions of
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editor?.off("transaction");
|
||||
};
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||
return markdownOutput;
|
||||
},
|
||||
getDocument: () => {
|
||||
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
||||
const documentHTML = editor?.getHTML() ?? "<p></p>";
|
||||
const documentJSON = editor?.getJSON() ?? null;
|
||||
|
||||
return {
|
||||
binary: documentBinary,
|
||||
html: documentHTML,
|
||||
json: documentJSON,
|
||||
};
|
||||
},
|
||||
scrollSummary: (marking: IMarking): void => {
|
||||
if (!editor) return;
|
||||
scrollSummary(editor, marking);
|
||||
},
|
||||
isEditorReadyToDiscard: () =>
|
||||
!!editor && getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress === false,
|
||||
setFocusAtPosition: (position: number) => {
|
||||
if (!editor || editor.isDestroyed) {
|
||||
console.error("Editor reference is not available or has been destroyed.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const docSize = editor.state.doc.content.size;
|
||||
const safePosition = Math.max(0, Math.min(position, docSize));
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(safePosition, [{ type: CORE_EXTENSIONS.PARAGRAPH }])
|
||||
.focus()
|
||||
.run();
|
||||
} catch (error) {
|
||||
console.error("An error occurred while setting focus at position:", error);
|
||||
}
|
||||
},
|
||||
getCurrentCursorPosition: () => editor?.state.selection.from,
|
||||
getSelectedText: () => {
|
||||
if (!editor) return null;
|
||||
|
||||
|
|
@ -253,18 +177,99 @@ export const useEditor = (props: TEditorHookProps) => {
|
|||
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
|
||||
}
|
||||
},
|
||||
getDocumentInfo: () => ({
|
||||
characters: editor?.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editor?.state),
|
||||
words: editor?.storage?.characterCount?.words?.() ?? 0,
|
||||
}),
|
||||
isEditorReadyToDiscard: () =>
|
||||
!!editor && getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress === false,
|
||||
isMenuItemActive: (props) => {
|
||||
const { itemKey } = props;
|
||||
const editorItems = getEditorMenuItems(editor);
|
||||
|
||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||
const item = getEditorMenuItem(itemKey);
|
||||
if (!item) return false;
|
||||
|
||||
return item.isActive(props);
|
||||
},
|
||||
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
||||
onDocumentInfoChange: (callback) => {
|
||||
const handleDocumentInfoChange = () => {
|
||||
if (!editor) return;
|
||||
callback({
|
||||
characters: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.characters?.() : 0,
|
||||
paragraphs: getParagraphCount(editor?.state),
|
||||
words: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.words?.() : 0,
|
||||
});
|
||||
};
|
||||
|
||||
// Subscribe to update event emitted from character count extension
|
||||
editor?.on("update", handleDocumentInfoChange);
|
||||
// Return a function to unsubscribe to the continuous transactions of
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editor?.off("update", handleDocumentInfoChange);
|
||||
};
|
||||
},
|
||||
onHeadingChange: (callback) => {
|
||||
const handleHeadingChange = () => {
|
||||
if (!editor) return;
|
||||
const headings = getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings;
|
||||
if (headings) {
|
||||
callback(headings);
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to update event emitted from headers extension
|
||||
editor?.on("update", handleHeadingChange);
|
||||
// Return a function to unsubscribe to the continuous transactions of
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editor?.off("update", handleHeadingChange);
|
||||
};
|
||||
},
|
||||
onStateChange: (callback) => {
|
||||
// Subscribe to editor state changes
|
||||
editor?.on("transaction", callback);
|
||||
|
||||
// Return a function to unsubscribe to the continuous transactions of
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editor?.off("transaction", callback);
|
||||
};
|
||||
},
|
||||
scrollToNodeViaDOMCoordinates(behavior, pos) {
|
||||
const resolvedPos = pos ?? editor?.state.selection.from;
|
||||
if (!editor || !resolvedPos) return;
|
||||
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
|
||||
},
|
||||
setEditorValueAtCursorPosition: (content) => {
|
||||
if (editor?.state.selection) {
|
||||
insertContentAtSavedSelection(editor, content);
|
||||
}
|
||||
},
|
||||
setFocusAtPosition: (position) => {
|
||||
if (!editor || editor.isDestroyed) {
|
||||
console.error("Editor reference is not available or has been destroyed.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const docSize = editor.state.doc.content.size;
|
||||
const safePosition = Math.max(0, Math.min(position, docSize));
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(safePosition, [{ type: CORE_EXTENSIONS.PARAGRAPH }])
|
||||
.focus()
|
||||
.run();
|
||||
} catch (error) {
|
||||
console.error("An error occurred while setting focus at position:", error);
|
||||
}
|
||||
},
|
||||
setProviderDocument: (value) => {
|
||||
const document = provider?.document;
|
||||
if (!document) return;
|
||||
Y.applyUpdate(document, value);
|
||||
},
|
||||
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
|
||||
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
||||
}),
|
||||
[editor]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
import { useEditor as useTiptapEditor } from "@tiptap/react";
|
||||
import { useImperativeHandle, useEffect } from "react";
|
||||
import * as Y from "yjs";
|
||||
// constants
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
// extensions
|
||||
import { CoreReadOnlyEditorExtensions } from "@/extensions";
|
||||
// helpers
|
||||
import { getParagraphCount } from "@/helpers/common";
|
||||
import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
||||
import { getEditorRefHelpers } from "@/helpers/editor-ref";
|
||||
// props
|
||||
import { CoreReadOnlyEditorProps } from "@/props";
|
||||
// types
|
||||
|
|
@ -30,7 +26,7 @@ export const useReadOnlyEditor = (props: TReadOnlyEditorHookProps) => {
|
|||
|
||||
const editor = useTiptapEditor({
|
||||
editable: false,
|
||||
immediatelyRender: true,
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: false,
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
parseOptions: { preserveWhitespace: true },
|
||||
|
|
@ -63,38 +59,7 @@ export const useReadOnlyEditor = (props: TReadOnlyEditorHookProps) => {
|
|||
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: true });
|
||||
}, [editor, initialValue]);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
|
||||
},
|
||||
setEditorValue: (content: string, emitUpdate = false) => {
|
||||
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||
return markdownOutput;
|
||||
},
|
||||
getDocument: () => {
|
||||
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
||||
const documentHTML = editor?.getHTML() ?? "<p></p>";
|
||||
const documentJSON = editor?.getJSON() ?? null;
|
||||
|
||||
return {
|
||||
binary: documentBinary,
|
||||
html: documentHTML,
|
||||
json: documentJSON,
|
||||
};
|
||||
},
|
||||
scrollSummary: (marking: IMarking): void => {
|
||||
if (!editor) return;
|
||||
scrollSummary(editor, marking);
|
||||
},
|
||||
getDocumentInfo: () => ({
|
||||
characters: editor.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editor.state),
|
||||
words: editor.storage?.characterCount?.words?.() ?? 0,
|
||||
}),
|
||||
}));
|
||||
useImperativeHandle(forwardedRef, () => getEditorRefHelpers({ editor, provider }));
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
|||
return;
|
||||
}
|
||||
|
||||
const scrollableParent = getScrollParent(dragHandleElement);
|
||||
const scrollableParent = getScrollParent(dragHandleElement!);
|
||||
if (!scrollableParent) return;
|
||||
|
||||
const scrollRegionUp = options.scrollThreshold.up;
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand
|
|||
if (!nodeFileSetDetails || !src) return;
|
||||
try {
|
||||
editor.storage[nodeType][nodeFileSetDetails.fileSetName]?.set(src, true);
|
||||
// update assets list storage value
|
||||
editor.commands.updateAssetsList?.({
|
||||
idToRemove: node.attrs.id,
|
||||
});
|
||||
await deleteHandler(src);
|
||||
} catch (error) {
|
||||
console.error("Error deleting file via delete utility plugin:", error);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { Editor } from "@tiptap/core";
|
|||
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { CORE_ASSETS_META_DATA_RECORD } from "@/helpers/assets";
|
||||
// plane editor imports
|
||||
import { NODE_FILE_MAP } from "@/plane-editor/constants/utility";
|
||||
// types
|
||||
|
|
@ -42,6 +44,13 @@ export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFile
|
|||
if (!isAValidNode) return;
|
||||
if (pos < 0 || pos > newState.doc.content.size) return;
|
||||
if (oldFileSources[nodeType]?.has(node.attrs.src)) return;
|
||||
// update assets list storage value
|
||||
const assetMetaData = CORE_ASSETS_META_DATA_RECORD[nodeType]?.(node.attrs);
|
||||
if (assetMetaData) {
|
||||
editor.commands.updateAssetsList?.({
|
||||
asset: assetMetaData,
|
||||
});
|
||||
}
|
||||
// if the src is just a id (private bucket), then we don't need to handle restore from here but
|
||||
// only while it fails to load
|
||||
if (nodeType === CORE_EXTENSIONS.CUSTOM_IMAGE && !node.attrs.src?.startsWith("http")) return;
|
||||
|
|
|
|||
14
packages/editor/src/core/types/asset.ts
Normal file
14
packages/editor/src/core/types/asset.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// plane editor imports
|
||||
import { TAdditionalEditorAsset } from "@/plane-editor/types/asset";
|
||||
|
||||
export type TEditorImageAsset = {
|
||||
href: string;
|
||||
id: string;
|
||||
name: string;
|
||||
src: string;
|
||||
type: CORE_EXTENSIONS.IMAGE | CORE_EXTENSIONS.CUSTOM_IMAGE;
|
||||
};
|
||||
|
||||
export type TEditorAsset = TEditorImageAsset | TAdditionalEditorAsset;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Extensions, JSONContent } from "@tiptap/core";
|
||||
import type { Selection } from "@tiptap/pm/state";
|
||||
import { Extensions, JSONContent } from "@tiptap/core";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
// extension types
|
||||
import type { TTextAlign } from "@/extensions";
|
||||
// helpers
|
||||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
TDisplayConfig,
|
||||
TDocumentEventEmitter,
|
||||
TDocumentEventsServer,
|
||||
TEditorAsset,
|
||||
TEmbedConfig,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
|
|
@ -75,41 +76,44 @@ type TCommandWithPropsWithItemKey<T extends TEditorCommands> = T extends keyof T
|
|||
? { itemKey: T } & TCommandExtraProps[T]
|
||||
: { itemKey: T };
|
||||
|
||||
export type TDocumentInfo = {
|
||||
characters: number;
|
||||
paragraphs: number;
|
||||
words: number;
|
||||
};
|
||||
|
||||
// editor refs
|
||||
export type EditorReadOnlyRefApi = {
|
||||
getMarkDown: () => string;
|
||||
clearEditor: (emitUpdate?: boolean) => void;
|
||||
getDocument: () => {
|
||||
binary: Uint8Array | null;
|
||||
html: string;
|
||||
json: JSONContent | null;
|
||||
};
|
||||
clearEditor: (emitUpdate?: boolean) => void;
|
||||
setEditorValue: (content: string, emitUpdate?: boolean) => void;
|
||||
getDocumentInfo: () => TDocumentInfo;
|
||||
getHeadings: () => IMarking[];
|
||||
getMarkDown: () => string;
|
||||
scrollSummary: (marking: IMarking) => void;
|
||||
getDocumentInfo: () => {
|
||||
characters: number;
|
||||
paragraphs: number;
|
||||
words: number;
|
||||
};
|
||||
setEditorValue: (content: string, emitUpdate?: boolean) => void;
|
||||
};
|
||||
|
||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
blur: () => void;
|
||||
scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void;
|
||||
getCurrentCursorPosition: () => number | undefined;
|
||||
setEditorValueAtCursorPosition: (content: string) => void;
|
||||
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
|
||||
executeMenuItemCommand: <T extends TEditorCommands>(props: TCommandWithPropsWithItemKey<T>) => void;
|
||||
isMenuItemActive: <T extends TEditorCommands>(props: TCommandWithPropsWithItemKey<T>) => boolean;
|
||||
onStateChange: (callback: () => void) => () => void;
|
||||
setFocusAtPosition: (position: number) => void;
|
||||
isEditorReadyToDiscard: () => boolean;
|
||||
getCurrentCursorPosition: () => number | undefined;
|
||||
getSelectedText: () => string | null;
|
||||
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
|
||||
setProviderDocument: (value: Uint8Array) => void;
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
|
||||
getHeadings: () => IMarking[];
|
||||
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
|
||||
isEditorReadyToDiscard: () => boolean;
|
||||
isMenuItemActive: <T extends TEditorCommands>(props: TCommandWithPropsWithItemKey<T>) => boolean;
|
||||
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
|
||||
onDocumentInfoChange: (callback: (documentInfo: TDocumentInfo) => void) => () => void;
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
|
||||
onStateChange: (callback: () => void) => () => void;
|
||||
scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void;
|
||||
setEditorValueAtCursorPosition: (content: string) => void;
|
||||
setFocusAtPosition: (position: number) => void;
|
||||
setProviderDocument: (value: Uint8Array) => void;
|
||||
}
|
||||
|
||||
// editor props
|
||||
|
|
@ -128,6 +132,7 @@ export interface IEditorProps {
|
|||
id: string;
|
||||
initialValue: string;
|
||||
mentionHandler: TMentionHandler;
|
||||
onAssetChange?: (assets: TEditorAsset[]) => void;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onEnterKeyPress?: (e?: any) => void;
|
||||
onTransaction?: () => void;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export type TEditorHookProps = TCoreHookProps &
|
|||
| "forwardedRef"
|
||||
| "id"
|
||||
| "mentionHandler"
|
||||
| "onAssetChange"
|
||||
| "onChange"
|
||||
| "onTransaction"
|
||||
| "placeholder"
|
||||
|
|
@ -38,6 +39,7 @@ export type TCollaborativeEditorHookProps = TCoreHookProps &
|
|||
| "forwardedRef"
|
||||
| "id"
|
||||
| "mentionHandler"
|
||||
| "onAssetChange"
|
||||
| "onChange"
|
||||
| "onTransaction"
|
||||
| "placeholder"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export * from "./ai";
|
||||
export * from "./asset";
|
||||
export * from "./collaboration";
|
||||
export * from "./config";
|
||||
export * from "./editor";
|
||||
|
|
|
|||
|
|
@ -35,5 +35,8 @@ export { useEditor } from "@/hooks/use-editor";
|
|||
export { type IMarking, useEditorMarkings } from "@/hooks/use-editor-markings";
|
||||
export { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
|
||||
export { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
export { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions";
|
||||
|
||||
// types
|
||||
export * from "@/types";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "アウトラインを開く"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "개요 열기"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_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": "Открыть структуру"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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ç"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Відкрити структуру"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "打开大纲"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "打開大綱"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
31
web/ce/components/pages/navigation-pane/index.ts
Normal file
31
web/ce/components/pages/navigation-pane/index.ts
Normal file
|
|
@ -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,
|
||||
];
|
||||
|
|
@ -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<TAdditionalPageNavigationPaneAssetItemProps> = () => null;
|
||||
|
|
@ -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 (
|
||||
<div className="size-full grid place-items-center">
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<Image src={resolvedPath} width={160} height={160} alt="An image depicting the assets of a page" />
|
||||
<div className="space-y-2.5">
|
||||
<h4 className="text-base font-medium">{t("page_navigation_pane.tabs.assets.empty_state.title")}</h4>
|
||||
<p className="text-sm text-custom-text-200 font-medium">
|
||||
{t("page_navigation_pane.tabs.assets.empty_state.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<div className="size-full grid place-items-center">
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<Image src={resolvedPath} width={160} height={160} alt="An image depicting the outline of a page" />
|
||||
<div className="space-y-2.5">
|
||||
<h4 className="text-base font-medium">{t("page_navigation_pane.tabs.outline.empty_state.title")}</h4>
|
||||
<p className="text-sm text-custom-text-200 font-medium">
|
||||
{t("page_navigation_pane.tabs.outline.empty_state.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
13
web/ce/components/pages/navigation-pane/tab-panels/root.tsx
Normal file
13
web/ce/components/pages/navigation-pane/tab-panels/root.tsx
Normal file
|
|
@ -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;
|
||||
|
|
@ -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<EditorRefApi>;
|
||||
handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
|
||||
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<Props> = observer((props) => {
|
|||
editorForwardRef,
|
||||
handleConnectionStatus,
|
||||
handleEditorReady,
|
||||
handleOpenNavigationPane,
|
||||
handlers,
|
||||
isNavigationPaneOpen,
|
||||
page,
|
||||
webhookConnectionParams,
|
||||
workspaceSlug,
|
||||
|
|
@ -67,9 +71,14 @@ export const PageEditorBody: React.FC<Props> = 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<Props> = 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<Props> = observer((props) => {
|
|||
>
|
||||
<div id="page-content-container" className="relative w-full flex-shrink-0">
|
||||
{/* table of content */}
|
||||
<div className="page-summary-container absolute h-full right-0 top-[64px] z-[5]">
|
||||
<div className="sticky top-[72px]">
|
||||
<div className="group/page-toc relative px-page-x">
|
||||
<div className="cursor-pointer max-h-[50vh] overflow-hidden">
|
||||
<PageContentBrowser editorRef={editorRef} showOutline />
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 opacity-0 translate-x-1/2 pointer-events-none group-hover/page-toc:opacity-100 group-hover/page-toc:-translate-x-1/4 group-hover/page-toc:pointer-events-auto transition-all duration-300 w-52 max-h-[70vh] overflow-y-scroll vertical-scrollbar scrollbar-sm whitespace-nowrap bg-custom-background-90 p-4 rounded">
|
||||
<PageContentBrowser editorRef={editorRef} />
|
||||
{!isNavigationPaneOpen && (
|
||||
<div className="page-summary-container absolute h-full right-0 top-[64px] z-[5]">
|
||||
<div className="sticky top-[72px]">
|
||||
<div className="group/page-toc relative px-page-x">
|
||||
<div
|
||||
className="!cursor-pointer max-h-[50vh] overflow-hidden"
|
||||
role="button"
|
||||
aria-label={t("page_navigation_pane.outline_floating_button")}
|
||||
onClick={handleOpenNavigationPane}
|
||||
>
|
||||
<PageContentBrowser editorRef={editorRef} showOutline />
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 opacity-0 translate-x-1/2 pointer-events-none group-hover/page-toc:opacity-100 group-hover/page-toc:-translate-x-1/4 group-hover/page-toc:pointer-events-auto transition-all duration-300 w-52 max-h-[70vh] overflow-y-scroll vertical-scrollbar scrollbar-sm whitespace-nowrap bg-custom-background-90 p-4 rounded">
|
||||
<PageContentBrowser editorRef={editorRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="page-header-container group/page-header">
|
||||
<div className={blockWidthClassName}>
|
||||
<PageEditorHeaderRoot page={page} />
|
||||
|
|
@ -218,6 +236,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
|||
aiHandler={{
|
||||
menu: getAIMenu,
|
||||
}}
|
||||
onAssetChange={updateAssetsList}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
|
|
|
|||
|
|
@ -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<TPage>) => Promise<Partial<TPage> | 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<EditorRefApi>(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 (
|
||||
<>
|
||||
<PageVersionsOverlay
|
||||
activeVersion={version}
|
||||
currentVersionDescription={currentVersionDescription ?? null}
|
||||
editorComponent={PagesVersionEditor}
|
||||
fetchAllVersions={handlers.fetchAllVersions}
|
||||
fetchVersionDetails={handlers.fetchVersionDetails}
|
||||
handleRestore={handleRestoreVersion}
|
||||
isOpen={isVersionsOverlayOpen}
|
||||
onClose={handleCloseVersionsOverlay}
|
||||
pageId={page.id ?? ""}
|
||||
restoreEnabled={isContentEditable}
|
||||
/>
|
||||
<PageEditorToolbarRoot page={page} />
|
||||
<PageEditorBody
|
||||
config={config}
|
||||
editorReady={editorReady}
|
||||
editorForwardRef={editorRef}
|
||||
handleConnectionStatus={setHasConnectionFailed}
|
||||
handleEditorReady={handleEditorReady}
|
||||
handlers={handlers}
|
||||
<div className="relative size-full overflow-hidden flex transition-all duration-300 ease-in-out">
|
||||
<div className="size-full flex flex-col overflow-hidden">
|
||||
<PageVersionsOverlay
|
||||
editorComponent={PagesVersionEditor}
|
||||
fetchVersionDetails={handlers.fetchVersionDetails}
|
||||
handleRestore={handleRestoreVersion}
|
||||
pageId={page.id ?? ""}
|
||||
restoreEnabled={isContentEditable}
|
||||
/>
|
||||
<PageEditorToolbarRoot
|
||||
handleOpenNavigationPane={handleOpenNavigationPane}
|
||||
isNavigationPaneOpen={isValidNavigationPaneTab}
|
||||
page={page}
|
||||
/>
|
||||
<PageEditorBody
|
||||
config={config}
|
||||
editorReady={editorReady}
|
||||
editorForwardRef={editorRef}
|
||||
handleConnectionStatus={setHasConnectionFailed}
|
||||
handleEditorReady={handleEditorReady}
|
||||
handleOpenNavigationPane={handleOpenNavigationPane}
|
||||
handlers={handlers}
|
||||
isNavigationPaneOpen={isValidNavigationPaneTab}
|
||||
page={page}
|
||||
webhookConnectionParams={webhookConnectionParams}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
<PageNavigationPaneRoot
|
||||
handleClose={handleCloseNavigationPane}
|
||||
isNavigationPaneOpen={isValidNavigationPaneTab}
|
||||
page={page}
|
||||
webhookConnectionParams={webhookConnectionParams}
|
||||
workspaceSlug={workspaceSlug}
|
||||
versionHistory={{
|
||||
fetchAllVersions: handlers.fetchAllVersions,
|
||||
fetchVersionDetails: handlers.fetchVersionDetails,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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> = (props) => {
|
||||
const { editorRef, setSidePeekVisible, showOutline = false } = props;
|
||||
const { className, editorRef, emptyState, setSidePeekVisible, showOutline = false } = props;
|
||||
// states
|
||||
const [headings, setHeadings] = useState<IMarking[]>([]);
|
||||
|
||||
|
|
@ -20,7 +23,7 @@ export const PageContentBrowser: React.FC<Props> = (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> = (props) => {
|
|||
);
|
||||
|
||||
const HeadingComponent: {
|
||||
[key: number]: React.FC<{ marking: IMarking; onClick: () => void }>;
|
||||
[key: number]: React.FC<THeadingComponentProps>;
|
||||
} = {
|
||||
1: OutlineHeading1,
|
||||
2: OutlineHeading2,
|
||||
3: OutlineHeading3,
|
||||
};
|
||||
|
||||
if (headings.length === 0) return emptyState ?? null;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-start gap-y-2 overflow-y-auto mt-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex flex-col items-start gap-y-1 overflow-y-auto mt-2",
|
||||
{
|
||||
"gap-y-2": showOutline,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{headings.map((marking) => {
|
||||
const Component = HeadingComponent[marking.level];
|
||||
if (!Component) return null;
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement, 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) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="text-sm text-left font-medium text-custom-text-300 hover:text-custom-primary-100 transition-colors"
|
||||
>
|
||||
<button type="button" onClick={onClick} className={cn(COMMON_CLASSNAME, "text-sm pl-1")}>
|
||||
{marking.text}
|
||||
</button>
|
||||
);
|
||||
|
||||
export const OutlineHeading2 = ({ marking, onClick }: THeadingComponentProps) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="ml-2 text-xs text-left font-medium text-custom-text-300 hover:text-custom-primary-100 transition-colors"
|
||||
>
|
||||
<button type="button" onClick={onClick} className={cn(COMMON_CLASSNAME, "text-xs pl-2")}>
|
||||
{marking.text}
|
||||
</button>
|
||||
);
|
||||
|
||||
export const OutlineHeading3 = ({ marking, onClick }: THeadingComponentProps) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="ml-4 text-xs text-left font-medium text-custom-text-300 hover:text-custom-primary-100 transition-colors"
|
||||
>
|
||||
<button type="button" onClick={onClick} className={cn(COMMON_CLASSNAME, "text-xs pl-4")}>
|
||||
{marking.text}
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
export * from "./color-dropdown";
|
||||
export * from "./info-popover";
|
||||
export * from "./options-dropdown";
|
||||
export * from "./root";
|
||||
export * from "./toolbar";
|
||||
|
|
|
|||
|
|
@ -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<Props> = observer((props) => {
|
||||
const { page } = props;
|
||||
// states
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
// refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(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 (
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
onMouseEnter={() => setIsPopoverOpen(true)}
|
||||
onMouseLeave={() => setIsPopoverOpen(false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
ref={setReferenceElement}
|
||||
className="size-6 grid place-items-center rounded text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 transition-colors"
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</button>
|
||||
{isPopoverOpen && (
|
||||
<div
|
||||
className="z-10 w-64 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-2 shadow-custom-shadow-rg"
|
||||
ref={setPopperElement}
|
||||
style={infoPopoverStyles.popper}
|
||||
{...infoPopoverAttributes.popper}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{documentInfoCards.map((card) => (
|
||||
<div key={card.key} className="p-2 bg-custom-background-90 rounded">
|
||||
<h6 className="text-base font-semibold">{card.info}</h6>
|
||||
<p className="mt-1.5 text-sm text-custom-text-300">{card.title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2 mt-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-custom-text-300">Edited by</p>
|
||||
<Link
|
||||
href={`/${workspaceSlug?.toString()}/profile/${page.updated_by}`}
|
||||
className="mt-2 flex items-center gap-1.5 text-sm font-medium"
|
||||
>
|
||||
<Avatar
|
||||
src={getFileURL(editorInformation?.avatar_url ?? "")}
|
||||
name={editorInformation?.display_name}
|
||||
className="flex-shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
<span>
|
||||
{editorInformation?.display_name}{" "}
|
||||
<span className="text-custom-text-300">{calculateTimeAgoShort(page.updated_at ?? "")} ago</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-custom-text-300">Created by</p>
|
||||
<Link
|
||||
href={`/${workspaceSlug?.toString()}/profile/${page.created_by}`}
|
||||
className="mt-2 flex items-center gap-1.5 text-sm font-medium"
|
||||
>
|
||||
<Avatar
|
||||
src={getFileURL(creatorInformation?.avatar_url ?? "")}
|
||||
name={creatorInformation?.display_name}
|
||||
className="flex-shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
<span>
|
||||
{creatorInformation?.display_name}{" "}
|
||||
<span className="text-custom-text-300">{renderFormattedDate(page.created_at)}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -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<Props> = 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<Props> = 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<Props> = observer((props) => {
|
|||
shouldRender: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
editorRef,
|
||||
handleFullWidth,
|
||||
handleStickyToolbar,
|
||||
isContentEditable,
|
||||
isFullWidth,
|
||||
isStickyToolbarEnabled,
|
||||
router,
|
||||
updateQueryParams,
|
||||
]
|
||||
[editorRef, handleFullWidth, handleStickyToolbar, isContentEditable, isFullWidth, isStickyToolbarEnabled]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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<Props> = 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 (
|
||||
<div
|
||||
id="page-toolbar-container"
|
||||
className={cn("max-h-[52px] transition-all ease-linear duration-300 overflow-auto", {
|
||||
"max-h-0 overflow-hidden": shouldHideToolbar,
|
||||
})}
|
||||
>
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:flex items-center relative min-h-[52px] page-toolbar-content px-page-x transition-all duration-200 ease-in-out",
|
||||
{
|
||||
"wide-layout": isFullWidth,
|
||||
}
|
||||
)}
|
||||
id="page-toolbar-container"
|
||||
className={cn("max-h-[52px] transition-all ease-linear duration-300 overflow-auto", {
|
||||
"max-h-0 overflow-hidden": shouldHideToolbar,
|
||||
})}
|
||||
>
|
||||
<div className="max-w-full w-full flex items-center justify-between">
|
||||
{editorRef && <PageToolbar editorRef={editorRef} />}
|
||||
<PageCollaboratorsList page={page} />
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:flex items-center relative min-h-[52px] page-toolbar-content px-page-x transition-all duration-200 ease-in-out",
|
||||
{
|
||||
"wide-layout": isFullWidth,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="max-w-full w-full flex items-center justify-between">
|
||||
{editorRef && <PageToolbar editorRef={editorRef} />}
|
||||
<div className="flex items-center gap-2">
|
||||
<PageCollaboratorsList page={page} />
|
||||
{!isNavigationPaneOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 transition-colors"
|
||||
onClick={handleOpenNavigationPane}
|
||||
>
|
||||
<PanelRight className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{shouldHideToolbar && (
|
||||
<div className="absolute z-10 top-0 right-0 h-[52px] px-page-x flex items-center">
|
||||
{!isNavigationPaneOpen && (
|
||||
<Tooltip tooltipContent={t("page_navigation_pane.open_button")}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 transition-colors"
|
||||
onClick={handleOpenNavigationPane}
|
||||
aria-label={t("page_navigation_pane.open_button")}
|
||||
>
|
||||
<PanelRight className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Props> = observer((props) => {
|
|||
<PageOfflineBadge page={page} />
|
||||
<PageLockControl page={page} />
|
||||
<PageMoveControl page={page} />
|
||||
<PageInfoPopover page={page} />
|
||||
<PageCopyLinkControl page={page} />
|
||||
<PageFavoriteControl page={page} />
|
||||
<PageShareControl page={page} storeType={storeType} />
|
||||
|
|
|
|||
11
web/core/components/pages/navigation-pane/index.ts
Normal file
11
web/core/components/pages/navigation-pane/index.ts
Normal file
|
|
@ -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);
|
||||
88
web/core/components/pages/navigation-pane/root.tsx
Normal file
88
web/core/components/pages/navigation-pane/root.tsx
Normal file
|
|
@ -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<TPageRootHandlers, "fetchAllVersions" | "fetchVersionDetails">;
|
||||
};
|
||||
|
||||
export const PageNavigationPaneRoot: React.FC<Props> = 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 (
|
||||
<aside
|
||||
className="flex-shrink-0 h-full flex flex-col bg-custom-background-100 pt-3.5 border-l border-custom-border-200 transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
width: `${PAGE_NAVIGATION_PANE_WIDTH}px`,
|
||||
marginRight: isNavigationPaneOpen ? "0px" : `-${PAGE_NAVIGATION_PANE_WIDTH}px`,
|
||||
}}
|
||||
>
|
||||
<div className="mb-3.5 px-3.5">
|
||||
<Tooltip tooltipContent={t("page_navigation_pane.close_button")}>
|
||||
<button
|
||||
type="button"
|
||||
className="size-3.5 grid place-items-center text-custom-text-200 hover:text-custom-text-100 transition-colors"
|
||||
onClick={handleClose}
|
||||
aria-label={t("page_navigation_pane.close_button")}
|
||||
>
|
||||
<ArrowRightCircle className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tab.Group as={React.Fragment} selectedIndex={selectedIndex} onChange={handleTabChange}>
|
||||
<PageNavigationPaneTabsList />
|
||||
<PageNavigationPaneTabPanelsRoot page={page} versionHistory={versionHistory} />
|
||||
</Tab.Group>
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
109
web/core/components/pages/navigation-pane/tab-panels/assets.tsx
Normal file
109
web/core/components/pages/navigation-pane/tab-panels/assets.tsx
Normal file
|
|
@ -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 (
|
||||
<a
|
||||
href={asset.href}
|
||||
className="relative group/asset-item h-12 flex items-center gap-2 pr-2 rounded border border-custom-border-200 hover:bg-custom-background-80 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 w-11 h-12 rounded-l bg-cover bg-no-repeat bg-center"
|
||||
style={{
|
||||
backgroundImage: `url('${getAssetSrc(asset.src)}')`,
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 space-y-0.5 truncate">
|
||||
<p className="text-sm font-medium truncate">{asset.name}</p>
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<p className="shrink-0 text-xs text-custom-text-200" />
|
||||
<a
|
||||
href={getAssetDownloadSrc(asset.src)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="shrink-0 py-0.5 px-1 flex items-center gap-1 rounded text-custom-text-200 hover:text-custom-text-100 opacity-0 pointer-events-none group-hover/asset-item:opacity-100 group-hover/asset-item:pointer-events-auto transition-opacity"
|
||||
>
|
||||
<Download className="shrink-0 size-3" />
|
||||
<span className="text-xs font-medium">{t("page_navigation_pane.tabs.assets.download_button")}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
||||
return <AdditionalPageNavigationPaneAssetItem asset={asset} page={page} />;
|
||||
});
|
||||
|
||||
export const PageNavigationPaneAssetsTabPanel: React.FC<Props> = observer((props) => {
|
||||
const { page } = props;
|
||||
// derived values
|
||||
const {
|
||||
editor: { assetsList },
|
||||
} = page;
|
||||
|
||||
if (assetsList.length === 0) return <PageNavigationPaneAssetsTabEmptyState />;
|
||||
|
||||
return (
|
||||
<div className="mt-5 space-y-4">
|
||||
{assetsList?.map((asset) => <AssetItem key={asset.id} asset={asset} page={page} />)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -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<Props> = 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 (
|
||||
<div className="space-y-3 mt-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-custom-text-300">
|
||||
{t("page_navigation_pane.tabs.info.actors_info.edited_by")}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-sm font-medium">
|
||||
<Link href={`/${workspaceSlug?.toString()}/profile/${page.updated_by}`} className="flex items-center gap-1">
|
||||
<Avatar
|
||||
src={getFileURL(editorInformation?.avatar_url ?? "")}
|
||||
name={editorInformation?.display_name}
|
||||
className="flex-shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
<span>{editorInformation?.display_name ?? t("common.deactivated_user")}</span>
|
||||
</Link>
|
||||
<span className="flex-shrink-0 text-custom-text-300">{calculateTimeAgoShort(page.updated_at ?? "")} ago</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-custom-text-300">
|
||||
{t("page_navigation_pane.tabs.info.actors_info.created_by")}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-sm font-medium">
|
||||
<Link href={`/${workspaceSlug?.toString()}/profile/${page.created_by}`} className="flex items-center gap-1">
|
||||
<Avatar
|
||||
src={getFileURL(creatorInformation?.avatar_url ?? "")}
|
||||
name={creatorInformation?.display_name}
|
||||
className="flex-shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
<span>{creatorInformation?.display_name ?? t("common.deactivated_user")}</span>
|
||||
</Link>
|
||||
<span className="flex-shrink-0 text-custom-text-300">{renderFormattedDate(page.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -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<Props> = observer((props) => {
|
||||
const { page } = props;
|
||||
// states
|
||||
const [documentInfo, setDocumentInfo] = useState<TDocumentInfo>(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 (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{documentInfoCards.map((card) => (
|
||||
<div key={card.key} className="p-2 bg-custom-background-90 rounded">
|
||||
<h6 className="text-base font-semibold">{card.info}</h6>
|
||||
<p className="mt-1.5 text-sm text-custom-text-300 font-medium">{card.title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -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<TPageRootHandlers, "fetchAllVersions" | "fetchVersionDetails">;
|
||||
};
|
||||
|
||||
export const PageNavigationPaneInfoTabPanel: React.FC<Props> = observer((props) => {
|
||||
const { page, versionHistory } = props;
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<PageNavigationPaneInfoTabDocumentInfo page={page} />
|
||||
<PageNavigationPaneInfoTabActorsInfo page={page} />
|
||||
<div className="flex-shrink-0 h-px bg-custom-background-80 my-3" />
|
||||
<PageNavigationPaneInfoTabVersionHistory page={page} versionHistory={versionHistory} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -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<TPageRootHandlers, "fetchAllVersions" | "fetchVersionDetails">;
|
||||
};
|
||||
|
||||
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 (
|
||||
<li className="relative flex items-center gap-x-4 text-xs font-medium">
|
||||
{/* timeline icon */}
|
||||
<div className="relative size-6 flex-none grid place-items-center">
|
||||
<div className="size-2 rounded-full bg-custom-background-80" />
|
||||
</div>
|
||||
{/* end timeline icon */}
|
||||
<Link
|
||||
href={getVersionLink(version.id)}
|
||||
className={cn("block flex-1 hover:bg-custom-background-90 rounded-md py-2 px-1", {
|
||||
"bg-custom-background-80 hover:bg-custom-background-80": isVersionActive,
|
||||
})}
|
||||
>
|
||||
<p className="text-custom-text-300">
|
||||
{renderFormattedDate(version.last_saved_at)}, {renderFormattedTime(version.last_saved_at)}
|
||||
</p>
|
||||
<p className="mt-1 flex items-center gap-1">
|
||||
<Avatar
|
||||
size="sm"
|
||||
src={getFileURL(versionCreator?.avatar_url ?? "")}
|
||||
name={versionCreator?.display_name}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span>{versionCreator?.display_name ?? t("common.deactivated_user")}</span>
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
export const PageNavigationPaneInfoTabVersionHistory: React.FC<Props> = 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 (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-custom-text-200">
|
||||
{t("page_navigation_pane.tabs.info.version_history.label")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<ul role="list" className="relative">
|
||||
{/* timeline line */}
|
||||
<div className={cn("absolute left-0 top-0 h-full flex w-6 justify-center")}>
|
||||
<div className="w-px bg-custom-background-80" />
|
||||
</div>
|
||||
{/* end timeline line */}
|
||||
<li className="relative flex items-center gap-x-4 text-xs font-medium">
|
||||
{/* timeline icon */}
|
||||
<div className="relative size-6 flex-none rounded-full grid place-items-center bg-custom-primary-100/20">
|
||||
<div className="size-2.5 rounded-full bg-custom-primary-100/40" />
|
||||
</div>
|
||||
{/* end timeline icon */}
|
||||
<Link
|
||||
href={getVersionLink()}
|
||||
className={cn("flex-1 hover:bg-custom-background-90 rounded-md py-2 px-1", {
|
||||
"bg-custom-background-80 hover:bg-custom-background-80": !activeVersion,
|
||||
})}
|
||||
>
|
||||
{t("page_navigation_pane.tabs.info.version_history.current_version")}
|
||||
</Link>
|
||||
</li>
|
||||
{versionsList?.map((version) => (
|
||||
<VersionHistoryItem
|
||||
key={version.id}
|
||||
getVersionLink={getVersionLink}
|
||||
isVersionActive={activeVersion === version.id}
|
||||
version={version}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -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> = (props) => {
|
||||
const { page } = props;
|
||||
// derived values
|
||||
const {
|
||||
editor: { editorRef },
|
||||
} = page;
|
||||
|
||||
return (
|
||||
<div className="size-full pt-3 space-y-1">
|
||||
<PageContentBrowser
|
||||
className="mt-0"
|
||||
editorRef={editorRef}
|
||||
emptyState={<PageNavigationPaneOutlineTabEmptyState />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<TPageRootHandlers, "fetchAllVersions" | "fetchVersionDetails">;
|
||||
};
|
||||
|
||||
export const PageNavigationPaneTabPanelsRoot: React.FC<Props> = (props) => {
|
||||
const { page, versionHistory } = props;
|
||||
|
||||
return (
|
||||
<Tab.Panels as={React.Fragment}>
|
||||
{ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => (
|
||||
<Tab.Panel
|
||||
key={tab.key}
|
||||
as="div"
|
||||
className="size-full p-3.5 pt-0 overflow-y-auto vertical-scrollbar scrollbar-sm outline-none"
|
||||
>
|
||||
{tab.key === "outline" && <PageNavigationPaneOutlineTabPanel page={page} />}
|
||||
{tab.key === "info" && <PageNavigationPaneInfoTabPanel page={page} versionHistory={versionHistory} />}
|
||||
{tab.key === "assets" && <PageNavigationPaneAssetsTabPanel page={page} />}
|
||||
<PageNavigationPaneAdditionalTabPanelsRoot activeTab={tab.key} page={page} />
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
);
|
||||
};
|
||||
37
web/core/components/pages/navigation-pane/tabs-list.tsx
Normal file
37
web/core/components/pages/navigation-pane/tabs-list.tsx
Normal file
|
|
@ -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 (
|
||||
<Tab.List className="relative flex items-center p-[2px] rounded-md bg-custom-background-80 mx-3.5">
|
||||
{({ selectedIndex }) => (
|
||||
<>
|
||||
{ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className="relative z-[1] flex-1 py-1.5 text-sm font-semibold outline-none"
|
||||
>
|
||||
{t(tab.i18n_label)}
|
||||
</Tab>
|
||||
))}
|
||||
{/* active tab indicator */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 bg-custom-background-90 rounded transition-all duration-500 ease-in-out pointer-events-none"
|
||||
style={{
|
||||
left: `calc(${(selectedIndex / ORDERED_PAGE_NAVIGATION_TABS_LIST.length) * 100}% + 2px)`,
|
||||
height: "calc(100% - 4px)",
|
||||
width: `calc(${100 / ORDERED_PAGE_NAVIGATION_TABS_LIST.length}% - 4px)`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Tab.List>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<TVersionEditorProps> = 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<TVersionEditorProps> = observer((props
|
|||
wideLayout: true,
|
||||
};
|
||||
|
||||
if (!isCurrentVersionActive && !versionDetails)
|
||||
if (!versionDetails)
|
||||
return (
|
||||
<div className="size-full px-5">
|
||||
<Loader className="relative space-y-4">
|
||||
|
|
@ -91,7 +89,7 @@ export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props
|
|||
</div>
|
||||
);
|
||||
|
||||
const description = isCurrentVersionActive ? currentVersionDescription : versionDetails?.description_html;
|
||||
const description = versionDetails?.description_html;
|
||||
if (description === undefined || description?.trim() === "") return null;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<TVersionEditorProps>;
|
||||
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
|
||||
handleClose: () => void;
|
||||
|
|
@ -23,16 +22,8 @@ type Props = {
|
|||
};
|
||||
|
||||
export const PageVersionsMainContent: React.FC<Props> = 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<Props> = 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<Props> = observer((props) => {
|
|||
) : (
|
||||
<>
|
||||
<div className="min-h-14 py-3 px-5 border-b border-custom-border-200 flex items-center justify-between gap-2">
|
||||
<h6 className="text-base font-medium">
|
||||
{isCurrentVersionActive
|
||||
? "Current version"
|
||||
: versionDetails
|
||||
<div className="flex items-center gap-4">
|
||||
<h6 className="text-base font-medium">
|
||||
{versionDetails
|
||||
? `${renderFormattedDate(versionDetails.last_saved_at)} ${renderFormattedTime(versionDetails.last_saved_at)}`
|
||||
: "Loading version details"}
|
||||
</h6>
|
||||
{!isCurrentVersionActive && restoreEnabled && (
|
||||
</h6>
|
||||
<span className="flex-shrink-0 flex items-center gap-1 text-xs font-medium text-custom-primary-100 bg-custom-primary-100/20 py-1 px-1.5 rounded">
|
||||
<EyeIcon className="flex-shrink-0 size-3" />
|
||||
View only
|
||||
</span>
|
||||
</div>
|
||||
{restoreEnabled && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
|
|
@ -116,12 +109,7 @@ export const PageVersionsMainContent: React.FC<Props> = observer((props) => {
|
|||
)}
|
||||
</div>
|
||||
<div className="pt-8 h-full overflow-y-scroll vertical-scrollbar scrollbar-sm">
|
||||
<VersionEditor
|
||||
activeVersion={activeVersion}
|
||||
currentVersionDescription={currentVersionDescription}
|
||||
isCurrentVersionActive={isCurrentVersionActive}
|
||||
versionDetails={versionDetails}
|
||||
/>
|
||||
<VersionEditor activeVersion={activeVersion} versionDetails={versionDetails} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<TVersionEditorProps>;
|
||||
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
|
||||
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
|
||||
handleRestore: (descriptionHTML: string) => Promise<void>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
pageId: string;
|
||||
restoreEnabled: boolean;
|
||||
};
|
||||
|
||||
export const PageVersionsOverlay: React.FC<Props> = 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 (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-[16] size-full bg-custom-background-100 flex overflow-hidden opacity-0 pointer-events-none transition-opacity",
|
||||
"absolute inset-0 z-[16] h-full bg-custom-background-100 flex overflow-hidden opacity-0 pointer-events-none transition-opacity",
|
||||
{
|
||||
"opacity-100 pointer-events-auto": isOpen,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
width: `calc(100% - ${PAGE_NAVIGATION_PANE_WIDTH}px)`,
|
||||
}}
|
||||
>
|
||||
<PageVersionsMainContent
|
||||
activeVersion={activeVersion}
|
||||
currentVersionDescription={currentVersionDescription}
|
||||
editorComponent={editorComponent}
|
||||
fetchVersionDetails={fetchVersionDetails}
|
||||
handleClose={handleClose}
|
||||
|
|
@ -56,13 +58,6 @@ export const PageVersionsOverlay: React.FC<Props> = observer((props) => {
|
|||
pageId={pageId}
|
||||
restoreEnabled={restoreEnabled}
|
||||
/>
|
||||
<PageVersionsSidebarRoot
|
||||
activeVersion={activeVersion}
|
||||
fetchAllVersions={fetchAllVersions}
|
||||
handleClose={handleClose}
|
||||
isOpen={isOpen}
|
||||
pageId={pageId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Props> = 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 (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn("block p-2 rounded-md w-72 hover:bg-custom-background-80 transition-colors", {
|
||||
"bg-custom-background-80": isActive,
|
||||
})}
|
||||
>
|
||||
<p className="text-sm font-medium truncate">
|
||||
{renderFormattedDate(version.last_saved_at)} {renderFormattedTime(version.last_saved_at)}
|
||||
</p>
|
||||
<p className="mt-2 flex items-center gap-1 text-xs">
|
||||
<Avatar
|
||||
src={getFileURL(ownerDetails?.avatar_url ?? "")}
|
||||
name={ownerDetails?.display_name}
|
||||
shape="square"
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="text-custom-text-300">{ownerDetails?.display_name ?? t("common.deactivated_user")}</span>
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
|
@ -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<TPageVersion[] | undefined>;
|
||||
isOpen: boolean;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const PageVersionsSidebarList: React.FC<Props> = (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 (
|
||||
<div className="mt-4 px-4 h-full flex flex-col space-y-2 overflow-y-scroll vertical-scrollbar scrollbar-sm">
|
||||
<Link
|
||||
href={getVersionLink("current")}
|
||||
className={cn("block p-2 rounded-md w-72 hover:bg-custom-background-80 transition-colors", {
|
||||
"bg-custom-background-80": activeVersion === "current",
|
||||
})}
|
||||
>
|
||||
<p className="text-sm font-medium">Current version</p>
|
||||
</Link>
|
||||
{versionsListError ? (
|
||||
<div className="h-full grid place-items-center">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<span className="flex-shrink-0 grid place-items-center size-11 text-custom-text-300">
|
||||
<TriangleAlert className="size-10" />
|
||||
</span>
|
||||
<div>
|
||||
<h6 className="text-base font-semibold">Something went wrong!</h6>
|
||||
<p className="text-xs text-custom-text-300">
|
||||
There was a problem while loading previous
|
||||
<br />
|
||||
versions, please try again.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="link-primary" onClick={handleRetry} loading={isRetrying}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : versionsList ? (
|
||||
versionsList.map((version) => (
|
||||
<PlaneVersionsSidebarListItem
|
||||
key={version.id}
|
||||
href={getVersionLink(version.id)}
|
||||
isActive={activeVersion === version.id}
|
||||
version={version}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="56px" />
|
||||
<Loader.Item height="56px" />
|
||||
<Loader.Item height="56px" />
|
||||
<Loader.Item height="56px" />
|
||||
<Loader.Item height="56px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<TPageVersion[] | undefined>;
|
||||
handleClose: () => void;
|
||||
isOpen: boolean;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const PageVersionsSidebarRoot: React.FC<Props> = (props) => {
|
||||
const { activeVersion, fetchAllVersions, handleClose, isOpen, pageId } = props;
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 py-4 border-l border-custom-border-200 flex flex-col">
|
||||
<div className="px-6 flex items-center justify-between gap-2">
|
||||
<h5 className="text-base font-semibold">Version history</h5>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex-shrink-0 size-6 grid place-items-center text-custom-text-300 hover:text-custom-text-100 transition-colors"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<PageVersionsSidebarList
|
||||
activeVersion={activeVersion}
|
||||
fetchAllVersions={fetchAllVersions}
|
||||
isOpen={isOpen}
|
||||
pageId={pageId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
duplicate: () => Promise<TPage | undefined>;
|
||||
mutateProperties: (data: Partial<TPage>, 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;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
41
web/core/store/pages/page-editor-info.ts
Normal file
41
web/core/store/pages/page-editor-info.ts
Normal file
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
}
|
||||
BIN
web/public/empty-state/pages/navigation-pane/assets-dark.webp
Normal file
BIN
web/public/empty-state/pages/navigation-pane/assets-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
web/public/empty-state/pages/navigation-pane/assets-light.webp
Normal file
BIN
web/public/empty-state/pages/navigation-pane/assets-light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
web/public/empty-state/pages/navigation-pane/outline-dark.webp
Normal file
BIN
web/public/empty-state/pages/navigation-pane/outline-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
web/public/empty-state/pages/navigation-pane/outline-light.webp
Normal file
BIN
web/public/empty-state/pages/navigation-pane/outline-light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -497,6 +497,7 @@
|
|||
text-rendering: optimizeLegibility;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue