[WEB-2050] dev: added new information panels to a page (#5409)

* dev: added new information panels to pages

* refactor: update function name
This commit is contained in:
Aaryan Khandelwal
2024-08-28 14:08:29 +05:30
committed by GitHub
parent fb2a04dc14
commit 7efda1c392
13 changed files with 173 additions and 65 deletions

View File

@@ -32,6 +32,7 @@
"@plane/ui": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-character-count": "^2.6.5",
"@tiptap/extension-collaboration": "^2.3.2",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-list-item": "^2.1.13",

View File

@@ -1,3 +1,4 @@
import CharacterCount from "@tiptap/extension-character-count";
import Placeholder from "@tiptap/extension-placeholder";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
@@ -157,4 +158,5 @@ export const CoreEditorExtensions = ({
},
includeChildren: true,
}),
CharacterCount,
];

View File

@@ -1,7 +1,5 @@
import { Extensions, generateJSON, getSchema } from "@tiptap/core";
import { Selection } from "@tiptap/pm/state";
import { EditorState, Selection } from "@tiptap/pm/state";
import { clsx, type ClassValue } from "clsx";
import { CoreEditorExtensionsWithoutProps } from "src/core/extensions/core-without-props";
import { twMerge } from "tailwind-merge";
interface EditorClassNames {
@@ -61,3 +59,12 @@ export const isValidHttpUrl = (string: string): boolean => {
return url.protocol === "http:" || url.protocol === "https:";
};
export const getParagraphCount = (editorState: EditorState | undefined) => {
if (!editorState) return 0;
let paragraphCount = 0;
editorState.doc.descendants((node) => {
if (node.type.name === "paragraph" && node.content.size > 0) paragraphCount++;
});
return paragraphCount;
};

View File

@@ -8,6 +8,7 @@ import { getEditorMenuItems } from "@/components/menus";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
import { getParagraphCount } from "@/helpers/common";
import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position";
import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// plane editor providers
@@ -249,6 +250,11 @@ export const useEditor = (props: CustomEditorProps) => {
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
documentInfo: {
characters: editorRef.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef.current?.state),
words: editorRef.current?.storage?.characterCount?.words?.() ?? 0,
},
}),
[editorRef, savedSelection, fileHandler.upload]
);

View File

