Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/editor/src/core/extensions/extensions.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -157,4 +158,5 @@ export const CoreEditorExtensions = ({
},
includeChildren: true,
}),
CharacterCount,
];
13 changes: 10 additions & 3 deletions packages/editor/src/core/helpers/common.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
};
6 changes: 6 additions & 0 deletions packages/editor/src/core/hooks/use-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
);
Expand Down
6 changes: 6 additions & 0 deletions packages/editor/src/core/hooks/use-read-only-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions packages/editor/src/core/types/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export type EditorReadOnlyRefApi = {
clearEditor: () => void;
setEditorValue: (content: string) => void;
scrollSummary: (marking: IMarking) => void;
documentInfo: {
characters: number;
paragraphs: number;
words: number;
};
};

export interface EditorRefApi extends EditorReadOnlyRefApi {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,7 +30,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
Expand Down Expand Up @@ -156,6 +158,7 @@ export const PageDetailsHeader = observer(() => {
</Breadcrumbs>
</div>
</div>
<PageEditInformationPopover page={page} />
<PageDetailsHeaderExtraActions />
{isContentEditable && (
<Button
Expand Down
70 changes: 70 additions & 0 deletions web/core/components/pages/dropdowns/edit-information-popover.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
});
1 change: 1 addition & 0 deletions web/core/components/pages/dropdowns/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./edit-information-popover";
export * from "./quick-actions";
2 changes: 1 addition & 1 deletion web/core/components/pages/editor/header/extra-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
69 changes: 44 additions & 25 deletions web/core/components/pages/editor/header/info-popover.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,74 @@
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);
// popper-js
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>
Expand Down
13 changes: 13 additions & 0 deletions web/helpers/date-time.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Loading