diff --git a/packages/editor/package.json b/packages/editor/package.json index 1c30558e3b..7d60512ae9 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -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", diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 4605597eed..823754a931 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -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, ]; diff --git a/packages/editor/src/core/helpers/common.ts b/packages/editor/src/core/helpers/common.ts index 98930d94f1..0fb32310d6 100644 --- a/packages/editor/src/core/helpers/common.ts +++ b/packages/editor/src/core/helpers/common.ts @@ -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; +}; diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 523c4be0fa..8cade7c173 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -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] ); diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index 0a737873ed..0c39033f7a 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -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) { diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index b26fac3844..7379579346 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -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 { diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index 4cfe11d353..6ddce2c1d4 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -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(() => { + {isContentEditable && !isVersionHistoryOverlayActive && ( {isPopoverOpen && (
-
-
Last updated on
-
- - {renderFormattedDate(updated_at)} -
-
-
-
Created on
-
- - {renderFormattedDate(created_at)} -
-
+ {documentInfoCards.map((card) => ( +
+
{card.info}
+

{card.title}

+
+ ))}
)} diff --git a/web/helpers/date-time.helper.ts b/web/helpers/date-time.helper.ts index cc8fd8f82d..50728182a9 100644 --- a/web/helpers/date-time.helper.ts +++ b/web/helpers/date-time.helper.ts @@ -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; +}; diff --git a/yarn.lock b/yarn.lock index a33ac524f2..c06e1c0233 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==