@@ -4,6 +4,7 @@ import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
// extensions
import { CoreReadOnlyEditorExtensions } from "@/extensions";
// helpers
import { getParagraphCount } from "@/helpers/common";
import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// props
import { CoreReadOnlyEditorProps } from "@/props";
@@ -81,6 +82,11 @@ export const useReadOnlyEditor = ({
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
documentInfo: {
characters: editorRef.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef.current?.state),
words: editorRef.current?.storage?.characterCount?.words?.() ?? 0,
},
}));
if (!editor) {

View File

@@ -9,6 +9,11 @@ export type EditorReadOnlyRefApi = {
clearEditor: (emitUpdate?: boolean) => void;
setEditorValue: (content: string) => void;
scrollSummary: (marking: IMarking) => void;
documentInfo: {
characters: number;
paragraphs: number;
words: number;
};
};
export interface EditorRefApi extends EditorReadOnlyRefApi {

View File

@@ -10,6 +10,7 @@ import { TLogoProps } from "@plane/types";
import { Breadcrumbs, Button, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { PageEditInformationPopover } from "@/components/pages";
// helpers
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
// hooks
@@ -30,7 +31,8 @@ export const PageDetailsHeader = observer(() => {
const [isOpen, setIsOpen] = useState(false);
// store hooks
const { currentProjectDetails, loader } = useProject();
const { isContentEditable, isSubmitting, name, logo_props, updatePageLogo } = usePage(pageId?.toString() ?? "");
const page = usePage(pageId?.toString() ?? "");
const { isContentEditable, isSubmitting, name, logo_props, updatePageLogo } = page;
// use platform
const { isMobile, platform } = usePlatformOS();
// derived values
@@ -159,6 +161,7 @@ export const PageDetailsHeader = observer(() => {
</Breadcrumbs>
</div>
</div>
<PageEditInformationPopover page={page} />
<PageDetailsHeaderExtraActions />
{isContentEditable && !isVersionHistoryOverlayActive && (
<Button

View File

@@ -0,0 +1,70 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
// plane ui
import { Avatar } from "@plane/ui";
// helpers
import { calculateTimeAgoShort, renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useMember } from "@/hooks/store";
// store
import { IPage } from "@/store/pages/page";
type Props = {
page: IPage;
};
export const PageEditInformationPopover: React.FC<Props> = observer((props) => {
const { page } = props;
// router
const { workspaceSlug } = useParams();
// store hooks
const { getUserDetails } = useMember();
const editorInformation = page.updated_by ? getUserDetails(page.updated_by) : undefined;
const creatorInformation = page.created_by ? getUserDetails(page.created_by) : undefined;
return (
<div className="flex-shrink-0 relative group/edit-information whitespace-nowrap">
<span className="text-sm text-custom-text-300">Edited {calculateTimeAgoShort(page.updated_at ?? "")} ago</span>
<div className="hidden group-hover/edit-information:block absolute z-10 top-full right-0 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-2 shadow-custom-shadow-rg space-y-2">
<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={editorInformation?.avatar}
name={editorInformation?.display_name}
className="flex-shrink-0"
size="sm"
/>
<span>
{editorInformation?.display_name}{" "}
<span className="text-custom-text-300">{renderFormattedDate(page.updated_at)}</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={creatorInformation?.avatar}
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>
);
});

View File

@@ -1 +1,2 @@
export * from "./edit-information-popover";
export * from "./quick-actions";

View File

@@ -72,7 +72,7 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
className="!min-w-[38rem]"
/>
)}
<PageInfoPopover page={page} />
<PageInfoPopover editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current} />
<PageOptionsDropdown
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
handleDuplicatePage={handleDuplicatePage}

View File

@@ -1,19 +1,19 @@
import { useState } from "react";
import { usePopper } from "react-popper";
import { Calendar, History, Info } from "lucide-react";
import { Info } from "lucide-react";
// plane editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
// store
import { IPage } from "@/store/pages/page";
import { getReadTimeFromWordsCount } from "@/helpers/date-time.helper";
type Props = {
page: IPage;
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
};
export const PageInfoPopover: React.FC<Props> = (props) => {
const { page } = props;
const { editorRef } = props;
// states
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
// refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -21,35 +21,54 @@ export const PageInfoPopover: React.FC<Props> = (props) => {
const { styles: infoPopoverStyles, attributes: infoPopoverAttributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-start",
});
// derived values
const { created_at, updated_at } = page;
const secondsToReadableTime = () => {
const wordsCount = editorRef?.documentInfo.words || 0;
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: editorRef?.documentInfo.words,
},
{
key: "characters-count",
title: "Characters",
info: editorRef?.documentInfo.characters,
},
{
key: "paragraphs-count",
title: "Paragraphs",
info: editorRef?.documentInfo.paragraphs,
},
{
key: "read-time",
title: "Read time",
info: secondsToReadableTime(),
},
];
return (
<div onMouseEnter={() => setIsPopoverOpen(true)} onMouseLeave={() => setIsPopoverOpen(false)}>
<button type="button" ref={setReferenceElement} className="block">
<Info className="h-3.5 w-3.5" />
<Info className="size-3.5" />
</button>
{isPopoverOpen && (
<div
className="z-10 w-64 space-y-2.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg"
className="z-10 w-64 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-2 shadow-custom-shadow-rg grid grid-cols-2 gap-1.5"
ref={setPopperElement}
style={infoPopoverStyles.popper}
{...infoPopoverAttributes.popper}
>
<div className="space-y-1.5">
<h6 className="text-xs text-custom-text-400">Last updated on</h6>
<h5 className="flex items-center gap-1 text-sm">
<History className="h-3 w-3" />
{renderFormattedDate(updated_at)}
</h5>
</div>
<div className="space-y-1.5">
<h6 className="text-xs text-custom-text-400">Created on</h6>
<h5 className="flex items-center gap-1 text-sm">
<Calendar className="h-3 w-3" />
{renderFormattedDate(created_at)}
</h5>
</div>
{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>

View File

@@ -344,3 +344,16 @@ export const convertMinutesToHoursMinutesString = (totalMinutes: number): string
return `${hours ? `${hours}h ` : ``}${minutes ? `${minutes}m ` : ``}`;
};
/**
* @description calculates the read time for a document using the words count
* @param {number} wordsCount
* @returns {number} total number of seconds
* @example getReadTimeFromWordsCount(400) // Output: 120
* @example getReadTimeFromWordsCount(100) // Output: 30s
*/
export const getReadTimeFromWordsCount = (wordsCount: number): number => {
const wordsPerMinute = 200;
const minutes = wordsCount / wordsPerMinute;
return minutes * 60;
};

View File

@@ -3750,6 +3750,11 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.4.0.tgz#60eea05b5ac8c8e8d615c057559fddb95033abeb"
integrity sha512-9S5DLIvFRBoExvmZ+/ErpTvs4Wf1yOEs8WXlKYUCcZssK7brTFj99XDwpHFA29HKDwma5q9UHhr2OB2o0JYAdw==
"@tiptap/extension-character-count@^2.6.5":
version "2.6.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-character-count/-/extension-character-count-2.6.5.tgz#8ccecf900c0c89a0b14de137b224c7b26def959e"
integrity sha512-UZlSzfZ6Vq0zOGhNOAzLIEjAhS46dSsHXFnhgw8l61tLknVeIyXbcdi8hBxWzOQ6XkH2PA3QSnahEGwYks73WQ==
"@tiptap/extension-code-block@^2.4.0":
version "2.4.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-2.4.0.tgz#b7f1da4825677a2ea6b8e970a1197877551e5dc8"
@@ -4377,7 +4382,7 @@
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@18.2.48", "@types/react@^16.8.0 || ^17.0.0 || ^18.0.0", "@types/react@^18.2.42", "@types/react@^18.2.48":
"@types/react@*", "@types/react@^16.8.0 || ^17.0.0 || ^18.0.0", "@types/react@^18.2.42", "@types/react@^18.2.48":
version "18.2.48"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.48.tgz#11df5664642d0bd879c1f58bc1d37205b064e8f1"
integrity sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==
@@ -10535,7 +10540,7 @@ prelude-ls@^1.2.1:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
"prettier-fallback@npm:prettier@^3":
"prettier-fallback@npm:prettier@^3", prettier@^3.1.1, prettier@^3.2.5, prettier@latest:
version "3.3.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.1.tgz#e68935518dd90bb7ec4821ba970e68f8de16e1ac"
integrity sha512-7CAwy5dRsxs8PHXT3twixW9/OEll8MLE0VRPCJyl7CkS6VHGPSlsVaWTiASPTyGyYRyApxlaWTzwUxVNrhcwDg==
@@ -10562,11 +10567,6 @@ prettier@^2.8.8:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da"
integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==
prettier@^3.1.1, prettier@^3.2.5, prettier@latest:
version "3.3.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.1.tgz#e68935518dd90bb7ec4821ba970e68f8de16e1ac"
integrity sha512-7CAwy5dRsxs8PHXT3twixW9/OEll8MLE0VRPCJyl7CkS6VHGPSlsVaWTiASPTyGyYRyApxlaWTzwUxVNrhcwDg==
pretty-error@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-4.0.0.tgz#90a703f46dd7234adb46d0f84823e9d1cb8f10d6"
@@ -11950,16 +11950,7 @@ string-argv@~0.3.2:
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6"
integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -12046,14 +12037,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -13337,16 +13321,7 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==