Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a47f709c5d |
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.24 on 2026-01-10 18:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0115_auto_20260105_1406'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='notification_view_mode',
|
||||
field=models.CharField(choices=[('full', 'Full'), ('compact', 'Compact')], default='full', max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-13 10:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0115_auto_20260105_1406'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='notification_view_mode',
|
||||
field=models.CharField(choices=[('full', 'Full'), ('compact', 'Compact')], default='full', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='is_password_reset_required',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspacemember',
|
||||
name='explored_features',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspacemember',
|
||||
name='getting_started_checklist',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspacemember',
|
||||
name='tips',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
]
|
||||
@@ -84,7 +84,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
is_staff = models.BooleanField(default=False)
|
||||
is_email_verified = models.BooleanField(default=False)
|
||||
is_password_autoset = models.BooleanField(default=False)
|
||||
is_password_reset_required = models.BooleanField(default=False)
|
||||
|
||||
# random token generated
|
||||
token = models.CharField(max_length=64, blank=True)
|
||||
|
||||
|
||||
@@ -214,9 +214,6 @@ class WorkspaceMember(BaseModel):
|
||||
default_props = models.JSONField(default=get_default_props)
|
||||
issue_props = models.JSONField(default=get_issue_props)
|
||||
is_active = models.BooleanField(default=True)
|
||||
getting_started_checklist = models.JSONField(default=dict)
|
||||
tips = models.JSONField(default=dict)
|
||||
explored_features = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "member", "deleted_at"]
|
||||
|
||||
@@ -98,7 +98,7 @@ export const AuthRoot = observer(function AuthRoot() {
|
||||
}
|
||||
|
||||
if (currentAuthMode === EAuthModes.SIGN_IN) {
|
||||
if (isSMTPConfigured && isMagicLoginEnabled && response.status === "MAGIC_CODE") {
|
||||
if (response.is_password_autoset && isSMTPConfigured && isMagicLoginEnabled) {
|
||||
setAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
generateEmailUniqueCode(data.email);
|
||||
} else if (isEmailPasswordEnabled) {
|
||||
@@ -109,7 +109,7 @@ export const AuthRoot = observer(function AuthRoot() {
|
||||
setErrorInfo(errorhandler);
|
||||
}
|
||||
} else {
|
||||
if (isSMTPConfigured && isMagicLoginEnabled && response.status === "MAGIC_CODE") {
|
||||
if (isSMTPConfigured && isMagicLoginEnabled) {
|
||||
setAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
generateEmailUniqueCode(data.email);
|
||||
} else if (isEmailPasswordEnabled) {
|
||||
@@ -119,7 +119,6 @@ export const AuthRoot = observer(function AuthRoot() {
|
||||
setErrorInfo(errorhandler);
|
||||
}
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined);
|
||||
|
||||
@@ -20,7 +20,7 @@ type Props = {
|
||||
|
||||
const PEEK_MODES: {
|
||||
key: IPeekMode;
|
||||
icon: any;
|
||||
icon: React.FC<React.SVGProps<SVGSVGElement>>;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ key: "side", icon: SidePanelIcon, label: "Side Peek" },
|
||||
|
||||
@@ -2,8 +2,8 @@ import { set } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
// plane imports
|
||||
import { UserService } from "@plane/services";
|
||||
import { NOTIFICATION_VIEW_MODES, EStartOfTheWeek } from "@plane/types";
|
||||
import type { TUserProfile } from "@plane/types";
|
||||
import { EStartOfTheWeek } from "@plane/types";
|
||||
// store
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
@@ -44,6 +44,7 @@ export class ProfileStore implements IProfileStore {
|
||||
},
|
||||
is_onboarded: false,
|
||||
is_tour_completed: false,
|
||||
notification_view_mode: NOTIFICATION_VIEW_MODES[0].key,
|
||||
use_case: undefined,
|
||||
billing_address_country: undefined,
|
||||
billing_address: undefined,
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface IEmailCheckData {
|
||||
}
|
||||
|
||||
export interface IEmailCheckResponse {
|
||||
status: "MAGIC_CODE" | "CREDENTIAL";
|
||||
is_password_autoset: boolean;
|
||||
existing: boolean;
|
||||
}
|
||||
|
||||
@@ -1,43 +1,24 @@
|
||||
// components
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { cn } from "@plane/utils";
|
||||
import { TopNavPowerK } from "@/components/navigation";
|
||||
import { HelpMenuRoot } from "@/components/workspace/sidebar/help-section/root";
|
||||
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
|
||||
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
|
||||
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||
import { InboxIcon } from "@plane/propel/icons";
|
||||
import useSWR from "swr";
|
||||
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
|
||||
import { cn } from "@plane/utils";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// local imports
|
||||
import { StarUsOnGitHubLink } from "@/app/(all)/[workspaceSlug]/(projects)/star-us-link";
|
||||
import { NotificationsPopoverRoot } from "@/components/notifications/popover/root";
|
||||
|
||||
export const TopNavigationRoot = observer(function TopNavigationRoot() {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
// store hooks
|
||||
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
|
||||
const { preferences } = useAppRailPreferences();
|
||||
|
||||
const showLabel = preferences.displayMode === "icon_with_label";
|
||||
|
||||
// Fetch notification count
|
||||
useSWR(
|
||||
workspaceSlug ? "WORKSPACE_UNREAD_NOTIFICATION_COUNT" : null,
|
||||
workspaceSlug ? () => getUnreadNotificationsCount(workspaceSlug.toString()) : null
|
||||
);
|
||||
|
||||
// Calculate notification count
|
||||
const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0;
|
||||
const totalNotifications = isMentionsEnabled
|
||||
? unreadNotificationsCount.mention_unread_notifications_count
|
||||
: unreadNotificationsCount.total_unread_notifications_count;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center min-h-10 w-full px-3.5 bg-canvas z-[27] transition-all duration-300", {
|
||||
@@ -54,23 +35,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
|
||||
</div>
|
||||
{/* Additional Actions */}
|
||||
<div className="shrink-0 flex-1 flex gap-1 items-center justify-end">
|
||||
<Tooltip tooltipContent="Inbox" position="bottom">
|
||||
<AppSidebarItem
|
||||
variant="link"
|
||||
item={{
|
||||
href: `/${workspaceSlug?.toString()}/notifications/`,
|
||||
icon: (
|
||||
<div className="relative">
|
||||
<InboxIcon className="size-5" />
|
||||
{totalNotifications > 0 && (
|
||||
<span className="absolute top-0 right-0 size-2 rounded-full bg-danger-primary" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
isActive: pathname?.includes("/notifications/"),
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<NotificationsPopoverRoot workspaceSlug={workspaceSlug?.toString()} />
|
||||
<HelpMenuRoot />
|
||||
<StarUsOnGitHubLink />
|
||||
<div className="flex items-center justify-center size-8 hover:bg-layer-1-hover rounded-md">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NotificationCardListRoot } from "./notification-card/root";
|
||||
export type TNotificationListRoot = {
|
||||
workspaceSlug: string;
|
||||
workspaceId: string;
|
||||
onNotificationClick?: () => void;
|
||||
};
|
||||
|
||||
export function NotificationListRoot(props: TNotificationListRoot) {
|
||||
|
||||
@@ -11,10 +11,11 @@ import { useWorkspaceNotifications } from "@/hooks/store/notifications";
|
||||
type TNotificationCardListRoot = {
|
||||
workspaceSlug: string;
|
||||
workspaceId: string;
|
||||
onNotificationClick?: () => void;
|
||||
};
|
||||
|
||||
export const NotificationCardListRoot = observer(function NotificationCardListRoot(props: TNotificationCardListRoot) {
|
||||
const { workspaceSlug, workspaceId } = props;
|
||||
const { workspaceSlug, workspaceId, onNotificationClick } = props;
|
||||
// hooks
|
||||
const { loader, paginationInfo, getNotifications, notificationIdsByWorkspaceId } = useWorkspaceNotifications();
|
||||
const notificationIds = notificationIdsByWorkspaceId(workspaceId);
|
||||
@@ -32,7 +33,12 @@ export const NotificationCardListRoot = observer(function NotificationCardListRo
|
||||
return (
|
||||
<div>
|
||||
{notificationIds.map((notificationId: string) => (
|
||||
<NotificationItem key={notificationId} workspaceSlug={workspaceSlug} notificationId={notificationId} />
|
||||
<NotificationItem
|
||||
key={notificationId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
notificationId={notificationId}
|
||||
onNotificationClick={onNotificationClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* fetch next page notifications */}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
*
|
||||
* Licensed under the Plane Commercial License (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* https://plane.so/legals/eula
|
||||
*
|
||||
* DO NOT remove or modify this notice.
|
||||
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
*/
|
||||
|
||||
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
|
||||
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||
import { NotificationsSidebarRoot } from "@/components/workspace-notifications/sidebar";
|
||||
import { Popover } from "@plane/propel/popover";
|
||||
|
||||
type NotificationsPopoverRootProps = {
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export function NotificationsPopoverRoot({ workspaceSlug }: NotificationsPopoverRootProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { unreadNotificationsCount, viewMode } = useWorkspaceNotifications();
|
||||
|
||||
const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0;
|
||||
const totalNotifications = isMentionsEnabled
|
||||
? unreadNotificationsCount.mention_unread_notifications_count
|
||||
: unreadNotificationsCount.total_unread_notifications_count;
|
||||
const isNotificationsPath = pathname.includes(`/${workspaceSlug}/notifications/`);
|
||||
const shouldPopoverBeOpen = viewMode === "compact" && !isNotificationsPath && isOpen;
|
||||
|
||||
const handleSidebarClick = () => {
|
||||
if (viewMode === "full") {
|
||||
setIsOpen(false);
|
||||
router.push(`/${workspaceSlug}/notifications/`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePopoverChange = (open: boolean) => {
|
||||
if (!isNotificationsPath) {
|
||||
setIsOpen(open);
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={shouldPopoverBeOpen} onOpenChange={handlePopoverChange}>
|
||||
<Popover.Button>
|
||||
<AppSidebarItem
|
||||
variant={"button"}
|
||||
item={{
|
||||
icon: (
|
||||
<div className="relative">
|
||||
<InboxIcon className="size-5" />
|
||||
{totalNotifications > 0 && (
|
||||
<span className="absolute top-0 right-0 size-2 rounded-full bg-danger-primary" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
isActive: isOpen,
|
||||
onClick: handleSidebarClick,
|
||||
}}
|
||||
/>
|
||||
</Popover.Button>
|
||||
<Popover.Panel side="bottom" align="start" positionerClassName={"z-30"} className={"h-[477px] w-[530px]"}>
|
||||
<NotificationsSidebarRoot
|
||||
viewMode="compact"
|
||||
onFullViewMode={() => setIsOpen(false)}
|
||||
onNotificationClick={() => setIsOpen(false)}
|
||||
onModeChange={(mode) => {
|
||||
if (mode === "full" && isOpen) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export const NotificationFilter = observer(function NotificationFilter() {
|
||||
data={translatedFilterTypeOptions}
|
||||
button={
|
||||
<Tooltip tooltipContent={t("notification.options.filters")} isMobile={isMobile} position="bottom">
|
||||
<IconButton size="base" variant="ghost" icon={ListFilter} />
|
||||
<IconButton size="base" variant="secondary" icon={ListFilter} />
|
||||
</Tooltip>
|
||||
}
|
||||
keyExtractor={(item: { label: string; value: ENotificationFilterType }) => item.value}
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ export const NotificationHeaderMenuOption = observer(function NotificationHeader
|
||||
return (
|
||||
<PopoverMenu
|
||||
data={popoverMenuOptions}
|
||||
button={<IconButton size="base" variant="ghost" icon={MoreVertical} />}
|
||||
button={<IconButton size="base" variant="secondary" icon={MoreVertical} />}
|
||||
keyExtractor={(item: TPopoverMenuOptions) => item.key}
|
||||
panelClassName="p-0 py-2 rounded-md border border-subtle bg-surface-1 space-y-1"
|
||||
render={(item: TPopoverMenuOptions) => <NotificationMenuOptionItem {...item} />}
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CheckCheck, RefreshCw } from "lucide-react";
|
||||
// plane imports
|
||||
import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// hooks
|
||||
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local imports
|
||||
import { NotificationFilter } from "../../filters/menu";
|
||||
import { NotificationHeaderMenuOption } from "./menu-option";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
|
||||
type TNotificationSidebarHeaderOptions = {
|
||||
workspaceSlug: string;
|
||||
@@ -20,56 +10,8 @@ type TNotificationSidebarHeaderOptions = {
|
||||
export const NotificationSidebarHeaderOptions = observer(function NotificationSidebarHeaderOptions(
|
||||
props: TNotificationSidebarHeaderOptions
|
||||
) {
|
||||
const { workspaceSlug } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { loader, getNotifications, markAllNotificationsAsRead } = useWorkspaceNotifications();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const refreshNotifications = async () => {
|
||||
if (loader) return;
|
||||
try {
|
||||
await getNotifications(workspaceSlug, ENotificationLoader.MUTATION_LOADER, ENotificationQueryParamType.CURRENT);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllNotificationsAsRead = async () => {
|
||||
// NOTE: We are using loader to prevent continues request when we are making all the notification to read
|
||||
if (loader) return;
|
||||
try {
|
||||
await markAllNotificationsAsRead(workspaceSlug);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex justify-center items-center gap-2 text-body-xs-medium">
|
||||
{/* mark all notifications as read*/}
|
||||
<Tooltip tooltipContent={t("notification.options.mark_all_as_read")} isMobile={isMobile} position="bottom">
|
||||
<IconButton
|
||||
size="base"
|
||||
variant="ghost"
|
||||
icon={loader === ENotificationLoader.MARK_ALL_AS_READY ? Spinner : CheckCheck}
|
||||
onClick={() => {
|
||||
handleMarkAllNotificationsAsRead();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* refetch current notifications */}
|
||||
<Tooltip tooltipContent={t("notification.options.refresh")} isMobile={isMobile} position="bottom">
|
||||
<IconButton
|
||||
size="base"
|
||||
variant="ghost"
|
||||
icon={RefreshCw}
|
||||
className={loader === ENotificationLoader.MUTATION_LOADER ? "animate-spin" : ""}
|
||||
onClick={refreshNotifications}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* notification filters */}
|
||||
<NotificationFilter />
|
||||
|
||||
|
||||
@@ -16,10 +16,11 @@ import { NotificationOption } from "./options";
|
||||
type TNotificationItem = {
|
||||
workspaceSlug: string;
|
||||
notificationId: string;
|
||||
onNotificationClick?: () => void;
|
||||
};
|
||||
|
||||
export const NotificationItem = observer(function NotificationItem(props: TNotificationItem) {
|
||||
const { workspaceSlug, notificationId } = props;
|
||||
const { workspaceSlug, notificationId, onNotificationClick } = props;
|
||||
// hooks
|
||||
const { currentSelectedNotificationId, setCurrentSelectedNotificationId } = useWorkspaceNotifications();
|
||||
const { asJson: notification, markNotificationAsRead } = useNotification(notificationId);
|
||||
@@ -39,6 +40,8 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
|
||||
|
||||
const handleNotificationIssuePeekOverview = async () => {
|
||||
if (workspaceSlug && projectId && issueId && !isSnoozeStateModalOpen && !customSnoozeModal) {
|
||||
onNotificationClick?.();
|
||||
|
||||
setPeekIssue(undefined);
|
||||
setCurrentSelectedNotificationId(notificationId);
|
||||
|
||||
|
||||
@@ -1,115 +1,198 @@
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
// plane imports
|
||||
import type { TNotificationTab } from "@plane/constants";
|
||||
import { NOTIFICATION_TABS } from "@plane/constants";
|
||||
import { ENotificationLoader, ENotificationQueryParamType, NOTIFICATION_TABS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Header, Row, ERowVariant, EHeaderVariant, ContentWrapper } from "@plane/ui";
|
||||
import { ContentWrapper, ERowVariant, Spinner, Tooltip } from "@plane/ui";
|
||||
import { cn, getNumberCount } from "@plane/utils";
|
||||
import type { TNotificationsViewMode } from "@plane/types";
|
||||
// components
|
||||
import { CountChip } from "@/components/common/count-chip";
|
||||
// hooks
|
||||
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
// plane web components
|
||||
import { NotificationListRoot } from "@/plane-web/components/workspace-notifications/list-root";
|
||||
// local imports
|
||||
|
||||
import { getIconButtonStyling, IconButton } from "@plane/propel/icon-button";
|
||||
import { Tabs } from "@plane/propel/tabs";
|
||||
import { CheckCheck, MoveDiagonal, RefreshCw } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { NotificationEmptyState } from "./empty-state";
|
||||
import { AppliedFilters } from "./filters/applied-filter";
|
||||
import { NotificationSidebarHeader } from "./header";
|
||||
import { NotificationSidebarHeaderOptions } from "./header/options";
|
||||
import { NotificationsLoader } from "./loader";
|
||||
import { ViewModeSelector } from "./view-mode-selector";
|
||||
import { usePlatformOS } from "@plane/hooks";
|
||||
|
||||
export const NotificationsSidebarRoot = observer(function NotificationsSidebarRoot() {
|
||||
type NotificationsSidebarRootProps = {
|
||||
viewMode?: TNotificationsViewMode;
|
||||
onFullViewMode?: () => void;
|
||||
onNotificationClick?: () => void;
|
||||
onModeChange?: (mode: TNotificationsViewMode) => void;
|
||||
};
|
||||
|
||||
const getSidebarWidthClass = (viewMode: TNotificationsViewMode, hasSelection: boolean) => {
|
||||
if (viewMode === "compact") {
|
||||
return "w-full md:w-full rounded-xl border border-subtle shadow-raised-200";
|
||||
}
|
||||
|
||||
return hasSelection ? "w-0 md:w-3/12" : "w-full md:w-3/12";
|
||||
};
|
||||
|
||||
export const NotificationsSidebarRoot = observer(function NotificationsSidebarRoot({
|
||||
viewMode = "full",
|
||||
onFullViewMode,
|
||||
onNotificationClick,
|
||||
onModeChange,
|
||||
}: NotificationsSidebarRootProps) {
|
||||
const { workspaceSlug } = useParams();
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
const notifications = useWorkspaceNotifications();
|
||||
const router = useRouter();
|
||||
if (!workspaceSlug) return null;
|
||||
|
||||
const workspace = getWorkspaceBySlug(workspaceSlug.toString());
|
||||
if (!workspace) return null;
|
||||
|
||||
const {
|
||||
currentSelectedNotificationId,
|
||||
unreadNotificationsCount,
|
||||
loader,
|
||||
notificationIdsByWorkspaceId,
|
||||
currentNotificationTab,
|
||||
setCurrentNotificationTab,
|
||||
} = useWorkspaceNotifications();
|
||||
setViewMode,
|
||||
viewMode: currentViewMode,
|
||||
loader,
|
||||
getNotifications,
|
||||
markAllNotificationsAsRead,
|
||||
} = notifications;
|
||||
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const workspace = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString()) : undefined;
|
||||
const notificationIds = workspace ? notificationIdsByWorkspaceId(workspace.id) : undefined;
|
||||
const notificationIds = notificationIdsByWorkspaceId(workspace.id);
|
||||
const sidebarWidthClass = getSidebarWidthClass(viewMode, Boolean(currentSelectedNotificationId));
|
||||
|
||||
const handleTabClick = useCallback(
|
||||
(tabValue: TNotificationTab) => {
|
||||
if (currentNotificationTab !== tabValue) {
|
||||
setCurrentNotificationTab(tabValue);
|
||||
}
|
||||
},
|
||||
[currentNotificationTab, setCurrentNotificationTab]
|
||||
);
|
||||
const refreshNotifications = async () => {
|
||||
if (loader) return;
|
||||
try {
|
||||
await getNotifications(workspaceSlug, ENotificationLoader.MUTATION_LOADER, ENotificationQueryParamType.CURRENT);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !workspace) return <></>;
|
||||
const handleMarkAllNotificationsAsRead = async () => {
|
||||
// NOTE: We are using loader to prevent continues request when we are making all the notification to read
|
||||
if (loader) return;
|
||||
try {
|
||||
await markAllNotificationsAsRead(workspaceSlug);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative border-0 md:border-r border-subtle z-[10] flex-shrink-0 bg-surface-1 h-full transition-all max-md:overflow-hidden",
|
||||
currentSelectedNotificationId ? "w-0 md:w-3/12" : "w-full md:w-3/12"
|
||||
"relative flex-shrink-0 bg-surface-1 h-full transition-all md:border-r border-subtle max-md:overflow-hidden",
|
||||
sidebarWidthClass
|
||||
)}
|
||||
>
|
||||
<div className="relative w-full h-full flex flex-col">
|
||||
<Row className="h-header border-b border-subtle flex flex-shrink-0">
|
||||
<NotificationSidebarHeader workspaceSlug={workspaceSlug.toString()} />
|
||||
</Row>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="px-3 py-2 border-b border-subtle flex items-center justify-between mb-2">
|
||||
<h4 className="text-18 font-medium">{t("notification.label")}</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip tooltipContent={t("notification.options.mark_all_as_read")} isMobile={isMobile} position="bottom">
|
||||
<IconButton
|
||||
size="base"
|
||||
variant="ghost"
|
||||
icon={loader === ENotificationLoader.MARK_ALL_AS_READY ? Spinner : CheckCheck}
|
||||
onClick={() => {
|
||||
handleMarkAllNotificationsAsRead();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Header variant={EHeaderVariant.SECONDARY} className="justify-start">
|
||||
{NOTIFICATION_TABS.map((tab) => (
|
||||
<div
|
||||
key={tab.value}
|
||||
className="h-full px-3 relative cursor-pointer"
|
||||
onClick={() => handleTabClick(tab.value)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-full flex justify-center items-center gap-1 text-body-xs-medium transition-all",
|
||||
{
|
||||
"text-accent-primary": currentNotificationTab === tab.value,
|
||||
"text-primary hover:text-secondary": currentNotificationTab !== tab.value,
|
||||
}
|
||||
)}
|
||||
{/* refetch current notifications */}
|
||||
<Tooltip tooltipContent={t("notification.options.refresh")} isMobile={isMobile} position="bottom">
|
||||
<IconButton
|
||||
size="base"
|
||||
variant="ghost"
|
||||
icon={RefreshCw}
|
||||
className={loader === ENotificationLoader.MUTATION_LOADER ? "animate-spin" : ""}
|
||||
onClick={refreshNotifications}
|
||||
/>
|
||||
</Tooltip>
|
||||
{viewMode === "compact" && (
|
||||
<Link
|
||||
to={`/${workspaceSlug}/notifications/`}
|
||||
className={getIconButtonStyling("ghost", "base")}
|
||||
onClick={onFullViewMode}
|
||||
>
|
||||
<div className="font-medium">{t(tab.i18n_label)}</div>
|
||||
{tab.count(unreadNotificationsCount) > 0 && (
|
||||
<CountChip count={getNumberCount(tab.count(unreadNotificationsCount))} />
|
||||
)}
|
||||
</div>
|
||||
{currentNotificationTab === tab.value && (
|
||||
<div className="border absolute bottom-0 right-0 left-0 rounded-t-md border-accent-strong" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Header>
|
||||
<MoveDiagonal size={16} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* applied filters */}
|
||||
<AppliedFilters workspaceSlug={workspaceSlug.toString()} />
|
||||
|
||||
{/* rendering notifications */}
|
||||
{loader === "init-loader" ? (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<NotificationsLoader />
|
||||
<ViewModeSelector
|
||||
value={currentViewMode}
|
||||
onChange={(mode) => {
|
||||
setViewMode(mode);
|
||||
if (mode === "full") {
|
||||
router.push(`/${workspaceSlug}/notifications/`);
|
||||
}
|
||||
onModeChange?.(mode);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{notificationIds && notificationIds.length > 0 ? (
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue={currentNotificationTab}
|
||||
onValueChange={setCurrentNotificationTab}
|
||||
className={"overflow-y-hidden flex-1"}
|
||||
>
|
||||
<div className="flex items-center justify-between mx-3">
|
||||
<Tabs.List className="w-fit">
|
||||
{NOTIFICATION_TABS.map((tab) => (
|
||||
<Tabs.Trigger key={tab.value} value={tab.value} size="sm">
|
||||
<div className="flex items-center gap-1.5 px-1">
|
||||
{t(tab.i18n_label)}
|
||||
{tab.count(unreadNotificationsCount) > 0 && (
|
||||
<span>{getNumberCount(tab.count(unreadNotificationsCount))}</span>
|
||||
)}
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
<NotificationSidebarHeaderOptions workspaceSlug={workspaceSlug.toString()} />
|
||||
</div>
|
||||
|
||||
<Tabs.Content value={currentNotificationTab} className="py-2 overflow-y-auto flex-1">
|
||||
{loader === "init-loader" ? (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<NotificationsLoader />
|
||||
</div>
|
||||
) : notificationIds && notificationIds.length > 0 ? (
|
||||
<ContentWrapper variant={ERowVariant.HUGGING}>
|
||||
<NotificationListRoot workspaceSlug={workspaceSlug.toString()} workspaceId={workspace?.id} />
|
||||
<NotificationListRoot
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
workspaceId={workspace.id}
|
||||
onNotificationClick={onNotificationClick}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
) : (
|
||||
<div className="relative w-full h-full flex justify-center items-center">
|
||||
<div className="relative w-full h-full flex items-center justify-center">
|
||||
<NotificationEmptyState currentNotificationTab={currentNotificationTab} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
|
||||
<AppliedFilters workspaceSlug={workspaceSlug.toString()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { CenterPanelIcon, FullScreenPanelIcon } from "@plane/propel/icons";
|
||||
import type { TNotificationsViewMode } from "@/store/notifications/workspace-notifications.store";
|
||||
import { Menu } from "@plane/propel/menu";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
|
||||
const VIEW_MODES = [
|
||||
{ key: "compact", icon: CenterPanelIcon, i18n_label: "notifications.compact" },
|
||||
{ key: "full", icon: FullScreenPanelIcon, i18n_label: "notifications.full" },
|
||||
] as const;
|
||||
|
||||
type ViewModeSelectorProps = {
|
||||
value: TNotificationsViewMode;
|
||||
onChange: (mode: TNotificationsViewMode) => void;
|
||||
};
|
||||
|
||||
export function ViewModeSelector({ value, onChange }: ViewModeSelectorProps) {
|
||||
const CurrentIcon = VIEW_MODES.find((m) => m.key === value)?.icon;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Menu
|
||||
ariaLabel={t("notifications.select_default_view")}
|
||||
customButton={
|
||||
<span className="flex items-center justify-center">
|
||||
{CurrentIcon && <CurrentIcon className="h-4 w-4 text-tertiary hover:text-secondary" />}
|
||||
</span>
|
||||
}
|
||||
optionsClassName="p-1"
|
||||
>
|
||||
<div className="text-tertiary text-12 px-2 py-1">{t("notifications.select_default_view")}</div>
|
||||
{VIEW_MODES.map(({ key, icon: Icon, i18n_label }) => {
|
||||
const selected = key === value;
|
||||
return (
|
||||
<Menu.MenuItem key={key} onClick={() => onChange(key)}>
|
||||
<div className="flex items-center justify-between w-full px-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className="h-4 w-4" />
|
||||
{t(i18n_label)}
|
||||
</div>
|
||||
{selected && <CheckIcon className="h-4 w-4" />}
|
||||
</div>
|
||||
</Menu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { orderBy, isEmpty, update, set } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane imports
|
||||
import type { TNotificationTab } from "@plane/constants";
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
TNotificationLite,
|
||||
TNotificationPaginatedInfo,
|
||||
TNotificationPaginatedInfoQueryParams,
|
||||
TNotificationsViewMode,
|
||||
TUnreadNotificationsCount,
|
||||
} from "@plane/types";
|
||||
// helpers
|
||||
@@ -24,6 +25,8 @@ import type { CoreRootStore } from "@/store/root.store";
|
||||
type TNotificationLoader = ENotificationLoader | undefined;
|
||||
type TNotificationQueryParamType = ENotificationQueryParamType;
|
||||
|
||||
export type TGroupedNotifications = Record<string, TNotification[]>;
|
||||
|
||||
export interface IWorkspaceNotificationStore {
|
||||
// observables
|
||||
loader: TNotificationLoader;
|
||||
@@ -34,6 +37,7 @@ export interface IWorkspaceNotificationStore {
|
||||
paginationInfo: Omit<TNotificationPaginatedInfo, "results"> | undefined;
|
||||
filters: TNotificationFilter;
|
||||
// computed
|
||||
viewMode: TNotificationsViewMode;
|
||||
// computed functions
|
||||
notificationIdsByWorkspaceId: (workspaceId: string) => string[] | undefined;
|
||||
notificationLiteByNotificationId: (notificationId: string | undefined) => TNotificationLite;
|
||||
@@ -52,6 +56,7 @@ export interface IWorkspaceNotificationStore {
|
||||
queryCursorType?: TNotificationQueryParamType
|
||||
) => Promise<TNotificationPaginatedInfo | undefined>;
|
||||
markAllNotificationsAsRead: (workspaceId: string) => Promise<void>;
|
||||
setViewMode: (viewMode: TNotificationsViewMode) => void;
|
||||
}
|
||||
|
||||
export class WorkspaceNotificationStore implements IWorkspaceNotificationStore {
|
||||
@@ -89,6 +94,7 @@ export class WorkspaceNotificationStore implements IWorkspaceNotificationStore {
|
||||
paginationInfo: observable,
|
||||
filters: observable,
|
||||
// computed
|
||||
viewMode: computed,
|
||||
// helper actions
|
||||
setCurrentNotificationTab: action,
|
||||
setCurrentSelectedNotificationId: action,
|
||||
@@ -100,10 +106,21 @@ export class WorkspaceNotificationStore implements IWorkspaceNotificationStore {
|
||||
getUnreadNotificationsCount: action,
|
||||
getNotifications: action,
|
||||
markAllNotificationsAsRead: action,
|
||||
setViewMode: action,
|
||||
});
|
||||
}
|
||||
|
||||
setViewMode = (viewMode: TNotificationsViewMode): void => {
|
||||
if (this.store.user.userProfile.data) {
|
||||
this.store.user.userProfile.data.notification_view_mode = viewMode;
|
||||
}
|
||||
this.store.user.userProfile.updateUserProfile({ notification_view_mode: viewMode });
|
||||
};
|
||||
|
||||
// computed
|
||||
get viewMode(): TNotificationsViewMode {
|
||||
return this.store.user.userProfile.data?.notification_view_mode || "full";
|
||||
}
|
||||
|
||||
// computed functions
|
||||
/**
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
import { computePosition, flip, shift, autoUpdate } from "@floating-ui/dom";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// icons
|
||||
import { DRAG_HANDLE_ICONS, createSvgElement } from "../icons";
|
||||
// dropdown
|
||||
import { createDropdownContent, handleDropdownAction } from "../dropdown-content";
|
||||
import type { DropdownOption } from "../dropdown-content";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
getTableHeightPx,
|
||||
getTableWidthPx,
|
||||
isCellSelection,
|
||||
selectColumn,
|
||||
getSelectedColumns,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { moveSelectedColumns, duplicateColumns } from "../actions";
|
||||
import {
|
||||
DROP_MARKER_THICKNESS,
|
||||
getDropMarker,
|
||||
getColDragMarker,
|
||||
hideDragMarker,
|
||||
hideDropMarker,
|
||||
updateColDragMarker,
|
||||
updateColDropMarker,
|
||||
} from "../marker-utils";
|
||||
import { updateCellContentVisibility } from "../utils";
|
||||
import { calculateColumnDropIndex, constructColumnDragPreview, getTableColumnNodesInfo } from "./utils";
|
||||
|
||||
export type ColumnDragHandleConfig = {
|
||||
editor: Editor;
|
||||
col: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a vanilla JS column drag handle element with dropdown functionality
|
||||
*/
|
||||
export function createColumnDragHandle(config: ColumnDragHandleConfig): {
|
||||
element: HTMLElement;
|
||||
destroy: () => void;
|
||||
} {
|
||||
const { editor, col } = config;
|
||||
|
||||
// Create container
|
||||
const container = document.createElement("div");
|
||||
container.className =
|
||||
"table-col-handle-container absolute z-20 top-0 left-0 flex justify-center items-center w-full -translate-y-1/2";
|
||||
|
||||
// Create button
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "default-state";
|
||||
|
||||
// Create icon (Ellipsis lucide icon as SVG)
|
||||
const icon = createSvgElement(DRAG_HANDLE_ICONS.ellipsis, "size-4 text-primary");
|
||||
|
||||
button.appendChild(icon);
|
||||
container.appendChild(button);
|
||||
|
||||
// State for dropdown
|
||||
let isDropdownOpen = false;
|
||||
let dropdownElement: HTMLElement | null = null;
|
||||
let backdropElement: HTMLElement | null = null;
|
||||
let cleanupFloating: (() => void) | null = null;
|
||||
let backdropClickHandler: (() => void) | null = null;
|
||||
|
||||
// Track drag event listeners for cleanup
|
||||
let dragListeners: {
|
||||
mouseup?: (e: MouseEvent) => void;
|
||||
mousemove?: (e: MouseEvent) => void;
|
||||
} = {};
|
||||
|
||||
// Dropdown toggle function
|
||||
const toggleDropdown = () => {
|
||||
if (isDropdownOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (!isDropdownOpen) return;
|
||||
|
||||
isDropdownOpen = false;
|
||||
|
||||
// Reset button to default state
|
||||
button.className = "default-state";
|
||||
|
||||
// Remove dropdown and backdrop
|
||||
if (dropdownElement) {
|
||||
dropdownElement.remove();
|
||||
dropdownElement = null;
|
||||
}
|
||||
if (backdropElement) {
|
||||
// Remove backdrop listener before removing element
|
||||
if (backdropClickHandler) {
|
||||
backdropElement.removeEventListener("click", backdropClickHandler);
|
||||
backdropClickHandler = null;
|
||||
}
|
||||
backdropElement.remove();
|
||||
backdropElement = null;
|
||||
}
|
||||
|
||||
// Cleanup floating UI (this also removes keydown listener)
|
||||
if (cleanupFloating) {
|
||||
cleanupFloating();
|
||||
cleanupFloating = null;
|
||||
}
|
||||
|
||||
// Remove active dropdown extension
|
||||
setTimeout(() => {
|
||||
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const openDropdown = () => {
|
||||
if (isDropdownOpen) return;
|
||||
|
||||
isDropdownOpen = true;
|
||||
|
||||
// Update button to open state
|
||||
button.className = "open-state";
|
||||
|
||||
// Add active dropdown extension
|
||||
editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
|
||||
// Create backdrop
|
||||
backdropElement = document.createElement("div");
|
||||
backdropElement.style.position = "fixed";
|
||||
backdropElement.style.inset = "0";
|
||||
backdropElement.style.zIndex = "99";
|
||||
backdropClickHandler = closeDropdown;
|
||||
backdropElement.addEventListener("click", backdropClickHandler);
|
||||
document.body.appendChild(backdropElement);
|
||||
|
||||
// Create dropdown
|
||||
dropdownElement = document.createElement("div");
|
||||
dropdownElement.className =
|
||||
"max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200";
|
||||
dropdownElement.style.position = "fixed";
|
||||
dropdownElement.style.zIndex = "100";
|
||||
|
||||
// Create and append dropdown content
|
||||
const content = createDropdownContent(getDropdownOptions());
|
||||
dropdownElement.appendChild(content);
|
||||
|
||||
document.body.appendChild(dropdownElement);
|
||||
|
||||
// Attach dropdown event listeners
|
||||
attachDropdownEventListeners(dropdownElement);
|
||||
|
||||
// Setup floating UI positioning
|
||||
cleanupFloating = autoUpdate(button, dropdownElement, () => {
|
||||
void computePosition(button, dropdownElement!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [
|
||||
flip({
|
||||
fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"],
|
||||
}),
|
||||
shift({
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
if (dropdownElement) {
|
||||
dropdownElement.style.left = `${x}px`;
|
||||
dropdownElement.style.top = `${y}px`;
|
||||
}
|
||||
return;
|
||||
});
|
||||
});
|
||||
|
||||
// Handle keyboard events
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
closeDropdown();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
// Store cleanup for this specific dropdown instance
|
||||
const originalCleanup = cleanupFloating;
|
||||
cleanupFloating = () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
if (originalCleanup) originalCleanup();
|
||||
};
|
||||
};
|
||||
|
||||
const getDropdownOptions = (): DropdownOption[] => [
|
||||
{
|
||||
key: "toggle-header",
|
||||
label: "Header column",
|
||||
icon: DRAG_HANDLE_ICONS.toggleRight,
|
||||
showRightIcon: true,
|
||||
},
|
||||
{
|
||||
key: "insert-left",
|
||||
label: "Insert left",
|
||||
icon: DRAG_HANDLE_ICONS.arrowLeft,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "insert-right",
|
||||
label: "Insert right",
|
||||
icon: DRAG_HANDLE_ICONS.arrowRight,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "duplicate",
|
||||
label: "Duplicate",
|
||||
icon: DRAG_HANDLE_ICONS.duplicate,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "clear-contents",
|
||||
label: "Clear contents",
|
||||
icon: DRAG_HANDLE_ICONS.close,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: DRAG_HANDLE_ICONS.trash,
|
||||
showRightIcon: false,
|
||||
},
|
||||
];
|
||||
|
||||
const attachDropdownEventListeners = (dropdown: HTMLElement) => {
|
||||
const buttons = dropdown.querySelectorAll("button[data-action]");
|
||||
const colorPanel = dropdown.querySelector(".color-panel");
|
||||
const colorChevron = dropdown.querySelector(".color-chevron");
|
||||
|
||||
buttons.forEach((btn) => {
|
||||
const action = btn.getAttribute("data-action");
|
||||
if (!action) return;
|
||||
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Handle common actions
|
||||
handleDropdownAction(action, editor, closeDropdown, colorPanel, colorChevron);
|
||||
|
||||
// Handle column-specific actions
|
||||
switch (action) {
|
||||
case "toggle-header":
|
||||
editor.chain().focus().toggleHeaderColumn().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "set-bg-color": {
|
||||
const color = btn.getAttribute("data-color");
|
||||
if (color) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes(CORE_EXTENSIONS.TABLE_CELL, {
|
||||
background: color,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
case "insert-left":
|
||||
editor.chain().focus().addColumnBefore().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "insert-right":
|
||||
editor.chain().focus().addColumnAfter().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "duplicate": {
|
||||
const table = findTable(editor.state.selection);
|
||||
if (table) {
|
||||
const tableMap = TableMap.get(table.node);
|
||||
let tr = editor.state.tr;
|
||||
const selectedColumns = getSelectedColumns(editor.state.selection, tableMap);
|
||||
tr = duplicateColumns(table, selectedColumns, tr);
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
case "clear-contents":
|
||||
editor.chain().focus().clearSelectedCells().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "delete":
|
||||
editor.chain().focus().deleteColumn().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Handle mousedown for dragging
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// Prevent dropdown from opening during drag
|
||||
if (e.button !== 0) return; // Only left click
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Check if this is a click (will be determined by mouseup without much movement)
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
let hasMoved = false;
|
||||
|
||||
// Clean up any existing drag listeners
|
||||
if (dragListeners.mouseup) {
|
||||
window.removeEventListener("mouseup", dragListeners.mouseup);
|
||||
}
|
||||
if (dragListeners.mousemove) {
|
||||
window.removeEventListener("mousemove", dragListeners.mousemove);
|
||||
}
|
||||
dragListeners = {};
|
||||
|
||||
const table = findTable(editor.state.selection);
|
||||
if (!table) return;
|
||||
|
||||
editor.view.dispatch(selectColumn(table, col, editor.state.tr));
|
||||
|
||||
// Drag column logic
|
||||
const tableWidthPx = getTableWidthPx(table, editor);
|
||||
const columns = getTableColumnNodesInfo(table, editor);
|
||||
|
||||
let dropIndex = col;
|
||||
const startLeft = columns[col].left ?? 0;
|
||||
const startXPos = e.clientX;
|
||||
const tableElement = editor.view.nodeDOM(table.pos);
|
||||
|
||||
const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null;
|
||||
const dragMarker = tableElement instanceof HTMLElement ? getColDragMarker(tableElement) : null;
|
||||
|
||||
const handleFinish = (): void => {
|
||||
// Clean up markers if they exist
|
||||
if (dropMarker && dragMarker) {
|
||||
hideDropMarker(dropMarker);
|
||||
hideDragMarker(dragMarker);
|
||||
}
|
||||
|
||||
if (isCellSelection(editor.state.selection)) {
|
||||
updateCellContentVisibility(editor, false);
|
||||
}
|
||||
|
||||
// Perform drag operation if user moved
|
||||
if (col !== dropIndex && hasMoved) {
|
||||
let tr = editor.state.tr;
|
||||
const selection = editor.state.selection;
|
||||
if (isCellSelection(selection)) {
|
||||
const table = findTable(selection);
|
||||
if (table) {
|
||||
tr = moveSelectedColumns(editor, table, selection, dropIndex, tr);
|
||||
}
|
||||
}
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
window.removeEventListener("mouseup", handleFinish);
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
dragListeners.mouseup = undefined;
|
||||
dragListeners.mousemove = undefined;
|
||||
|
||||
// If it was just a click (no movement), toggle dropdown
|
||||
if (!hasMoved) {
|
||||
toggleDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
let pseudoColumn: HTMLElement | undefined;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent): void => {
|
||||
// Mark that we've moved
|
||||
const deltaX = Math.abs(moveEvent.clientX - startX);
|
||||
const deltaY = Math.abs(moveEvent.clientY - startY);
|
||||
if (deltaX > 3 || deltaY > 3) {
|
||||
hasMoved = true;
|
||||
}
|
||||
|
||||
// Calculate drop index
|
||||
const currentLeft = startLeft + moveEvent.clientX - startXPos;
|
||||
dropIndex = calculateColumnDropIndex(col, columns, currentLeft);
|
||||
|
||||
// Update visual markers if they exist
|
||||
if (dropMarker && dragMarker) {
|
||||
if (!pseudoColumn) {
|
||||
pseudoColumn = constructColumnDragPreview(editor, editor.state.selection, table);
|
||||
const tableHeightPx = getTableHeightPx(table, editor);
|
||||
if (pseudoColumn) {
|
||||
pseudoColumn.style.height = `${tableHeightPx}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const dragMarkerWidthPx = columns[col].width;
|
||||
const dragMarkerLeftPx = Math.max(0, Math.min(currentLeft, tableWidthPx - dragMarkerWidthPx));
|
||||
const dropMarkerLeftPx =
|
||||
dropIndex <= col ? columns[dropIndex].left : columns[dropIndex].left + columns[dropIndex].width;
|
||||
|
||||
updateColDropMarker({
|
||||
element: dropMarker,
|
||||
left: dropMarkerLeftPx - Math.floor(DROP_MARKER_THICKNESS / 2) - 1,
|
||||
width: DROP_MARKER_THICKNESS,
|
||||
});
|
||||
updateColDragMarker({
|
||||
element: dragMarker,
|
||||
left: dragMarkerLeftPx,
|
||||
width: dragMarkerWidthPx,
|
||||
pseudoColumn,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
dragListeners.mouseup = handleFinish;
|
||||
dragListeners.mousemove = handleMove;
|
||||
window.addEventListener("mouseup", handleFinish);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
} catch (error) {
|
||||
console.error("Error in ColumnDragHandle:", error);
|
||||
handleFinish();
|
||||
}
|
||||
};
|
||||
|
||||
// Attach mousedown listener
|
||||
button.addEventListener("mousedown", handleMouseDown);
|
||||
|
||||
// Cleanup function
|
||||
const destroy = () => {
|
||||
// Close dropdown if open
|
||||
if (isDropdownOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
// Remove drag listeners
|
||||
if (dragListeners.mouseup) {
|
||||
window.removeEventListener("mouseup", dragListeners.mouseup);
|
||||
}
|
||||
if (dragListeners.mousemove) {
|
||||
window.removeEventListener("mousemove", dragListeners.mousemove);
|
||||
}
|
||||
|
||||
// Remove mousedown listener
|
||||
button.removeEventListener("mousedown", handleMouseDown);
|
||||
|
||||
// Remove DOM elements
|
||||
container.remove();
|
||||
};
|
||||
|
||||
return {
|
||||
element: container,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
shift,
|
||||
flip,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
autoUpdate,
|
||||
useClick,
|
||||
useRole,
|
||||
FloatingOverlay,
|
||||
FloatingPortal,
|
||||
} from "@floating-ui/react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
getTableHeightPx,
|
||||
getTableWidthPx,
|
||||
isCellSelection,
|
||||
selectColumn,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { moveSelectedColumns } from "../actions";
|
||||
import {
|
||||
DROP_MARKER_THICKNESS,
|
||||
getColDragMarker,
|
||||
getDropMarker,
|
||||
hideDragMarker,
|
||||
hideDropMarker,
|
||||
updateColDragMarker,
|
||||
updateColDropMarker,
|
||||
} from "../marker-utils";
|
||||
import { updateCellContentVisibility } from "../utils";
|
||||
import { ColumnOptionsDropdown } from "./dropdown";
|
||||
import { calculateColumnDropIndex, constructColumnDragPreview, getTableColumnNodesInfo } from "./utils";
|
||||
|
||||
export type ColumnDragHandleProps = {
|
||||
col: number;
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export function ColumnDragHandle(props: ColumnDragHandleProps) {
|
||||
const { col, editor } = props;
|
||||
// states
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
// Track active event listeners for cleanup
|
||||
const activeListenersRef = useRef<{
|
||||
mouseup?: (e: MouseEvent) => void;
|
||||
mousemove?: (e: MouseEvent) => void;
|
||||
}>({});
|
||||
|
||||
// Cleanup window event listeners on unmount
|
||||
useEffect(() => {
|
||||
const listenersRef = activeListenersRef.current;
|
||||
return () => {
|
||||
// Remove any lingering window event listeners when component unmounts
|
||||
if (listenersRef.mouseup) {
|
||||
window.removeEventListener("mouseup", listenersRef.mouseup);
|
||||
}
|
||||
if (listenersRef.mousemove) {
|
||||
window.removeEventListener("mousemove", listenersRef.mousemove);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
// floating ui
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
placement: "bottom-start",
|
||||
middleware: [
|
||||
flip({
|
||||
fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"],
|
||||
}),
|
||||
shift({
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
open: isDropdownOpen,
|
||||
onOpenChange: (open) => {
|
||||
setIsDropdownOpen(open);
|
||||
if (open) {
|
||||
editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context);
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
context.onOpenChange(false);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isDropdownOpen, context]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Prevent multiple simultaneous drag operations
|
||||
// If there are already listeners attached, remove them first
|
||||
if (activeListenersRef.current.mouseup) {
|
||||
window.removeEventListener("mouseup", activeListenersRef.current.mouseup);
|
||||
}
|
||||
if (activeListenersRef.current.mousemove) {
|
||||
window.removeEventListener("mousemove", activeListenersRef.current.mousemove);
|
||||
}
|
||||
activeListenersRef.current.mouseup = undefined;
|
||||
activeListenersRef.current.mousemove = undefined;
|
||||
|
||||
const table = findTable(editor.state.selection);
|
||||
if (!table) return;
|
||||
|
||||
editor.view.dispatch(selectColumn(table, col, editor.state.tr));
|
||||
|
||||
// drag column
|
||||
const tableWidthPx = getTableWidthPx(table, editor);
|
||||
const columns = getTableColumnNodesInfo(table, editor);
|
||||
|
||||
let dropIndex = col;
|
||||
const startLeft = columns[col].left ?? 0;
|
||||
const startX = e.clientX;
|
||||
const tableElement = editor.view.nodeDOM(table.pos);
|
||||
|
||||
const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null;
|
||||
const dragMarker = tableElement instanceof HTMLElement ? getColDragMarker(tableElement) : null;
|
||||
|
||||
const handleFinish = () => {
|
||||
if (!dropMarker || !dragMarker) return;
|
||||
hideDropMarker(dropMarker);
|
||||
hideDragMarker(dragMarker);
|
||||
|
||||
if (isCellSelection(editor.state.selection)) {
|
||||
updateCellContentVisibility(editor, false);
|
||||
}
|
||||
|
||||
if (col !== dropIndex) {
|
||||
let tr = editor.state.tr;
|
||||
const selection = editor.state.selection;
|
||||
if (isCellSelection(selection)) {
|
||||
const table = findTable(selection);
|
||||
if (table) {
|
||||
tr = moveSelectedColumns(editor, table, selection, dropIndex, tr);
|
||||
}
|
||||
}
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
window.removeEventListener("mouseup", handleFinish);
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
// Clear the ref
|
||||
activeListenersRef.current.mouseup = undefined;
|
||||
activeListenersRef.current.mousemove = undefined;
|
||||
};
|
||||
|
||||
let pseudoColumn: HTMLElement | undefined;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent) => {
|
||||
if (!dropMarker || !dragMarker) return;
|
||||
const currentLeft = startLeft + moveEvent.clientX - startX;
|
||||
dropIndex = calculateColumnDropIndex(col, columns, currentLeft);
|
||||
|
||||
if (!pseudoColumn) {
|
||||
pseudoColumn = constructColumnDragPreview(editor, editor.state.selection, table);
|
||||
const tableHeightPx = getTableHeightPx(table, editor);
|
||||
if (pseudoColumn) {
|
||||
pseudoColumn.style.height = `${tableHeightPx}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const dragMarkerWidthPx = columns[col].width;
|
||||
const dragMarkerLeftPx = Math.max(0, Math.min(currentLeft, tableWidthPx - dragMarkerWidthPx));
|
||||
const dropMarkerLeftPx =
|
||||
dropIndex <= col ? columns[dropIndex].left : columns[dropIndex].left + columns[dropIndex].width;
|
||||
|
||||
updateColDropMarker({
|
||||
element: dropMarker,
|
||||
left: dropMarkerLeftPx - Math.floor(DROP_MARKER_THICKNESS / 2) - 1,
|
||||
width: DROP_MARKER_THICKNESS,
|
||||
});
|
||||
updateColDragMarker({
|
||||
element: dragMarker,
|
||||
left: dragMarkerLeftPx,
|
||||
width: dragMarkerWidthPx,
|
||||
pseudoColumn,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
// Store references for cleanup
|
||||
activeListenersRef.current.mouseup = handleFinish;
|
||||
activeListenersRef.current.mousemove = handleMove;
|
||||
window.addEventListener("mouseup", handleFinish);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
} catch (error) {
|
||||
console.error("Error in ColumnDragHandle:", error);
|
||||
handleFinish();
|
||||
}
|
||||
},
|
||||
[col, editor]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-col-handle-container absolute z-20 top-0 left-0 flex justify-center items-center w-full -translate-y-1/2">
|
||||
<button
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
type="button"
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn("px-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200", {
|
||||
"!opacity-100 bg-accent-primary border-accent-strong": isDropdownOpen,
|
||||
"hover:bg-layer-1-hover": !isDropdownOpen,
|
||||
})}
|
||||
>
|
||||
<Ellipsis className="size-4 text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
{isDropdownOpen && (
|
||||
<FloatingPortal>
|
||||
{/* Backdrop */}
|
||||
<FloatingOverlay
|
||||
style={{
|
||||
zIndex: 99,
|
||||
}}
|
||||
lockScroll
|
||||
/>
|
||||
<div
|
||||
className="max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200"
|
||||
ref={refs.setFloating}
|
||||
{...getFloatingProps()}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<ColumnOptionsDropdown editor={editor} onClose={() => context.onOpenChange(false)} />
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
@@ -9,20 +10,16 @@ import {
|
||||
haveTableRelatedChanges,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { createColumnDragHandle } from "./drag-handle";
|
||||
|
||||
type DragHandleInstance = {
|
||||
element: HTMLElement;
|
||||
destroy: () => void;
|
||||
};
|
||||
import type { ColumnDragHandleProps } from "./drag-handle";
|
||||
import { ColumnDragHandle } from "./drag-handle";
|
||||
|
||||
type TableColumnDragHandlePluginState = {
|
||||
decorations?: DecorationSet;
|
||||
// track table structure to detect changes
|
||||
tableWidth?: number;
|
||||
tableNodePos?: number;
|
||||
// track drag handle instances for cleanup
|
||||
dragHandles?: DragHandleInstance[];
|
||||
// track renderers for cleanup
|
||||
renderers?: ReactRenderer[];
|
||||
};
|
||||
|
||||
const TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY = new PluginKey("tableColumnHandlerDecorationPlugin");
|
||||
@@ -63,40 +60,43 @@ export const TableColumnDragHandlePlugin = (editor: Editor): Plugin<TableColumnD
|
||||
decorations: mapped,
|
||||
tableWidth: tableMap.width,
|
||||
tableNodePos: table.pos,
|
||||
dragHandles: prev.dragHandles,
|
||||
renderers: prev.renderers,
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up old drag handles before creating new ones
|
||||
prev.dragHandles?.forEach((handle) => {
|
||||
// Clean up old renderers before creating new ones
|
||||
prev.renderers?.forEach((renderer) => {
|
||||
try {
|
||||
handle.destroy();
|
||||
renderer.destroy();
|
||||
} catch (error) {
|
||||
console.error("Error destroying drag handle:", error);
|
||||
console.error("Error destroying renderer:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// recreate all decorations
|
||||
const decorations: Decoration[] = [];
|
||||
const dragHandles: DragHandleInstance[] = [];
|
||||
const renderers: ReactRenderer[] = [];
|
||||
|
||||
for (let col = 0; col < tableMap.width; col++) {
|
||||
const pos = getTableCellWidgetDecorationPos(table, tableMap, col);
|
||||
|
||||
const dragHandle = createColumnDragHandle({
|
||||
const dragHandleComponent = new ReactRenderer(ColumnDragHandle, {
|
||||
props: {
|
||||
col,
|
||||
editor,
|
||||
} satisfies ColumnDragHandleProps,
|
||||
editor,
|
||||
col,
|
||||
});
|
||||
|
||||
dragHandles.push(dragHandle);
|
||||
decorations.push(Decoration.widget(pos, () => dragHandle.element));
|
||||
renderers.push(dragHandleComponent);
|
||||
decorations.push(Decoration.widget(pos, () => dragHandleComponent.element));
|
||||
}
|
||||
|
||||
return {
|
||||
decorations: DecorationSet.create(newState.doc, decorations),
|
||||
tableWidth: tableMap.width,
|
||||
tableNodePos: table.pos,
|
||||
dragHandles,
|
||||
renderers,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -107,15 +107,15 @@ export const TableColumnDragHandlePlugin = (editor: Editor): Plugin<TableColumnD
|
||||
},
|
||||
},
|
||||
destroy() {
|
||||
// Clean up all drag handles when plugin is destroyed
|
||||
// Clean up all renderers when plugin is destroyed
|
||||
const state =
|
||||
editor.state &&
|
||||
(TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY.getState(editor.state) as TableColumnDragHandlePluginState | undefined);
|
||||
state?.dragHandles?.forEach((handle: DragHandleInstance) => {
|
||||
state?.renderers?.forEach((renderer: ReactRenderer) => {
|
||||
try {
|
||||
handle.destroy();
|
||||
renderer.destroy();
|
||||
} catch (error) {
|
||||
console.error("Error destroying drag handle:", error);
|
||||
console.error("Error destroying renderer:", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// icons
|
||||
import { DRAG_HANDLE_ICONS, createSvgElement } from "./icons";
|
||||
|
||||
export type DropdownOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
showRightIcon: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the color selector section for the dropdown
|
||||
*/
|
||||
export function createColorSelector(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
container.className = "mb-2";
|
||||
|
||||
// Create toggle button
|
||||
const toggleBtn = document.createElement("button");
|
||||
toggleBtn.type = "button";
|
||||
toggleBtn.className =
|
||||
"flex items-center justify-between gap-2 w-full rounded-sm px-1 py-1.5 text-11 text-left truncate text-secondary hover:bg-layer-transparent-hover";
|
||||
toggleBtn.setAttribute("data-action", "toggle-color-selector");
|
||||
|
||||
// Left span with icon and text
|
||||
const leftSpan = document.createElement("span");
|
||||
leftSpan.className = "flex items-center gap-2";
|
||||
const paletteIcon = createSvgElement(DRAG_HANDLE_ICONS.palette, "shrink-0 size-3");
|
||||
leftSpan.appendChild(paletteIcon);
|
||||
leftSpan.appendChild(document.createTextNode("Color"));
|
||||
|
||||
// Right chevron icon
|
||||
const chevronIcon = createSvgElement(
|
||||
DRAG_HANDLE_ICONS.chevronRight,
|
||||
"shrink-0 size-3 transition-transform duration-200 color-chevron"
|
||||
);
|
||||
|
||||
toggleBtn.appendChild(leftSpan);
|
||||
toggleBtn.appendChild(chevronIcon);
|
||||
container.appendChild(toggleBtn);
|
||||
|
||||
// Create color panel
|
||||
const colorPanel = document.createElement("div");
|
||||
colorPanel.className = "p-1 space-y-2 mb-1.5 hidden color-panel";
|
||||
|
||||
const innerDiv = document.createElement("div");
|
||||
innerDiv.className = "space-y-1";
|
||||
|
||||
const title = document.createElement("p");
|
||||
title.className = "text-11 text-tertiary font-semibold";
|
||||
title.textContent = "Background colors";
|
||||
innerDiv.appendChild(title);
|
||||
|
||||
const colorsContainer = document.createElement("div");
|
||||
colorsContainer.className = "flex items-center flex-wrap gap-2";
|
||||
|
||||
// Create color buttons
|
||||
COLORS_LIST.forEach((color) => {
|
||||
const colorBtn = document.createElement("button");
|
||||
colorBtn.type = "button";
|
||||
colorBtn.className =
|
||||
"flex-shrink-0 size-6 rounded-sm border-[0.5px] border-strong-1 hover:opacity-60 transition-opacity";
|
||||
colorBtn.style.backgroundColor = color.backgroundColor;
|
||||
colorBtn.setAttribute("data-action", "set-bg-color");
|
||||
colorBtn.setAttribute("data-color", color.backgroundColor);
|
||||
colorsContainer.appendChild(colorBtn);
|
||||
});
|
||||
|
||||
// Create clear color button
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.type = "button";
|
||||
clearBtn.className =
|
||||
"flex-shrink-0 size-6 grid place-items-center rounded-sm text-tertiary border-[0.5px] border-strong-1 hover:bg-layer-transparent-hover transition-colors";
|
||||
clearBtn.setAttribute("data-action", "clear-bg-color");
|
||||
const banIcon = createSvgElement(DRAG_HANDLE_ICONS.ban, "size-4");
|
||||
clearBtn.appendChild(banIcon);
|
||||
colorsContainer.appendChild(clearBtn);
|
||||
|
||||
innerDiv.appendChild(colorsContainer);
|
||||
colorPanel.appendChild(innerDiv);
|
||||
container.appendChild(colorPanel);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the dropdown content with options and color selector
|
||||
*/
|
||||
export function createDropdownContent(options: DropdownOption[]): DocumentFragment {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// Create option buttons
|
||||
options.forEach((option, index) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `flex items-center ${option.showRightIcon ? "justify-between" : ""} gap-2 w-full rounded-sm px-1 py-1.5 text-11 text-left truncate text-secondary hover:bg-layer-transparent-hover`;
|
||||
btn.setAttribute("data-action", option.key);
|
||||
|
||||
// Create icon element
|
||||
const iconElement = createSvgElement(option.icon, "shrink-0 size-3");
|
||||
|
||||
// Create label
|
||||
const labelDiv = document.createElement("div");
|
||||
labelDiv.className = "flex-grow truncate";
|
||||
labelDiv.textContent = option.label;
|
||||
|
||||
// Append in correct order
|
||||
if (option.showRightIcon) {
|
||||
btn.appendChild(labelDiv);
|
||||
btn.appendChild(iconElement);
|
||||
} else {
|
||||
btn.appendChild(iconElement);
|
||||
btn.appendChild(labelDiv);
|
||||
}
|
||||
|
||||
fragment.appendChild(btn);
|
||||
|
||||
// Add divider after first option (header toggle)
|
||||
if (index === 0) {
|
||||
const hr = document.createElement("hr");
|
||||
hr.className = "my-2 border-subtle";
|
||||
fragment.appendChild(hr);
|
||||
|
||||
// Add color selector after divider
|
||||
const colorSection = createColorSelector();
|
||||
fragment.appendChild(colorSection);
|
||||
}
|
||||
});
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles dropdown action events
|
||||
*/
|
||||
export function handleDropdownAction(
|
||||
action: string,
|
||||
editor: Editor,
|
||||
onClose: () => void,
|
||||
colorPanel?: Element | null,
|
||||
colorChevron?: Element | null
|
||||
): void {
|
||||
switch (action) {
|
||||
case "toggle-color-selector":
|
||||
if (colorPanel && colorChevron) {
|
||||
const isHidden = colorPanel.classList.contains("hidden");
|
||||
if (isHidden) {
|
||||
colorPanel.classList.remove("hidden");
|
||||
colorChevron.classList.add("rotate-90");
|
||||
} else {
|
||||
colorPanel.classList.add("hidden");
|
||||
colorChevron.classList.remove("rotate-90");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "set-bg-color": {
|
||||
// Color is handled by the button's data-color attribute
|
||||
// This case is handled in the event listener
|
||||
break;
|
||||
}
|
||||
case "clear-bg-color":
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes(CORE_EXTENSIONS.TABLE_CELL, {
|
||||
background: null,
|
||||
})
|
||||
.run();
|
||||
onClose();
|
||||
break;
|
||||
default:
|
||||
// Other actions are handled by specific implementations
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* SVG icon paths for table drag handle dropdowns
|
||||
*/
|
||||
|
||||
export const DRAG_HANDLE_ICONS = {
|
||||
// Toggle icons
|
||||
toggleRight: '<rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/>',
|
||||
|
||||
// Arrow icons
|
||||
arrowUp: '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
|
||||
arrowDown: '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
|
||||
arrowLeft: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
|
||||
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
||||
|
||||
// Action icons
|
||||
duplicate:
|
||||
'<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
trash:
|
||||
'<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
|
||||
|
||||
// Color icons
|
||||
palette:
|
||||
'<circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/>',
|
||||
chevronRight: '<path d="m9 18 6-6-6-6"/>',
|
||||
ban: '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
|
||||
|
||||
// Ellipsis (drag handle button)
|
||||
ellipsis: '<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Creates an SVG element (DOM element) with the given icon path
|
||||
*/
|
||||
export function createSvgElement(iconPath: string, className = "size-3"): SVGSVGElement {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("class", className);
|
||||
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
svg.setAttribute("width", "24");
|
||||
svg.setAttribute("height", "24");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", "currentColor");
|
||||
svg.setAttribute("stroke-width", "2");
|
||||
svg.setAttribute("stroke-linecap", "round");
|
||||
svg.setAttribute("stroke-linejoin", "round");
|
||||
svg.innerHTML = iconPath;
|
||||
return svg;
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
import { computePosition, flip, shift, autoUpdate } from "@floating-ui/dom";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// icons
|
||||
import { DRAG_HANDLE_ICONS, createSvgElement } from "../icons";
|
||||
// dropdown
|
||||
import { createDropdownContent, handleDropdownAction } from "../dropdown-content";
|
||||
import type { DropdownOption } from "../dropdown-content";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
getTableHeightPx,
|
||||
getTableWidthPx,
|
||||
isCellSelection,
|
||||
selectRow,
|
||||
getSelectedRows,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { moveSelectedRows, duplicateRows } from "../actions";
|
||||
import {
|
||||
DROP_MARKER_THICKNESS,
|
||||
getDropMarker,
|
||||
getRowDragMarker,
|
||||
hideDragMarker,
|
||||
hideDropMarker,
|
||||
updateRowDragMarker,
|
||||
updateRowDropMarker,
|
||||
} from "../marker-utils";
|
||||
import { updateCellContentVisibility } from "../utils";
|
||||
import { calculateRowDropIndex, constructRowDragPreview, getTableRowNodesInfo } from "./utils";
|
||||
|
||||
export type RowDragHandleConfig = {
|
||||
editor: Editor;
|
||||
row: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a vanilla JS row drag handle element with dropdown functionality
|
||||
*/
|
||||
export function createRowDragHandle(config: RowDragHandleConfig): {
|
||||
element: HTMLElement;
|
||||
destroy: () => void;
|
||||
} {
|
||||
const { editor, row } = config;
|
||||
|
||||
// Create container
|
||||
const container = document.createElement("div");
|
||||
container.className =
|
||||
"table-row-handle-container absolute z-20 top-0 left-0 flex justify-center items-center h-full -translate-x-1/2";
|
||||
|
||||
// Create button
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "default-state";
|
||||
|
||||
// Create icon (Ellipsis lucide icon as SVG)
|
||||
const icon = createSvgElement(DRAG_HANDLE_ICONS.ellipsis, "size-4 text-primary");
|
||||
|
||||
button.appendChild(icon);
|
||||
container.appendChild(button);
|
||||
|
||||
// State for dropdown
|
||||
let isDropdownOpen = false;
|
||||
let dropdownElement: HTMLElement | null = null;
|
||||
let backdropElement: HTMLElement | null = null;
|
||||
let cleanupFloating: (() => void) | null = null;
|
||||
let backdropClickHandler: (() => void) | null = null;
|
||||
|
||||
// Track drag event listeners for cleanup
|
||||
let dragListeners: {
|
||||
mouseup?: (e: MouseEvent) => void;
|
||||
mousemove?: (e: MouseEvent) => void;
|
||||
} = {};
|
||||
|
||||
// Dropdown toggle function
|
||||
const toggleDropdown = () => {
|
||||
if (isDropdownOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (!isDropdownOpen) return;
|
||||
|
||||
isDropdownOpen = false;
|
||||
|
||||
// Reset button to default state
|
||||
button.className = "default-state";
|
||||
|
||||
// Remove dropdown and backdrop
|
||||
if (dropdownElement) {
|
||||
dropdownElement.remove();
|
||||
dropdownElement = null;
|
||||
}
|
||||
if (backdropElement) {
|
||||
// Remove backdrop listener before removing element
|
||||
if (backdropClickHandler) {
|
||||
backdropElement.removeEventListener("click", backdropClickHandler);
|
||||
backdropClickHandler = null;
|
||||
}
|
||||
backdropElement.remove();
|
||||
backdropElement = null;
|
||||
}
|
||||
|
||||
// Cleanup floating UI (this also removes keydown listener)
|
||||
if (cleanupFloating) {
|
||||
cleanupFloating();
|
||||
cleanupFloating = null;
|
||||
}
|
||||
|
||||
// Remove active dropdown extension
|
||||
setTimeout(() => {
|
||||
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const openDropdown = () => {
|
||||
if (isDropdownOpen) return;
|
||||
|
||||
isDropdownOpen = true;
|
||||
|
||||
// Update button to open state
|
||||
button.className = "open-state";
|
||||
|
||||
// Add active dropdown extension
|
||||
editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
|
||||
// Create backdrop
|
||||
backdropElement = document.createElement("div");
|
||||
backdropElement.style.position = "fixed";
|
||||
backdropElement.style.inset = "0";
|
||||
backdropElement.style.zIndex = "99";
|
||||
backdropClickHandler = closeDropdown;
|
||||
backdropElement.addEventListener("click", backdropClickHandler);
|
||||
document.body.appendChild(backdropElement);
|
||||
|
||||
// Create dropdown
|
||||
dropdownElement = document.createElement("div");
|
||||
dropdownElement.className =
|
||||
"max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200";
|
||||
dropdownElement.style.position = "fixed";
|
||||
dropdownElement.style.zIndex = "100";
|
||||
|
||||
// Create and append dropdown content
|
||||
const content = createDropdownContent(getDropdownOptions());
|
||||
dropdownElement.appendChild(content);
|
||||
|
||||
document.body.appendChild(dropdownElement);
|
||||
|
||||
// Attach dropdown event listeners
|
||||
attachDropdownEventListeners(dropdownElement);
|
||||
|
||||
// Setup floating UI positioning
|
||||
cleanupFloating = autoUpdate(button, dropdownElement, () => {
|
||||
void computePosition(button, dropdownElement!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [
|
||||
flip({
|
||||
fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"],
|
||||
}),
|
||||
shift({
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
if (dropdownElement) {
|
||||
dropdownElement.style.left = `${x}px`;
|
||||
dropdownElement.style.top = `${y}px`;
|
||||
}
|
||||
return;
|
||||
});
|
||||
});
|
||||
|
||||
// Handle keyboard events
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
closeDropdown();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
// Store cleanup for this specific dropdown instance
|
||||
const originalCleanup = cleanupFloating;
|
||||
cleanupFloating = () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
if (originalCleanup) originalCleanup();
|
||||
};
|
||||
};
|
||||
|
||||
const getDropdownOptions = (): DropdownOption[] => [
|
||||
{
|
||||
key: "toggle-header",
|
||||
label: "Header row",
|
||||
icon: DRAG_HANDLE_ICONS.toggleRight,
|
||||
showRightIcon: true,
|
||||
},
|
||||
{
|
||||
key: "insert-above",
|
||||
label: "Insert above",
|
||||
icon: DRAG_HANDLE_ICONS.arrowUp,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "insert-below",
|
||||
label: "Insert below",
|
||||
icon: DRAG_HANDLE_ICONS.arrowDown,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "duplicate",
|
||||
label: "Duplicate",
|
||||
icon: DRAG_HANDLE_ICONS.duplicate,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "clear-contents",
|
||||
label: "Clear contents",
|
||||
icon: DRAG_HANDLE_ICONS.close,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: DRAG_HANDLE_ICONS.trash,
|
||||
showRightIcon: false,
|
||||
},
|
||||
];
|
||||
|
||||
const attachDropdownEventListeners = (dropdown: HTMLElement) => {
|
||||
const buttons = dropdown.querySelectorAll("button[data-action]");
|
||||
const colorPanel = dropdown.querySelector(".color-panel");
|
||||
const colorChevron = dropdown.querySelector(".color-chevron");
|
||||
|
||||
buttons.forEach((btn) => {
|
||||
const action = btn.getAttribute("data-action");
|
||||
if (!action) return;
|
||||
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Handle common actions
|
||||
handleDropdownAction(action, editor, closeDropdown, colorPanel, colorChevron);
|
||||
|
||||
// Handle row-specific actions
|
||||
switch (action) {
|
||||
case "toggle-header":
|
||||
editor.chain().focus().toggleHeaderRow().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "set-bg-color": {
|
||||
const color = btn.getAttribute("data-color");
|
||||
if (color) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes(CORE_EXTENSIONS.TABLE_CELL, {
|
||||
background: color,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
case "insert-above":
|
||||
editor.chain().focus().addRowBefore().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "insert-below":
|
||||
editor.chain().focus().addRowAfter().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "duplicate": {
|
||||
const table = findTable(editor.state.selection);
|
||||
if (table) {
|
||||
const tableMap = TableMap.get(table.node);
|
||||
let tr = editor.state.tr;
|
||||
const selectedRows = getSelectedRows(editor.state.selection, tableMap);
|
||||
tr = duplicateRows(table, selectedRows, tr);
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
case "clear-contents":
|
||||
editor.chain().focus().clearSelectedCells().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "delete":
|
||||
editor.chain().focus().deleteRow().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Handle mousedown for dragging
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// Prevent dropdown from opening during drag
|
||||
if (e.button !== 0) return; // Only left click
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Check if this is a click (will be determined by mouseup without much movement)
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
let hasMoved = false;
|
||||
|
||||
// Clean up any existing drag listeners
|
||||
if (dragListeners.mouseup) {
|
||||
window.removeEventListener("mouseup", dragListeners.mouseup);
|
||||
}
|
||||
if (dragListeners.mousemove) {
|
||||
window.removeEventListener("mousemove", dragListeners.mousemove);
|
||||
}
|
||||
dragListeners = {};
|
||||
|
||||
const table = findTable(editor.state.selection);
|
||||
if (!table) return;
|
||||
|
||||
editor.view.dispatch(selectRow(table, row, editor.state.tr));
|
||||
|
||||
// Drag row logic
|
||||
const tableHeightPx = getTableHeightPx(table, editor);
|
||||
const rows = getTableRowNodesInfo(table, editor);
|
||||
|
||||
let dropIndex = row;
|
||||
const startTop = rows[row].top ?? 0;
|
||||
const startYPos = e.clientY;
|
||||
const tableElement = editor.view.nodeDOM(table.pos);
|
||||
|
||||
const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null;
|
||||
const dragMarker = tableElement instanceof HTMLElement ? getRowDragMarker(tableElement) : null;
|
||||
|
||||
const handleFinish = (): void => {
|
||||
// Clean up markers if they exist
|
||||
if (dropMarker && dragMarker) {
|
||||
hideDropMarker(dropMarker);
|
||||
hideDragMarker(dragMarker);
|
||||
}
|
||||
|
||||
if (isCellSelection(editor.state.selection)) {
|
||||
updateCellContentVisibility(editor, false);
|
||||
}
|
||||
|
||||
// Perform drag operation if user moved
|
||||
if (row !== dropIndex && hasMoved) {
|
||||
let tr = editor.state.tr;
|
||||
const selection = editor.state.selection;
|
||||
if (isCellSelection(selection)) {
|
||||
const table = findTable(selection);
|
||||
if (table) {
|
||||
tr = moveSelectedRows(editor, table, selection, dropIndex, tr);
|
||||
}
|
||||
}
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
window.removeEventListener("mouseup", handleFinish);
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
dragListeners.mouseup = undefined;
|
||||
dragListeners.mousemove = undefined;
|
||||
|
||||
// If it was just a click (no movement), toggle dropdown
|
||||
if (!hasMoved) {
|
||||
toggleDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
let pseudoRow: HTMLElement | undefined;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent): void => {
|
||||
// Mark that we've moved
|
||||
const deltaX = Math.abs(moveEvent.clientX - startX);
|
||||
const deltaY = Math.abs(moveEvent.clientY - startY);
|
||||
if (deltaX > 3 || deltaY > 3) {
|
||||
hasMoved = true;
|
||||
}
|
||||
|
||||
// Calculate drop index
|
||||
const cursorTop = startTop + moveEvent.clientY - startYPos;
|
||||
dropIndex = calculateRowDropIndex(row, rows, cursorTop);
|
||||
|
||||
// Update visual markers if they exist
|
||||
if (dropMarker && dragMarker) {
|
||||
if (!pseudoRow) {
|
||||
pseudoRow = constructRowDragPreview(editor, editor.state.selection, table);
|
||||
const tableWidthPx = getTableWidthPx(table, editor);
|
||||
if (pseudoRow) {
|
||||
pseudoRow.style.width = `${tableWidthPx}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const dragMarkerHeightPx = rows[row].height;
|
||||
const dragMarkerTopPx = Math.max(0, Math.min(cursorTop, tableHeightPx - dragMarkerHeightPx));
|
||||
const dropMarkerTopPx = dropIndex <= row ? rows[dropIndex].top : rows[dropIndex].top + rows[dropIndex].height;
|
||||
|
||||
updateRowDropMarker({
|
||||
element: dropMarker,
|
||||
top: dropMarkerTopPx - DROP_MARKER_THICKNESS / 2,
|
||||
height: DROP_MARKER_THICKNESS,
|
||||
});
|
||||
updateRowDragMarker({
|
||||
element: dragMarker,
|
||||
top: dragMarkerTopPx,
|
||||
height: dragMarkerHeightPx,
|
||||
pseudoRow,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
dragListeners.mouseup = handleFinish;
|
||||
dragListeners.mousemove = handleMove;
|
||||
window.addEventListener("mouseup", handleFinish);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
} catch (error) {
|
||||
console.error("Error in RowDragHandle:", error);
|
||||
handleFinish();
|
||||
}
|
||||
};
|
||||
|
||||
// Attach mousedown listener
|
||||
button.addEventListener("mousedown", handleMouseDown);
|
||||
|
||||
// Cleanup function
|
||||
const destroy = () => {
|
||||
// Close dropdown if open
|
||||
if (isDropdownOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
// Remove drag listeners
|
||||
if (dragListeners.mouseup) {
|
||||
window.removeEventListener("mouseup", dragListeners.mouseup);
|
||||
}
|
||||
if (dragListeners.mousemove) {
|
||||
window.removeEventListener("mousemove", dragListeners.mousemove);
|
||||
}
|
||||
|
||||
// Remove mousedown listener
|
||||
button.removeEventListener("mousedown", handleMouseDown);
|
||||
|
||||
// Remove DOM elements
|
||||
container.remove();
|
||||
};
|
||||
|
||||
return {
|
||||
element: container,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
FloatingOverlay,
|
||||
FloatingPortal,
|
||||
shift,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
useRole,
|
||||
} from "@floating-ui/react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
getTableHeightPx,
|
||||
getTableWidthPx,
|
||||
isCellSelection,
|
||||
selectRow,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { moveSelectedRows } from "../actions";
|
||||
import {
|
||||
DROP_MARKER_THICKNESS,
|
||||
getDropMarker,
|
||||
getRowDragMarker,
|
||||
hideDragMarker,
|
||||
hideDropMarker,
|
||||
updateRowDragMarker,
|
||||
updateRowDropMarker,
|
||||
} from "../marker-utils";
|
||||
import { updateCellContentVisibility } from "../utils";
|
||||
import { RowOptionsDropdown } from "./dropdown";
|
||||
import { calculateRowDropIndex, constructRowDragPreview, getTableRowNodesInfo } from "./utils";
|
||||
|
||||
export type RowDragHandleProps = {
|
||||
editor: Editor;
|
||||
row: number;
|
||||
};
|
||||
|
||||
export function RowDragHandle(props: RowDragHandleProps) {
|
||||
const { editor, row } = props;
|
||||
// states
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
// Track active event listeners for cleanup
|
||||
const activeListenersRef = useRef<{
|
||||
mouseup?: (e: MouseEvent) => void;
|
||||
mousemove?: (e: MouseEvent) => void;
|
||||
}>({});
|
||||
|
||||
// Cleanup window event listeners on unmount
|
||||
useEffect(() => {
|
||||
const listenersRef = activeListenersRef.current;
|
||||
return () => {
|
||||
// Remove any lingering window event listeners when component unmounts
|
||||
if (listenersRef.mouseup) {
|
||||
window.removeEventListener("mouseup", listenersRef.mouseup);
|
||||
}
|
||||
if (listenersRef.mousemove) {
|
||||
window.removeEventListener("mousemove", listenersRef.mousemove);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
// floating ui
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
placement: "bottom-start",
|
||||
middleware: [
|
||||
flip({
|
||||
fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"],
|
||||
}),
|
||||
shift({
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
open: isDropdownOpen,
|
||||
onOpenChange: (open) => {
|
||||
setIsDropdownOpen(open);
|
||||
if (open) {
|
||||
editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context);
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
context.onOpenChange(false);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isDropdownOpen, context]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Prevent multiple simultaneous drag operations
|
||||
// If there are already listeners attached, remove them first
|
||||
if (activeListenersRef.current.mouseup) {
|
||||
window.removeEventListener("mouseup", activeListenersRef.current.mouseup);
|
||||
}
|
||||
if (activeListenersRef.current.mousemove) {
|
||||
window.removeEventListener("mousemove", activeListenersRef.current.mousemove);
|
||||
}
|
||||
activeListenersRef.current.mouseup = undefined;
|
||||
activeListenersRef.current.mousemove = undefined;
|
||||
|
||||
const table = findTable(editor.state.selection);
|
||||
if (!table) return;
|
||||
|
||||
editor.view.dispatch(selectRow(table, row, editor.state.tr));
|
||||
|
||||
// drag row
|
||||
const tableHeightPx = getTableHeightPx(table, editor);
|
||||
const rows = getTableRowNodesInfo(table, editor);
|
||||
|
||||
let dropIndex = row;
|
||||
const startTop = rows[row].top ?? 0;
|
||||
const startY = e.clientY;
|
||||
const tableElement = editor.view.nodeDOM(table.pos);
|
||||
|
||||
const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null;
|
||||
const dragMarker = tableElement instanceof HTMLElement ? getRowDragMarker(tableElement) : null;
|
||||
|
||||
const handleFinish = (): void => {
|
||||
if (!dropMarker || !dragMarker) return;
|
||||
hideDropMarker(dropMarker);
|
||||
hideDragMarker(dragMarker);
|
||||
|
||||
if (isCellSelection(editor.state.selection)) {
|
||||
updateCellContentVisibility(editor, false);
|
||||
}
|
||||
|
||||
if (row !== dropIndex) {
|
||||
let tr = editor.state.tr;
|
||||
const selection = editor.state.selection;
|
||||
if (isCellSelection(selection)) {
|
||||
const table = findTable(selection);
|
||||
if (table) {
|
||||
tr = moveSelectedRows(editor, table, selection, dropIndex, tr);
|
||||
}
|
||||
}
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
window.removeEventListener("mouseup", handleFinish);
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
// Clear the ref
|
||||
activeListenersRef.current.mouseup = undefined;
|
||||
activeListenersRef.current.mousemove = undefined;
|
||||
};
|
||||
|
||||
let pseudoRow: HTMLElement | undefined;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent): void => {
|
||||
if (!dropMarker || !dragMarker) return;
|
||||
const cursorTop = startTop + moveEvent.clientY - startY;
|
||||
dropIndex = calculateRowDropIndex(row, rows, cursorTop);
|
||||
|
||||
if (!pseudoRow) {
|
||||
pseudoRow = constructRowDragPreview(editor, editor.state.selection, table);
|
||||
const tableWidthPx = getTableWidthPx(table, editor);
|
||||
if (pseudoRow) {
|
||||
pseudoRow.style.width = `${tableWidthPx}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const dragMarkerHeightPx = rows[row].height;
|
||||
const dragMarkerTopPx = Math.max(0, Math.min(cursorTop, tableHeightPx - dragMarkerHeightPx));
|
||||
const dropMarkerTopPx = dropIndex <= row ? rows[dropIndex].top : rows[dropIndex].top + rows[dropIndex].height;
|
||||
|
||||
updateRowDropMarker({
|
||||
element: dropMarker,
|
||||
top: dropMarkerTopPx - DROP_MARKER_THICKNESS / 2,
|
||||
height: DROP_MARKER_THICKNESS,
|
||||
});
|
||||
updateRowDragMarker({
|
||||
element: dragMarker,
|
||||
top: dragMarkerTopPx,
|
||||
height: dragMarkerHeightPx,
|
||||
pseudoRow,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
// Store references for cleanup
|
||||
activeListenersRef.current.mouseup = handleFinish;
|
||||
activeListenersRef.current.mousemove = handleMove;
|
||||
window.addEventListener("mouseup", handleFinish);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
} catch (error) {
|
||||
console.error("Error in RowDragHandle:", error);
|
||||
handleFinish();
|
||||
}
|
||||
},
|
||||
[editor, row]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-row-handle-container absolute z-20 top-0 left-0 flex justify-center items-center h-full -translate-x-1/2">
|
||||
<button
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
type="button"
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn("py-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200", {
|
||||
"!opacity-100 bg-accent-primary border-accent-strong": isDropdownOpen,
|
||||
"hover:bg-layer-1-hover": !isDropdownOpen,
|
||||
})}
|
||||
>
|
||||
<Ellipsis className="size-4 text-primary rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
{isDropdownOpen && (
|
||||
<FloatingPortal>
|
||||
{/* Backdrop */}
|
||||
<FloatingOverlay
|
||||
style={{
|
||||
zIndex: 99,
|
||||
}}
|
||||
lockScroll
|
||||
/>
|
||||
<div
|
||||
className="max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200"
|
||||
ref={refs.setFloating}
|
||||
{...getFloatingProps()}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<RowOptionsDropdown editor={editor} onClose={() => context.onOpenChange(false)} />
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
@@ -9,20 +10,16 @@ import {
|
||||
haveTableRelatedChanges,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { createRowDragHandle } from "./drag-handle";
|
||||
|
||||
type DragHandleInstance = {
|
||||
element: HTMLElement;
|
||||
destroy: () => void;
|
||||
};
|
||||
import type { RowDragHandleProps } from "./drag-handle";
|
||||
import { RowDragHandle } from "./drag-handle";
|
||||
|
||||
type TableRowDragHandlePluginState = {
|
||||
decorations?: DecorationSet;
|
||||
// track table structure to detect changes
|
||||
tableHeight?: number;
|
||||
tableNodePos?: number;
|
||||
// track drag handle instances for cleanup
|
||||
dragHandles?: DragHandleInstance[];
|
||||
// track renderers for cleanup
|
||||
renderers?: ReactRenderer[];
|
||||
};
|
||||
|
||||
const TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY = new PluginKey("tableRowDragHandlePlugin");
|
||||
@@ -63,40 +60,43 @@ export const TableRowDragHandlePlugin = (editor: Editor): Plugin<TableRowDragHan
|
||||
decorations: mapped,
|
||||
tableHeight: tableMap.height,
|
||||
tableNodePos: table.pos,
|
||||
dragHandles: prev.dragHandles,
|
||||
renderers: prev.renderers,
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up old drag handles before creating new ones
|
||||
prev.dragHandles?.forEach((handle) => {
|
||||
// Clean up old renderers before creating new ones
|
||||
prev.renderers?.forEach((renderer) => {
|
||||
try {
|
||||
handle.destroy();
|
||||
renderer.destroy();
|
||||
} catch (error) {
|
||||
console.error("Error destroying drag handle:", error);
|
||||
console.error("Error destroying renderer:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// recreate all decorations
|
||||
const decorations: Decoration[] = [];
|
||||
const dragHandles: DragHandleInstance[] = [];
|
||||
const renderers: ReactRenderer[] = [];
|
||||
|
||||
for (let row = 0; row < tableMap.height; row++) {
|
||||
const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width);
|
||||
|
||||
const dragHandle = createRowDragHandle({
|
||||
const dragHandleComponent = new ReactRenderer(RowDragHandle, {
|
||||
props: {
|
||||
editor,
|
||||
row,
|
||||
} satisfies RowDragHandleProps,
|
||||
editor,
|
||||
row,
|
||||
});
|
||||
|
||||
dragHandles.push(dragHandle);
|
||||
decorations.push(Decoration.widget(pos, () => dragHandle.element));
|
||||
renderers.push(dragHandleComponent);
|
||||
decorations.push(Decoration.widget(pos, () => dragHandleComponent.element));
|
||||
}
|
||||
|
||||
return {
|
||||
decorations: DecorationSet.create(newState.doc, decorations),
|
||||
tableHeight: tableMap.height,
|
||||
tableNodePos: table.pos,
|
||||
dragHandles,
|
||||
renderers,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -107,15 +107,15 @@ export const TableRowDragHandlePlugin = (editor: Editor): Plugin<TableRowDragHan
|
||||
},
|
||||
},
|
||||
destroy() {
|
||||
// Clean up all drag handles when plugin is destroyed
|
||||
// Clean up all renderers when plugin is destroyed
|
||||
const state =
|
||||
editor.state &&
|
||||
(TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY.getState(editor.state) as TableRowDragHandlePluginState | undefined);
|
||||
state?.dragHandles?.forEach((handle: DragHandleInstance) => {
|
||||
state?.renderers?.forEach((renderer: ReactRenderer) => {
|
||||
try {
|
||||
handle.destroy();
|
||||
renderer.destroy();
|
||||
} catch (error) {
|
||||
console.error("Error destroying drag handle:", error);
|
||||
console.error("Error destroying renderer:", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -72,18 +72,6 @@ export const TableInsertPlugin = (editor: Editor): Plugin => {
|
||||
});
|
||||
};
|
||||
|
||||
let updateScheduled = false;
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
if (updateScheduled) return;
|
||||
updateScheduled = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
updateScheduled = false;
|
||||
updateAllTables();
|
||||
});
|
||||
};
|
||||
|
||||
return new Plugin({
|
||||
key: TABLE_INSERT_PLUGIN_KEY,
|
||||
|
||||
@@ -92,9 +80,9 @@ export const TableInsertPlugin = (editor: Editor): Plugin => {
|
||||
|
||||
return {
|
||||
update(view, prevState) {
|
||||
// Debounce updates using RAF to batch multiple changes
|
||||
// Update when document changes
|
||||
if (!prevState.doc.eq(view.state.doc)) {
|
||||
scheduleUpdate();
|
||||
updateAllTables();
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
|
||||
@@ -219,11 +219,16 @@ export const createRowInsertButton = (editor: Editor, tableInfo: TableInfo): HTM
|
||||
|
||||
export const findAllTables = (editor: Editor): TableInfo[] => {
|
||||
const tables: TableInfo[] = [];
|
||||
const tableElements = editor.view.dom.querySelectorAll("table");
|
||||
|
||||
// Iterate through document to look for tables
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.spec.tableRole === "table") {
|
||||
try {
|
||||
tableElements.forEach((tableElement) => {
|
||||
// Find the table's ProseMirror position
|
||||
let tablePos = -1;
|
||||
let tableNode: ProseMirrorNode | null = null;
|
||||
|
||||
// Walk through the document to find matching table nodes
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.spec.tableRole === "table") {
|
||||
const domAtPos = editor.view.domAtPos(pos + 1);
|
||||
let domTable = domAtPos.node;
|
||||
|
||||
@@ -236,17 +241,20 @@ export const findAllTables = (editor: Editor): TableInfo[] => {
|
||||
domTable = domTable.parentNode;
|
||||
}
|
||||
|
||||
if (domTable instanceof HTMLElement && domTable.tagName === "TABLE") {
|
||||
tables.push({
|
||||
tableElement: domTable,
|
||||
tableNode: node,
|
||||
tablePos: pos,
|
||||
});
|
||||
if (domTable === tableElement) {
|
||||
tablePos = pos;
|
||||
tableNode = node;
|
||||
return false; // Stop iteration
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip tables that fail to resolve
|
||||
console.error("Error finding table:", error);
|
||||
}
|
||||
});
|
||||
|
||||
if (tablePos !== -1 && tableNode) {
|
||||
tables.push({
|
||||
tableElement,
|
||||
tableNode,
|
||||
tablePos,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -57,31 +57,7 @@
|
||||
.table-col-handle-container,
|
||||
.table-row-handle-container {
|
||||
& > button {
|
||||
@apply opacity-0 rounded-sm outline-none;
|
||||
|
||||
&.default-state {
|
||||
@apply bg-layer-1 hover:bg-layer-1-hover border border-strong-1;
|
||||
}
|
||||
|
||||
&.open-state {
|
||||
@apply opacity-100! bg-accent-primary border-accent-strong;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-col-handle-container {
|
||||
& > button {
|
||||
@apply px-1;
|
||||
|
||||
svg {
|
||||
@apply rotate-90;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-row-handle-container {
|
||||
& > button {
|
||||
@apply py-1;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +160,7 @@
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
outline: none;
|
||||
z-index: 9;
|
||||
z-index: 10;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -321,9 +321,9 @@
|
||||
--txt-link-primary-hover: var(--brand-900);
|
||||
--txt-link-secondary: var(--neutral-900);
|
||||
/* Label colors */
|
||||
--label-indigo-bg: var(--extended-color-indigo-50);
|
||||
--label-indigo-bg: var(--extended-color-indigo-100);
|
||||
--label-indigo-bg-strong: var(--extended-color-indigo-700);
|
||||
--label-indigo-hover: var(--extended-color-indigo-100);
|
||||
--label-indigo-hover: var(--extended-color-indigo-300);
|
||||
--label-indigo-icon: var(--extended-color-indigo-700);
|
||||
--label-indigo-text: var(--extended-color-indigo-700);
|
||||
--label-indigo-border: var(--extended-color-indigo-700);
|
||||
@@ -331,7 +331,7 @@
|
||||
--label-emerald-bg: var(--extended-color-emerald-50);
|
||||
--label-emerald-bg-strong: var(--extended-color-emerald-600);
|
||||
--label-emerald-hover: var(--extended-color-emerald-200);
|
||||
--label-emerald-icon: var(--extended-color-emerald-800);
|
||||
--label-emerald-icon: var(--extended-color-emerald-600);
|
||||
--label-emerald-text: var(--extended-color-emerald-800);
|
||||
--label-emerald-border: var(--extended-color-emerald-800);
|
||||
--label-emerald-focus: var(--extended-color-emerald-700);
|
||||
@@ -352,31 +352,10 @@
|
||||
--label-yellow-bg: var(--extended-color-yellow-50);
|
||||
--label-yellow-bg-strong: var(--extended-color-yellow-600);
|
||||
--label-yellow-hover: var(--extended-color-yellow-100);
|
||||
--label-yellow-icon: var(--extended-color-yellow-700);
|
||||
--label-yellow-text: var(--extended-color-yellow-700);
|
||||
--label-yellow-icon: var(--extended-color-yellow-600);
|
||||
--label-yellow-text: var(--extended-color-yellow-600);
|
||||
--label-yellow-border: var(--extended-color-yellow-600);
|
||||
--label-yellow-focus: var(--extended-color-yellow-400);
|
||||
--label-orange-bg: var(--extended-color-orange-50);
|
||||
--label-orange-bg-strong: var(--extended-color-orange-600);
|
||||
--label-orange-hover: var(--extended-color-orange-100);
|
||||
--label-orange-icon: var(--extended-color-orange-600);
|
||||
--label-orange-text: var(--extended-color-orange-600);
|
||||
--label-orange-border: var(--extended-color-orange-600);
|
||||
--label-orange-focus: var(--extended-color-orange-300);
|
||||
--label-pink-bg: var(--extended-color-pink-50);
|
||||
--label-pink-bg-strong: var(--extended-color-pink-600);
|
||||
--label-pink-hover: var(--extended-color-pink-100);
|
||||
--label-pink-icon: var(--extended-color-pink-600);
|
||||
--label-pink-text: var(--extended-color-pink-600);
|
||||
--label-pink-border: var(--extended-color-pink-600);
|
||||
--label-pink-focus: var(--extended-color-pink-400);
|
||||
--label-purple-bg: var(--extended-color-purple-50);
|
||||
--label-purple-bg-strong: var(--extended-color-purple-500);
|
||||
--label-purple-hover: var(--extended-color-purple-100);
|
||||
--label-purple-icon: var(--extended-color-purple-500);
|
||||
--label-purple-text: var(--extended-color-purple-600);
|
||||
--label-purple-border: var(--extended-color-purple-600);
|
||||
--label-purple-focus: var(--extended-color-purple-300);
|
||||
/* Illustration colors */
|
||||
--illustration-fill-primary: var(--neutral-white);
|
||||
--illustration-fill-secondary: var(--neutral-200);
|
||||
@@ -571,62 +550,41 @@
|
||||
--txt-link-primary-hover: var(--brand-700);
|
||||
--txt-link-secondary: var(--neutral-1100);
|
||||
/* Label colors */
|
||||
--label-indigo-bg: var(--extended-color-indigo-900);
|
||||
--label-indigo-bg: var(--extended-color-indigo-800);
|
||||
--label-indigo-bg-strong: var(--extended-color-indigo-500);
|
||||
--label-indigo-hover: var(--extended-color-indigo-800);
|
||||
--label-indigo-icon: var(--extended-color-indigo-400);
|
||||
--label-indigo-text: var(--extended-color-indigo-400);
|
||||
--label-indigo-border: var(--extended-color-indigo-400);
|
||||
--label-indigo-hover: var(--extended-color-indigo-700);
|
||||
--label-indigo-icon: var(--extended-color-indigo-500);
|
||||
--label-indigo-text: var(--extended-color-indigo-500);
|
||||
--label-indigo-border: var(--extended-color-indigo-500);
|
||||
--label-indigo-focus: var(--extended-color-indigo-400);
|
||||
--label-emerald-bg: var(--extended-color-emerald-950);
|
||||
--label-emerald-bg: var(--extended-color-emerald-700);
|
||||
--label-emerald-bg-strong: var(--extended-color-emerald-600);
|
||||
--label-emerald-hover: var(--extended-color-emerald-900);
|
||||
--label-emerald-icon: var(--extended-color-emerald-400);
|
||||
--label-emerald-hover: var(--extended-color-emerald-800);
|
||||
--label-emerald-icon: var(--extended-color-emerald-600);
|
||||
--label-emerald-text: var(--extended-color-emerald-400);
|
||||
--label-emerald-border: var(--extended-color-emerald-400);
|
||||
--label-emerald-focus: var(--extended-color-emerald-700);
|
||||
--label-grey-bg: var(--extended-color-grey-900);
|
||||
--label-grey-bg: var(--extended-color-grey-800);
|
||||
--label-grey-bg-strong: var(--extended-color-grey-500);
|
||||
--label-grey-hover: var(--extended-color-grey-800);
|
||||
--label-grey-icon: var(--extended-color-grey-400);
|
||||
--label-grey-text: var(--extended-color-grey-400);
|
||||
--label-grey-border: var(--extended-color-grey-400);
|
||||
--label-grey-hover: var(--extended-color-grey-700);
|
||||
--label-grey-icon: var(--extended-color-grey-500);
|
||||
--label-grey-text: var(--extended-color-grey-500);
|
||||
--label-grey-border: var(--extended-color-grey-500);
|
||||
--label-grey-focus: var(--extended-color-grey-400);
|
||||
--label-crimson-bg: var(--extended-color-crimson-950);
|
||||
--label-crimson-bg: var(--extended-color-crimson-800);
|
||||
--label-crimson-bg-strong: var(--extended-color-crimson-500);
|
||||
--label-crimson-hover: var(--extended-color-crimson-900);
|
||||
--label-crimson-icon: var(--extended-color-crimson-400);
|
||||
--label-crimson-text: var(--extended-color-crimson-400);
|
||||
--label-crimson-border: var(--extended-color-crimson-400);
|
||||
--label-crimson-hover: var(--extended-color-crimson-700);
|
||||
--label-crimson-icon: var(--extended-color-crimson-500);
|
||||
--label-crimson-text: var(--extended-color-crimson-500);
|
||||
--label-crimson-border: var(--extended-color-crimson-500);
|
||||
--label-crimson-focus: var(--extended-color-crimson-400);
|
||||
--label-yellow-bg: var(--extended-color-yellow-950);
|
||||
--label-yellow-bg: var(--extended-color-yellow-900);
|
||||
--label-yellow-bg-strong: var(--extended-color-yellow-500);
|
||||
--label-yellow-hover: var(--extended-color-yellow-900);
|
||||
--label-yellow-hover: var(--extended-color-yellow-800);
|
||||
--label-yellow-icon: var(--extended-color-yellow-500);
|
||||
--label-yellow-text: var(--extended-color-yellow-500);
|
||||
--label-yellow-border: var(--extended-color-yellow-500);
|
||||
--label-yellow-focus: var(--extended-color-yellow-400);
|
||||
--label-orange-bg: var(--extended-color-orange-950);
|
||||
--label-orange-bg-strong: var(--extended-color-orange-400);
|
||||
--label-orange-hover: var(--extended-color-orange-900);
|
||||
--label-orange-icon: var(--extended-color-orange-300);
|
||||
--label-orange-text: var(--extended-color-orange-300);
|
||||
--label-orange-border: var(--extended-color-orange-300);
|
||||
--label-orange-focus: var(--extended-color-orange-300);
|
||||
--label-pink-bg: var(--extended-color-pink-900);
|
||||
--label-pink-bg-strong: var(--extended-color-pink-500);
|
||||
--label-pink-hover: var(--extended-color-pink-800);
|
||||
--label-pink-icon: var(--extended-color-pink-400);
|
||||
--label-pink-text: var(--extended-color-pink-400);
|
||||
--label-pink-border: var(--extended-color-pink-400);
|
||||
--label-pink-focus: var(--extended-color-pink-400);
|
||||
--label-purple-bg: var(--extended-color-purple-900);
|
||||
--label-purple-bg-strong: var(--extended-color-purple-400);
|
||||
--label-purple-hover: var(--extended-color-purple-800);
|
||||
--label-purple-icon: var(--extended-color-purple-400);
|
||||
--label-purple-text: var(--extended-color-purple-400);
|
||||
--label-purple-border: var(--extended-color-purple-400);
|
||||
--label-purple-focus: var(--extended-color-purple-300);
|
||||
/* Illustration colors */
|
||||
--illustration-fill-primary: var(--neutral-400);
|
||||
--illustration-fill-secondary: var(--neutral-500);
|
||||
@@ -1101,42 +1059,42 @@
|
||||
--text-h4-bold--letter-spacing: var(--tracking-default);
|
||||
--text-h4-bold--font-weight: var(--font-weight-bold);
|
||||
|
||||
--text-h5-regular: var(--text-18);
|
||||
--text-h5-regular: var(--text-16);
|
||||
--text-h5-regular--line-height: 1.2;
|
||||
--text-h5-regular--letter-spacing: var(--tracking-default);
|
||||
--text-h5-regular--font-weight: var(--font-weight-regular);
|
||||
|
||||
--text-h5-medium: var(--text-18);
|
||||
--text-h5-medium: var(--text-16);
|
||||
--text-h5-medium--line-height: 1.2;
|
||||
--text-h5-medium--letter-spacing: var(--tracking-default);
|
||||
--text-h5-medium--font-weight: var(--font-weight-medium);
|
||||
|
||||
--text-h5-semibold: var(--text-18);
|
||||
--text-h5-semibold: var(--text-16);
|
||||
--text-h5-semibold--line-height: 1.2;
|
||||
--text-h5-semibold--letter-spacing: var(--tracking-default);
|
||||
--text-h5-semibold--font-weight: var(--font-weight-semibold);
|
||||
|
||||
--text-h5-bold: var(--text-18);
|
||||
--text-h5-bold: var(--text-16);
|
||||
--text-h5-bold--line-height: 1.2;
|
||||
--text-h5-bold--letter-spacing: var(--tracking-default);
|
||||
--text-h5-bold--font-weight: var(--font-weight-bold);
|
||||
|
||||
--text-h6-regular: var(--text-16);
|
||||
--text-h6-regular: var(--text-14);
|
||||
--text-h6-regular--line-height: 1.2;
|
||||
--text-h6-regular--letter-spacing: var(--tracking-default);
|
||||
--text-h6-regular--font-weight: var(--font-weight-regular);
|
||||
|
||||
--text-h6-medium: var(--text-16);
|
||||
--text-h6-medium: var(--text-14);
|
||||
--text-h6-medium--line-height: 1.2;
|
||||
--text-h6-medium--letter-spacing: var(--tracking-default);
|
||||
--text-h6-medium--font-weight: var(--font-weight-medium);
|
||||
|
||||
--text-h6-semibold: var(--text-16);
|
||||
--text-h6-semibold: var(--text-14);
|
||||
--text-h6-semibold--line-height: 1.2;
|
||||
--text-h6-semibold--letter-spacing: var(--tracking-default);
|
||||
--text-h6-semibold--font-weight: var(--font-weight-semibold);
|
||||
|
||||
--text-h6-bold: var(--text-16);
|
||||
--text-h6-bold: var(--text-14);
|
||||
--text-h6-bold--line-height: 1.2;
|
||||
--text-h6-bold--letter-spacing: var(--tracking-default);
|
||||
--text-h6-bold--font-weight: var(--font-weight-bold);
|
||||
|
||||
@@ -53,6 +53,13 @@ export interface IUserAccount {
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export const NOTIFICATION_VIEW_MODES = [
|
||||
{ key: "full", label: "Full" },
|
||||
{ key: "compact", label: "Compact" },
|
||||
] as const;
|
||||
|
||||
export type TNotificationsViewMode = (typeof NOTIFICATION_VIEW_MODES)[number]["key"];
|
||||
|
||||
export type TUserProfile = {
|
||||
id: string | undefined;
|
||||
user: string | undefined;
|
||||
@@ -76,6 +83,7 @@ export type TUserProfile = {
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
start_of_the_week: EStartOfTheWeek;
|
||||
notification_view_mode: TNotificationsViewMode;
|
||||
};
|
||||
|
||||
export interface IInstanceAdminStatus {
|
||||
|
||||
Reference in New Issue
Block a user