[WIKI-320] refactor: page header actions (#6946)

* refactor: page header actions

* chore: update toolbar component

* chore: update archived and lock badge colors

* chore: added observer to favorite control
This commit is contained in:
Aaryan Khandelwal 2025-04-17 20:52:33 +05:30 committed by GitHub
parent 8166a757a7
commit eac1115566
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 557 additions and 290 deletions

View file

@ -161,7 +161,6 @@ const PageDetailsPage = observer(() => {
config={pageRootConfig}
handlers={pageRootHandlers}
page={page}
storeType={EPageStoreType.PROJECT}
webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>

View file

@ -8,10 +8,10 @@ import { ICustomSearchSelectOption } from "@plane/types";
import { Breadcrumbs, Header, CustomSearchSelect } from "@plane/ui";
// components
import { BreadcrumbLink, PageAccessIcon, SwitcherLabel } from "@/components/common";
import { PageEditInformationPopover } from "@/components/pages";
import { PageHeaderActions } from "@/components/pages/header/actions";
// helpers
// hooks
import { getPageName } from "@/helpers/page.helper";
// hooks
import { useProject } from "@/hooks/store";
// plane web components
import { useAppRouter } from "@/hooks/use-app-router";
@ -24,21 +24,22 @@ export interface IPagesHeaderProps {
showButton?: boolean;
}
const storeType = EPageStoreType.PROJECT;
export const PageDetailsHeader = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, pageId, projectId } = useParams();
// store hooks
const { currentProjectDetails, loader } = useProject();
const { getPageById, getCurrentProjectPageIds } = usePageStore(storeType);
const page = usePage({
pageId: pageId?.toString() ?? "",
storeType: EPageStoreType.PROJECT,
storeType,
});
const { getPageById, getCurrentProjectPageIds } = usePageStore(EPageStoreType.PROJECT);
// derived values
const projectPageIds = getCurrentProjectPageIds(projectId?.toString());
if (!page) return null;
const switcherOptions = projectPageIds
.map((id) => {
const _page = id === pageId ? page : getPageById(id);
@ -109,8 +110,8 @@ export const PageDetailsHeader = observer(() => {
</div>
</Header.LeftItem>
<Header.RightItem>
<PageEditInformationPopover page={page} />
<PageDetailsHeaderExtraActions page={page} />
<PageHeaderActions page={page} storeType={storeType} />
</Header.RightItem>
</Header>
);

View file

@ -1,6 +1,6 @@
"use client";
import React, { RefObject, useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { ChevronRight, CornerDownRight, LucideIcon, RefreshCcw, Sparkles, TriangleAlert } from "lucide-react";
// plane editor
import { EditorRefApi } from "@plane/editor";
@ -18,7 +18,7 @@ import { AskPiMenu } from "./ask-pi-menu";
const aiService = new AIService();
type Props = {
editorRef: RefObject<EditorRefApi>;
editorRef: EditorRefApi | null;
isOpen: boolean;
onClose: () => void;
workspaceId: string;
@ -73,7 +73,7 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
};
// handle task click
const handleClick = async (key: AI_EDITOR_TASKS) => {
const selection = editorRef.current?.getSelectedText();
const selection = editorRef?.getSelectedText();
if (!selection || activeTask === key) return;
setActiveTask(key);
if (key === AI_EDITOR_TASKS.ASK_ANYTHING) return;
@ -86,7 +86,7 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
};
// handle re-generate response
const handleRegenerate = async () => {
const selection = editorRef.current?.getSelectedText();
const selection = editorRef?.getSelectedText();
if (!selection || !activeTask) return;
setIsRegenerating(true);
await handleGenerateResponse({
@ -104,7 +104,7 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
// handle re-generate response
const handleToneChange = async (key: string) => {
const selectedTone = TONES_LIST.find((t) => t.key === key);
const selection = editorRef.current?.getSelectedText();
const selection = editorRef?.getSelectedText();
if (!selectedTone || !selection || !activeTask) return;
setResponse(undefined);
setIsRegenerating(false);
@ -123,7 +123,7 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
// handle replace selected text with the response
const handleInsertText = (insertOnNextLine: boolean) => {
if (!response) return;
editorRef.current?.insertText(response, insertOnNextLine);
editorRef?.insertText(response, insertOnNextLine);
onClose();
};

View file

@ -0,0 +1,10 @@
"use client";
// store
import { TPageInstance } from "@/store/pages/base-page";
export type TPageCollaboratorsListProps = {
page: TPageInstance;
};
export const PageCollaboratorsList = ({}: TPageCollaboratorsListProps) => null;

View file

@ -0,0 +1,116 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { LockKeyhole, LockKeyholeOpen } from "lucide-react";
// plane imports
import { Tooltip } from "@plane/ui";
// hooks
import { usePageOperations } from "@/hooks/use-page-operations";
// store
import { TPageInstance } from "@/store/pages/base-page";
// Define our lock display states, renaming "icon-only" to "neutral"
type LockDisplayState = "neutral" | "locked" | "unlocked";
type Props = {
page: TPageInstance;
};
export const PageLockControl = observer(({ page }: Props) => {
// Initial state: if locked, then "locked", otherwise default to "neutral"
const [displayState, setDisplayState] = useState<LockDisplayState>(page.is_locked ? "locked" : "neutral");
// derived values
const { canCurrentUserLockPage, is_locked } = page;
// Ref for the transition timer
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Ref to store the previous value of isLocked for detecting transitions
const prevLockedRef = useRef(is_locked);
// page operations
const {
pageOperations: { toggleLock },
} = usePageOperations({
page,
});
// Cleanup any running timer on unmount
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
},
[]
);
// Update display state when isLocked changes
useEffect(() => {
// Clear any previous timer to avoid overlapping transitions
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
// Transition logic:
// If locked, ensure the display state is "locked"
// If unlocked after being locked, show "unlocked" briefly then revert to "neutral"
if (is_locked) {
setDisplayState("locked");
} else if (prevLockedRef.current === true) {
setDisplayState("unlocked");
timerRef.current = setTimeout(() => {
setDisplayState("neutral");
timerRef.current = null;
}, 600);
} else {
setDisplayState("neutral");
}
// Update the previous locked state
prevLockedRef.current = is_locked;
}, [is_locked]);
if (!canCurrentUserLockPage) return null;
// Render different UI based on the current display state
return (
<>
{displayState === "neutral" && (
<Tooltip tooltipContent="Lock" position="bottom">
<button
type="button"
onClick={toggleLock}
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"
aria-label="Lock"
>
<LockKeyhole className="size-3.5" />
</button>
</Tooltip>
)}
{displayState === "locked" && (
<button
type="button"
onClick={toggleLock}
className="h-6 flex items-center gap-1 px-2 rounded text-custom-primary-100 bg-custom-primary-100/20 hover:bg-custom-primary-100/30 transition-colors"
aria-label="Locked"
>
<LockKeyhole className="flex-shrink-0 size-3.5 animate-lock-icon" />
<span className="text-xs font-medium whitespace-nowrap overflow-hidden transition-all duration-500 ease-out animate-text-slide-in">
Locked
</span>
</button>
)}
{displayState === "unlocked" && (
<div
className="h-6 flex items-center gap-1 px-2 rounded text-custom-text-200 animate-fade-out"
aria-label="Unlocked"
>
<LockKeyholeOpen className="flex-shrink-0 size-3.5 animate-unlock-icon" />
<span className="text-xs font-medium whitespace-nowrap overflow-hidden transition-all duration-500 ease-out animate-text-slide-in animate-text-fade-out">
Unlocked
</span>
</div>
)}
</>
);
});

View file

@ -0,0 +1,10 @@
"use client";
// store
import { TPageInstance } from "@/store/pages/base-page";
export type TPageMoveControlProps = {
page: TPageInstance;
};
export const PageMoveControl = ({}: TPageMoveControlProps) => null;

View file

@ -1,19 +0,0 @@
import { observer } from "mobx-react";
// helpers
import { calculateTimeAgoShort } from "@/helpers/date-time.helper";
// store types
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
page: TPageInstance;
};
export const PageEditInformationPopover: React.FC<Props> = observer((props) => {
const { page } = props;
return (
<div className="flex-shrink-0 whitespace-nowrap">
<span className="text-sm text-custom-text-300">Edited {calculateTimeAgoShort(page.updated_at ?? "")} ago</span>
</div>
);
});

View file

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

View file

@ -43,10 +43,10 @@ export type TEditorBodyHandlers = {
type Props = {
config: TEditorBodyConfig;
editorRef: React.RefObject<EditorRefApi>;
editorReady: boolean;
editorForwardRef: React.RefObject<EditorRefApi>;
handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
handleEditorReady: Dispatch<SetStateAction<boolean>>;
handleEditorReady: (status: boolean) => void;
handlers: TEditorBodyHandlers;
page: TPageInstance;
webhookConnectionParams: TWebhookConnectionQueryParams;
@ -56,7 +56,7 @@ type Props = {
export const PageEditorBody: React.FC<Props> = observer((props) => {
const {
config,
editorRef,
editorForwardRef,
handleConnectionStatus,
handleEditorReady,
handlers,
@ -70,7 +70,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
const { getUserDetails } = useMember();
// derived values
const { id: pageId, name: pageTitle, isContentEditable, updateTitle } = page;
const { id: pageId, name: pageTitle, isContentEditable, updateTitle, editorRef } = page;
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
// issue-embed
const { issueEmbedProps } = useIssueEmbed({
@ -172,10 +172,10 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
<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?.current} showOutline />
<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?.current} />
<PageContentBrowser editorRef={editorRef} />
</div>
</div>
</div>
@ -196,7 +196,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
id={pageId}
fileHandler={config.fileHandler}
handleEditorReady={handleEditorReady}
ref={editorRef}
ref={editorForwardRef}
containerClassName="h-full p-0 pb-64"
displayConfig={displayConfig}
mentionHandler={{

View file

@ -23,7 +23,7 @@ export const PageEditorHeaderLogoPicker: React.FC<Props> = observer((props) => {
return (
<div
className={cn(className, "max-h-0 pointer-events-none transition-all ease-linear duration-200", {
className={cn(className, "max-h-0 pointer-events-none transition-all ease-linear duration-300", {
"max-h-[56px] pointer-events-auto": isLogoSelected,
})}
>

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// editor
@ -18,8 +18,6 @@ import {
import { useAppRouter } from "@/hooks/use-app-router";
import { usePageFallback } from "@/hooks/use-page-fallback";
import { useQueryParams } from "@/hooks/use-query-params";
// plane web hooks
import { EPageStoreType } from "@/plane-web/hooks/store";
// store
import { TPageInstance } from "@/store/pages/base-page";
@ -38,13 +36,12 @@ type TPageRootProps = {
config: TPageRootConfig;
handlers: TPageRootHandlers;
page: TPageInstance;
storeType: EPageStoreType;
webhookConnectionParams: TWebhookConnectionQueryParams;
workspaceSlug: string;
};
export const PageRoot = observer((props: TPageRootProps) => {
const { config, handlers, page, storeType, webhookConnectionParams, workspaceSlug } = props;
const { config, handlers, page, webhookConnectionParams, workspaceSlug } = props;
// states
const [editorReady, setEditorReady] = useState(false);
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
@ -56,7 +53,7 @@ export const PageRoot = observer((props: TPageRootProps) => {
// search params
const searchParams = useSearchParams();
// derived values
const { isContentEditable } = page;
const { isContentEditable, setEditorRef } = page;
// page fallback
usePageFallback({
editorRef,
@ -67,6 +64,16 @@ export const PageRoot = observer((props: TPageRootProps) => {
// update query params
const { updateQueryParams } = useQueryParams();
const handleEditorReady = useCallback(
(status: boolean) => {
setEditorReady(status);
if (editorRef.current && !page.editorRef) {
setEditorRef(editorRef.current);
}
},
[page.editorRef, setEditorRef]
);
const version = searchParams.get("version");
useEffect(() => {
if (!version) {
@ -89,6 +96,14 @@ export const PageRoot = observer((props: TPageRootProps) => {
};
const currentVersionDescription = editorRef.current?.getDocument().html;
// reset editor ref on unmount
useEffect(
() => () => {
setEditorRef(null);
},
[setEditorRef]
);
return (
<>
<PageVersionsOverlay
@ -103,13 +118,13 @@ export const PageRoot = observer((props: TPageRootProps) => {
pageId={page.id ?? ""}
restoreEnabled={isContentEditable}
/>
<PageEditorToolbarRoot editorReady={editorReady} editorRef={editorRef} page={page} storeType={storeType} />
<PageEditorToolbarRoot page={page} />
<PageEditorBody
config={config}
editorReady={editorReady}
editorRef={editorRef}
editorForwardRef={editorRef}
handleConnectionStatus={setHasConnectionFailed}
handleEditorReady={setEditorReady}
handleEditorReady={handleEditorReady}
handlers={handlers}
page={page}
webhookConnectionParams={webhookConnectionParams}

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
// plane editor
import { EditorRefApi, IMarking } from "@plane/editor";
// components
@ -24,10 +24,13 @@ export const PageContentBrowser: React.FC<Props> = (props) => {
};
}, [editorRef]);
const handleOnClick = (marking: IMarking) => {
editorRef?.scrollSummary(marking);
if (setSidePeekVisible) setSidePeekVisible(false);
};
const handleOnClick = useCallback(
(marking: IMarking) => {
editorRef?.scrollSummary(marking);
setSidePeekVisible?.(false);
},
[editorRef, setSidePeekVisible]
);
const HeadingComponent: {
[key: number]: React.FC<{ marking: IMarking; onClick: () => void }>;

View file

@ -13,7 +13,7 @@ import { getPageName } from "@/helpers/page.helper";
import { usePageFilters } from "@/hooks/use-page-filters";
type Props = {
editorRef: React.RefObject<EditorRefApi>;
editorRef: EditorRefApi | null;
readOnly: boolean;
title: string | undefined;
updateTitle: (title: string) => void;
@ -53,7 +53,7 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
editorRef.current?.setFocusAtPosition(0);
editorRef?.setFocusAtPosition(0);
}
}}
value={title}

View file

@ -1,103 +0,0 @@
"use client";
import { observer } from "mobx-react";
// constants
import { IS_FAVORITE_MENU_OPEN } from "@plane/constants";
// editor
import { EditorRefApi } from "@plane/editor";
// plane hooks
import { useLocalStorage } from "@plane/hooks";
// ui
import { ArchiveIcon, FavoriteStar, setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
// components
import { LockedComponent } from "@/components/icons/locked-component";
import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import useOnlineStatus from "@/hooks/use-online-status";
// plane web hooks
import { EPageStoreType } from "@/plane-web/hooks/store";
// store
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
editorRef: EditorRefApi;
page: TPageInstance;
storeType: EPageStoreType;
};
export const PageExtraOptions: React.FC<Props> = observer((props) => {
const { editorRef, page, storeType } = props;
// derived values
const {
archived_at,
isContentEditable,
is_favorite,
is_locked,
canCurrentUserFavoritePage,
addToFavorites,
removePageFromFavorites,
} = page;
// use online status
const { isOnline } = useOnlineStatus();
// local storage
const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage<boolean>(
IS_FAVORITE_MENU_OPEN,
false
);
// favorite handler
const handleFavorite = () => {
if (is_favorite) {
removePageFromFavorites().then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page removed from favorites.",
})
);
} else {
addToFavorites().then(() => {
if (!isFavoriteMenuOpen) toggleFavoriteMenu(true);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page added to favorites.",
});
});
}
};
return (
<div className="flex items-center gap-3">
{is_locked && <LockedComponent />}
{archived_at && (
<div className="flex-shrink-0 flex h-7 items-center gap-2 rounded-full bg-blue-500/20 px-3 py-0.5 text-xs font-medium text-blue-500">
<ArchiveIcon className="flex-shrink-0 size-3" />
<span>Archived at {renderFormattedDate(archived_at)}</span>
</div>
)}
{isContentEditable && !isOnline && (
<Tooltip
tooltipHeading="You are offline."
tooltipContent="You can continue making changes. They will be synced when you are back online."
>
<div className="flex-shrink-0 flex h-7 items-center gap-2 rounded-full bg-custom-background-80 px-3 py-0.5 text-xs font-medium text-custom-text-300">
<span className="flex-shrink-0 size-1.5 rounded-full bg-custom-text-300" />
<span>Offline</span>
</div>
</Tooltip>
)}
{canCurrentUserFavoritePage && (
<FavoriteStar
selected={is_favorite}
onClick={handleFavorite}
buttonClassName="flex-shrink-0"
iconClassName="text-custom-text-100"
/>
)}
<PageInfoPopover editorRef={editorRef} page={page} />
<PageOptionsDropdown editorRef={editorRef} page={page} storeType={storeType} />
</div>
);
});

View file

@ -1,7 +1,5 @@
export * from "./color-dropdown";
export * from "./extra-options";
export * from "./info-popover";
export * from "./options-dropdown";
export * from "./root";
export * from "./mobile-root";
export * from "./toolbar";

View file

@ -1,28 +1,25 @@
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 editor
import { EditorRefApi } from "@plane/editor";
// plane ui
// plane imports
import { Avatar } from "@plane/ui";
// plane utils
import { getFileURL, renderFormattedDate } from "@plane/utils";
// helpers
import { getReadTimeFromWordsCount } from "@/helpers/date-time.helper";
import { calculateTimeAgoShort, getReadTimeFromWordsCount } from "@/helpers/date-time.helper";
// hooks
import { useMember } from "@/hooks/store";
// store types
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
editorRef: EditorRefApi | null;
page: TPageInstance;
};
export const PageInfoPopover: React.FC<Props> = (props) => {
const { editorRef, page } = props;
export const PageInfoPopover: React.FC<Props> = observer((props) => {
const { page } = props;
// states
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
// refs
@ -40,7 +37,7 @@ export const PageInfoPopover: React.FC<Props> = (props) => {
const editorInformation = page.updated_by ? getUserDetails(page.updated_by) : undefined;
const creatorInformation = page.created_by ? getUserDetails(page.created_by) : undefined;
const documentsInfo = editorRef?.getDocumentInfo() || { words: 0, characters: 0, paragraphs: 0 };
const documentsInfo = page.editorRef?.getDocumentInfo() || { words: 0, characters: 0, paragraphs: 0 };
const secondsToReadableTime = () => {
const wordsCount = documentsInfo.words;
@ -72,8 +69,16 @@ export const PageInfoPopover: React.FC<Props> = (props) => {
];
return (
<div onMouseEnter={() => setIsPopoverOpen(true)} onMouseLeave={() => setIsPopoverOpen(false)}>
<button type="button" ref={setReferenceElement} className="block">
<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 && (
@ -106,7 +111,7 @@ export const PageInfoPopover: React.FC<Props> = (props) => {
/>
<span>
{editorInformation?.display_name}{" "}
<span className="text-custom-text-300">{renderFormattedDate(page.updated_at)}</span>
<span className="text-custom-text-300">{calculateTimeAgoShort(page.updated_at ?? "")} ago</span>
</span>
</Link>
</div>
@ -133,4 +138,4 @@ export const PageInfoPopover: React.FC<Props> = (props) => {
)}
</div>
);
};
});

View file

@ -1,37 +0,0 @@
import { observer } from "mobx-react";
// plane imports
import { EditorRefApi } from "@plane/editor";
import { Header, EHeaderVariant } from "@plane/ui";
// components
import { PageExtraOptions, PageToolbar } from "@/components/pages";
// hooks
import { usePageFilters } from "@/hooks/use-page-filters";
// plane web hooks
import { EPageStoreType } from "@/plane-web/hooks/store";
// store
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
editorRef: EditorRefApi;
page: TPageInstance;
storeType: EPageStoreType;
};
export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
const { editorRef, page, storeType } = props;
// derived values
const { isContentEditable } = page;
// page filters
const { isStickyToolbarEnabled } = usePageFilters();
return (
<>
<Header variant={EHeaderVariant.SECONDARY}>
<PageExtraOptions editorRef={editorRef} page={page} storeType={storeType} />
</Header>
<Header variant={EHeaderVariant.TERNARY}>
{isContentEditable && editorRef && <PageToolbar editorRef={editorRef} isHidden={!isStickyToolbarEnabled} />}
</Header>
</>
);
});

View file

@ -4,9 +4,7 @@ import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import { ArrowUpToLine, Clipboard, History } from "lucide-react";
// document editor
import { EditorRefApi } from "@plane/editor";
// ui
// plane imports
import { TContextMenuItem, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
import { ExportPageModal, PageActions, TPageActions } from "@/components/pages";
@ -21,19 +19,18 @@ import { EPageStoreType } from "@/plane-web/hooks/store";
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
editorRef: EditorRefApi | null;
page: TPageInstance;
storeType: EPageStoreType;
};
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
const { editorRef, page, storeType } = props;
const { page, storeType } = props;
// states
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
// router
const router = useRouter();
// store values
const { name, isContentEditable } = page;
const { name, isContentEditable, editorRef } = page;
// page filters
const { isFullWidth, handleFullWidth, isStickyToolbarEnabled, handleStickyToolbar } = usePageFilters();
// update query params
@ -127,10 +124,7 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
optionsOrder={[
"full-screen",
"sticky-toolbar",
"copy-link",
"make-a-copy",
"move",
"toggle-lock",
"toggle-access",
"archive-restore",
"delete",

View file

@ -1,58 +1,48 @@
import { observer } from "mobx-react";
import { EditorRefApi } from "@plane/editor";
// components
import { PageEditorMobileHeaderRoot, PageExtraOptions, PageToolbar } from "@/components/pages";
import { PageToolbar } from "@/components/pages";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { usePageFilters } from "@/hooks/use-page-filters";
// plane web hooks
import { EPageStoreType } from "@/plane-web/hooks/store";
// plane web components
import { PageCollaboratorsList } from "@/plane-web/components/pages/header/collaborators-list";
// store
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
editorReady: boolean;
editorRef: React.RefObject<EditorRefApi>;
page: TPageInstance;
storeType: EPageStoreType;
};
export const PageEditorToolbarRoot: React.FC<Props> = observer((props) => {
const { editorReady, editorRef, page, storeType } = props;
const { page } = props;
// derived values
const { isContentEditable } = page;
const { isContentEditable, editorRef } = page;
// page filters
const { isFullWidth, isStickyToolbarEnabled } = usePageFilters();
// derived values
const resolvedEditorRef = editorRef.current;
const shouldHideToolbar = !isStickyToolbarEnabled || !isContentEditable;
if (!resolvedEditorRef) return null;
return (
<div id="page-toolbar-container">
<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 border-b border-transparent transition-all duration-200 ease-in-out",
"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,
"border-custom-border-200": true,
}
)}
>
<div className="max-w-full w-full flex items-center justify-between">
<div>
{editorReady && resolvedEditorRef && (
<PageToolbar editorRef={resolvedEditorRef} isHidden={shouldHideToolbar} />
)}
</div>
<PageExtraOptions editorRef={resolvedEditorRef} page={page} storeType={storeType} />
{editorRef && <PageToolbar editorRef={editorRef} />}
<PageCollaboratorsList page={page} />
</div>
</div>
<div className="md:hidden">
<PageEditorMobileHeaderRoot editorRef={resolvedEditorRef} page={page} storeType={storeType} />
</div>
</div>
);
});

View file

@ -15,7 +15,6 @@ import { cn } from "@/helpers/common.helper";
type Props = {
editorRef: EditorRefApi;
isHidden: boolean;
};
type ToolbarButtonProps = {
@ -65,7 +64,7 @@ ToolbarButton.displayName = "ToolbarButton";
const toolbarItems = TOOLBAR_ITEMS.document;
export const PageToolbar: React.FC<Props> = (props) => {
const { editorRef, isHidden } = props;
const { editorRef } = props;
// states
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
@ -98,14 +97,7 @@ export const PageToolbar: React.FC<Props> = (props) => {
);
return (
<div
className={cn(
"flex flex-wrap items-center divide-x divide-custom-border-200 opacity-100 transition-opacity duration-200 ease-in-out",
{
"opacity-0 pointer-events-none": isHidden,
}
)}
>
<div className="flex items-center divide-x divide-custom-border-200 overflow-x-scroll">
<CustomMenu
customButton={
<span className="text-custom-text-300 text-sm border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 h-7 w-24 rounded px-2 flex items-center justify-between gap-2 whitespace-nowrap text-left">
@ -139,20 +131,22 @@ export const PageToolbar: React.FC<Props> = (props) => {
</CustomMenu.MenuItem>
))}
</CustomMenu>
<ColorDropdown
handleColorSelect={(key, color) =>
editorRef.executeMenuItemCommand({
itemKey: key,
color,
})
}
isColorActive={(key, color) =>
editorRef.isMenuItemActive({
itemKey: key,
color,
})
}
/>
<div className="flex-shrink-0">
<ColorDropdown
handleColorSelect={(key, color) =>
editorRef.executeMenuItemCommand({
itemKey: key,
color,
})
}
isColorActive={(key, color) =>
editorRef.isMenuItemActive({
itemKey: key,
color,
})
}
/>
</div>
{Object.keys(toolbarItems).map((key) => (
<div key={key} className="flex items-center gap-0.5 px-2 first:pl-0 last:pr-0">
{toolbarItems[key].map((item) => (

View file

@ -0,0 +1,37 @@
"use client";
import { observer } from "mobx-react";
// components
import { PageInfoPopover, 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";
// plane web hooks
import { EPageStoreType } from "@/plane-web/hooks/store";
// store
import { TPageInstance } from "@/store/pages/base-page";
// local imports
import { PageArchivedBadge } from "./archived-badge";
import { PageCopyLinkControl } from "./copy-link-control";
import { PageOfflineBadge } from "./offline-badge";
type Props = {
page: TPageInstance;
storeType: EPageStoreType;
};
export const PageHeaderActions: React.FC<Props> = observer((props) => {
const { page, storeType } = props;
return (
<div className="flex items-center gap-1">
<PageArchivedBadge page={page} />
<PageOfflineBadge page={page} />
<PageLockControl page={page} />
<PageMoveControl page={page} />
<PageInfoPopover page={page} />
<PageCopyLinkControl page={page} />
<PageOptionsDropdown page={page} storeType={storeType} />
</div>
);
});

View file

@ -0,0 +1,21 @@
import { observer } from "mobx-react";
// plane imports
import { ArchiveIcon } from "@plane/ui";
import { renderFormattedDate } from "@plane/utils";
// store
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
page: TPageInstance;
};
export const PageArchivedBadge = observer(({ page }: Props) => {
if (!page.archived_at) return null;
return (
<div className="flex-shrink-0 h-6 flex items-center gap-1 px-2 rounded text-custom-primary-100 bg-custom-primary-100/20">
<ArchiveIcon className="flex-shrink-0 size-3.5" />
<span className="text-xs font-medium">Archived at {renderFormattedDate(page.archived_at)}</span>
</div>
);
});

View file

@ -0,0 +1,27 @@
import { observer } from "mobx-react";
import { Link } from "lucide-react";
// hooks
import { usePageOperations } from "@/hooks/use-page-operations";
// store
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
page: TPageInstance;
};
export const PageCopyLinkControl = observer(({ page }: Props) => {
// page operations
const { pageOperations } = usePageOperations({
page,
});
return (
<button
type="button"
onClick={pageOperations.copyLink}
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"
>
<Link className="size-3.5" />
</button>
);
});

View file

@ -0,0 +1,31 @@
import { observer } from "mobx-react";
// plane imports
import { FavoriteStar } from "@plane/ui";
// hooks
import { usePageOperations } from "@/hooks/use-page-operations";
// store
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
page: TPageInstance;
};
export const PageFavoriteControl = observer(({ page }: Props) => {
// derived values
const { is_favorite, canCurrentUserFavoritePage } = page;
// page operations
const { pageOperations } = usePageOperations({
page,
});
if (!canCurrentUserFavoritePage) return null;
return (
<FavoriteStar
selected={is_favorite}
onClick={pageOperations.toggleFavorite}
buttonClassName="flex-shrink-0 size-6 group rounded hover:bg-custom-background-80 transition-colors"
iconClassName="size-3.5 text-custom-text-200 group-hover:text-custom-text-10"
/>
);
});

View file

@ -0,0 +1,30 @@
import { observer } from "mobx-react";
// plane imports
import { Tooltip } from "@plane/ui";
// hooks
import useOnlineStatus from "@/hooks/use-online-status";
// store
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
page: TPageInstance;
};
export const PageOfflineBadge = observer(({ page }: Props) => {
// use online status
const { isOnline } = useOnlineStatus();
if (!page.isContentEditable || isOnline) return null;
return (
<Tooltip
tooltipHeading="You are offline."
tooltipContent="You can continue making changes. They will be synced when you are back online."
>
<div className="flex-shrink-0 flex h-7 items-center gap-2 rounded-full bg-custom-background-80 px-3 py-0.5 text-xs font-medium text-custom-text-300">
<span className="flex-shrink-0 size-1.5 rounded-full bg-custom-text-300" />
<span>Offline</span>
</div>
</Tooltip>
);
});

View file

@ -47,7 +47,7 @@ export const usePageOperations = (
// collaborative actions
const { executeCollaborativeAction } = useCollaborativePageActions(props);
// local storage
const { setValue: toggleFavoriteMenu, storedValue: isfavoriteMenuOpen } = useLocalStorage<boolean>(
const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage<boolean>(
IS_FAVORITE_MENU_OPEN,
false
);
@ -147,7 +147,7 @@ export const usePageOperations = (
);
} else {
addToFavorites().then(() => {
if (!isfavoriteMenuOpen) toggleFavoriteMenu(true);
if (!isFavoriteMenuOpen) toggleFavoriteMenu(true);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
@ -199,7 +199,9 @@ export const usePageOperations = (
getRedirectionLink,
is_favorite,
is_locked,
isFavoriteMenuOpen,
removePageFromFavorites,
toggleFavoriteMenu,
]);
return {
pageOperations,

View file

@ -2,6 +2,7 @@ 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";
@ -11,6 +12,7 @@ import { RootStore } from "@/plane-web/store/root.store";
export type TBasePage = TPage & {
// observables
isSubmitting: TNameDescriptionLoader;
editorRef: EditorRefApi | null;
// computed
asJSON: TPage | undefined;
isCurrentUserOwner: boolean;
@ -32,6 +34,8 @@ export type TBasePage = TPage & {
addToFavorites: () => Promise<void>;
removePageFromFavorites: () => Promise<void>;
duplicate: () => Promise<TPage | undefined>;
mutateProperties: (data: Partial<TPage>, shouldUpdateName?: boolean) => void;
setEditorRef: (editorRef: EditorRefApi | null) => void;
};
export type TBasePagePermissions = {
@ -68,6 +72,7 @@ export type TPageInstance = TBasePage &
export class BasePage implements TBasePage {
// loaders
isSubmitting: TNameDescriptionLoader = "saved";
editorRef: EditorRefApi | null = null;
// page properties
id: string | undefined;
name: string | undefined;
@ -125,6 +130,7 @@ export class BasePage implements TBasePage {
makeObservable(this, {
// loaders
isSubmitting: observable.ref,
editorRef: observable.ref,
// page properties
id: observable.ref,
name: observable.ref,
@ -165,6 +171,8 @@ export class BasePage implements TBasePage {
addToFavorites: action,
removePageFromFavorites: action,
duplicate: action,
mutateProperties: action,
setEditorRef: action,
});
this.rootStore = store;
@ -426,25 +434,34 @@ export class BasePage implements TBasePage {
};
updatePageLogo = async (value: TChangeHandlerProps) => {
let logoValue = {};
if (value?.type === "emoji")
logoValue = {
value: convertHexEmojiToDecimal(value.value.unified),
url: value.value.imageUrl,
const originalLogoProps = { ...this.logo_props };
try {
let logoValue = {};
if (value?.type === "emoji")
logoValue = {
value: convertHexEmojiToDecimal(value.value.unified),
url: value.value.imageUrl,
};
else if (value?.type === "icon") logoValue = value.value;
const logoProps: TLogoProps = {
in_use: value?.type,
[value?.type]: logoValue,
};
else if (value?.type === "icon") logoValue = value.value;
const logoProps: TLogoProps = {
in_use: value?.type,
[value?.type]: logoValue,
};
await this.services.update({
logo_props: logoProps,
});
runInAction(() => {
this.logo_props = logoProps;
});
runInAction(() => {
this.logo_props = logoProps;
});
await this.services.update({
logo_props: logoProps,
});
} catch (error) {
console.error("Error in updating page logo", error);
runInAction(() => {
this.logo_props = originalLogoProps as TLogoProps;
});
throw error;
}
};
/**
@ -498,4 +515,23 @@ export class BasePage implements TBasePage {
* @description duplicate the page
*/
duplicate = async () => await this.services.duplicate();
/**
* @description mutate multiple properties at once
* @param data Partial<TPage>
*/
mutateProperties = (data: Partial<TPage>, shouldUpdateName: boolean = true) => {
Object.keys(data).forEach((key) => {
const value = data[key as keyof TPage];
if (key === "name" && !shouldUpdateName) return;
set(this, key, value);
});
};
setEditorRef = (editorRef: EditorRefApi | null) => {
console.log("store editorRef", editorRef);
runInAction(() => {
this.editorRef = editorRef;
});
};
}

View file

@ -206,7 +206,19 @@ export class ProjectPageStore implements IProjectPageStore {
const pages = await this.service.fetchAll(workspaceSlug, projectId);
runInAction(() => {
for (const page of pages) if (page?.id) set(this.data, [page.id], new ProjectPage(this.store, page));
for (const page of pages) {
if (page?.id) {
const existingPage = this.getPageById(page.id);
if (existingPage) {
// If page already exists, update all fields except name
const { name, ...otherFields } = page;
existingPage.mutateProperties(otherFields, false);
} else {
// If new page, create a new instance with all data
set(this.data, [page.id], new ProjectPage(this.store, page));
}
}
}
this.loader = undefined;
});
@ -238,8 +250,16 @@ export class ProjectPageStore implements IProjectPageStore {
});
const page = await this.service.fetchById(workspaceSlug, projectId, pageId);
const pageInstance = page?.id ? this.getPageById(page.id) : undefined;
runInAction(() => {
if (page?.id) set(this.data, [page.id], new ProjectPage(this.store, page));
if (page?.id) {
if (pageInstance) {
pageInstance.mutateProperties(page, false);
} else {
set(this.data, [page.id], new ProjectPage(this.store, page));
}
}
this.loader = undefined;
});

View file

@ -854,3 +854,91 @@ div.web-view-spinner div.bar12 {
.epr-search-container > .epr-icn-search {
color: rgb(var(--color-text-400)) !important;
}
/* Lock icon animations */
@keyframes textSlideIn {
0% {
opacity: 0;
transform: translateX(-8px);
max-width: 0px;
}
40% {
opacity: 0.7;
max-width: 60px;
}
100% {
opacity: 1;
transform: translateX(0);
max-width: 60px;
}
}
@keyframes textFadeOut {
0% {
opacity: 1;
transform: translateX(0);
}
100% {
opacity: 0;
transform: translateX(8px);
}
}
@keyframes lockIconAnimation {
0% {
transform: rotate(-5deg) scale(1);
}
25% {
transform: rotate(0deg) scale(1.15);
}
50% {
transform: rotate(5deg) scale(1.08);
}
100% {
transform: rotate(0deg) scale(1);
}
}
@keyframes unlockIconAnimation {
0% {
transform: rotate(0deg) scale(1);
}
40% {
transform: rotate(-8deg) scale(1.15);
}
80% {
transform: rotate(3deg) scale(1.05);
}
100% {
transform: rotate(0deg) scale(1);
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.animate-text-slide-in {
animation: textSlideIn 400ms ease-out forwards;
}
.animate-text-fade-out {
animation: textFadeOut 600ms ease-in 300ms forwards;
}
.animate-lock-icon {
animation: lockIconAnimation 600ms ease-out forwards;
}
.animate-unlock-icon {
animation: unlockIconAnimation 600ms ease-out forwards;
}
.animate-fade-out {
animation: fadeOut 500ms ease-in 100ms forwards;
}