Compare commits

..

3 Commits

Author SHA1 Message Date
Satish Gandham 733b725b5e Extract utility functions out of Storage class 2024-12-24 16:17:00 +05:30
Satish Gandham 3bee051ee9 Fix typos and add comments 2024-12-24 14:32:53 +05:30
Satish Gandham 6b902d458e Add comments to stroage class 2024-12-18 16:00:17 +05:30
383 changed files with 6010 additions and 11565 deletions
+8 -6
View File
@@ -4,11 +4,10 @@ import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
import Link from "next/link";
import { useForm } from "react-hook-form";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// types
import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
// ui
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import {
CodeBlock,
@@ -18,6 +17,8 @@ import {
TControllerInputFormField,
TCopyField,
} from "@/components/common";
// helpers
import { API_BASE_URL, cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
@@ -102,7 +103,8 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
url: originURL,
description: (
<>
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Authorized origin URL</CodeBlock> field{" "}
We will auto-generate this. Paste this into the{" "}
<CodeBlock darkerShade>Authorized origin URL</CodeBlock> field{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
@@ -121,8 +123,8 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
url: `${originURL}/auth/github/callback/`,
description: (
<>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
field{" "}
We will auto-generate this. Paste this into your{" "}
<CodeBlock darkerShade>Authorized Callback URI</CodeBlock> field{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
+2 -2
View File
@@ -5,12 +5,12 @@ import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane internal packages
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
import { resolveGeneralTheme } from "@plane/utils";
// components
import { AuthenticationMethodCard } from "@/components/authentication";
import { PageHeader } from "@/components/common";
// helpers
import { resolveGeneralTheme } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
// icons
+6 -4
View File
@@ -2,11 +2,10 @@ import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
import Link from "next/link";
import { useForm } from "react-hook-form";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// types
import { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
// ui
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import {
CodeBlock,
@@ -16,6 +15,8 @@ import {
TControllerInputFormField,
TCopyField,
} from "@/components/common";
// helpers
import { API_BASE_URL, cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
@@ -116,7 +117,8 @@ export const InstanceGitlabConfigForm: FC<Props> = (props) => {
url: `${originURL}/auth/gitlab/callback/`,
description: (
<>
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Redirect URI</CodeBlock> field of your{" "}
We will auto-generate this. Paste this into the{" "}
<CodeBlock darkerShade>Redirect URI</CodeBlock> field of your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
+4 -3
View File
@@ -3,11 +3,10 @@ import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
import Link from "next/link";
import { useForm } from "react-hook-form";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// types
import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
// ui
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import {
CodeBlock,
@@ -17,6 +16,8 @@ import {
TControllerInputFormField,
TCopyField,
} from "@/components/common";
// helpers
import { API_BASE_URL, cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
+2 -2
View File
@@ -3,10 +3,10 @@
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane internal packages
import { TInstanceConfigurationKeys } from "@plane/types";
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
// plane admin components
+3 -4
View File
@@ -4,11 +4,11 @@ import { ReactNode } from "react";
import { ThemeProvider, useTheme } from "next-themes";
import { SWRConfig } from "swr";
// ui
import { ADMIN_BASE_PATH, DEFAULT_SWR_CONFIG } from "@plane/constants";
import { Toast } from "@plane/ui";
import { resolveGeneralTheme } from "@plane/utils";
// constants
import { SWR_CONFIG } from "@/constants/swr-config";
// helpers
import { ASSET_PREFIX, resolveGeneralTheme } from "@/helpers/common.helper";
// lib
import { InstanceProvider } from "@/lib/instance-provider";
import { StoreProvider } from "@/lib/store-provider";
@@ -22,7 +22,6 @@ const ToastWithTheme = () => {
};
export default function RootLayout({ children }: { children: ReactNode }) {
const ASSET_PREFIX = ADMIN_BASE_PATH;
return (
<html lang="en">
<head>
@@ -35,7 +34,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<body className={`antialiased`}>
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
<ToastWithTheme />
<SWRConfig value={DEFAULT_SWR_CONFIG}>
<SWRConfig value={SWR_CONFIG}>
<StoreProvider>
<InstanceProvider>
<UserProvider>{children}</UserProvider>
+3 -1
View File
@@ -3,11 +3,13 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
// constants
import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
// types
import { IWorkspace } from "@plane/types";
// components
import { Button, CustomSelect, getButtonStyling, Input, setToast, TOAST_TYPE } from "@plane/ui";
// helpers
import { WEB_BASE_URL } from "@/helpers/common.helper";
// hooks
import { useWorkspace } from "@/hooks/store";
// services
+3 -1
View File
@@ -7,10 +7,12 @@ import useSWR from "swr";
import { Loader as LoaderIcon } from "lucide-react";
// types
import { TInstanceConfigurationKeys } from "@plane/types";
// ui
import { Button, getButtonStyling, Loader, setPromiseToast, ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { WorkspaceListItem } from "@/components/workspace";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance, useWorkspace } from "@/hooks/store";
@@ -10,7 +10,7 @@ import {
// components
import { AuthenticationMethodCard } from "@/components/authentication";
// helpers
import { getBaseAuthenticationModes } from "@/lib/auth-helpers";
import { getBaseAuthenticationModes } from "@/helpers/authentication.helper";
// plane admin components
import { UpgradeButton } from "@/plane-admin/components/common";
// images
@@ -3,9 +3,10 @@
import React from "react";
// icons
import { SquareArrowOutUpRight } from "lucide-react";
// plane internal packages
// ui
import { getButtonStyling } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { cn } from "@/helpers/common.helper";
export const UpgradeButton: React.FC = () => (
<a href="https://plane.so/pricing?mode=self-hosted" target="_blank" className={cn(getButtonStyling("primary", "sm"))}>
@@ -5,14 +5,13 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
import { Transition } from "@headlessui/react";
// plane internal packages
import { WEB_BASE_URL } from "@plane/constants";
// ui
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { WEB_BASE_URL, cn } from "@/helpers/common.helper";
// hooks
import { useTheme } from "@/hooks/store";
// assets
// eslint-disable-next-line import/order
import packageJson from "package.json";
const helpOptions = [
@@ -5,10 +5,11 @@ import { observer } from "mobx-react";
import { useTheme as useNextTheme } from "next-themes";
import { LogOut, UserCog2, Palette } from "lucide-react";
import { Menu, Transition } from "@headlessui/react";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// plane ui
import { Avatar } from "@plane/ui";
import { getFileURL, cn } from "@plane/utils";
// helpers
import { API_BASE_URL, cn } from "@/helpers/common.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useTheme, useUser } from "@/hooks/store";
// services
@@ -4,11 +4,11 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
// plane internal packages
import { Tooltip, WorkspaceIcon } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { cn } from "@/helpers/common.helper";
import { useTheme } from "@/hooks/store";
// helpers
const INSTANCE_ADMIN_LINKS = [
{
@@ -1,7 +1,7 @@
import { FC } from "react";
import { Info, X } from "lucide-react";
// plane constants
import { TAuthErrorInfo } from "@plane/constants";
// helpers
import { TAuthErrorInfo } from "@/helpers/authentication.helper";
type TAuthBanner = {
bannerData: TAuthErrorInfo | undefined;
@@ -2,7 +2,7 @@
import { FC } from "react";
// helpers
import { cn } from "@plane/utils";
import { cn } from "helpers/common.helper";
type Props = {
name: string;
@@ -5,10 +5,12 @@ import { observer } from "mobx-react";
import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// plane internal packages
// types
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
// ui
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
@@ -5,10 +5,12 @@ import { observer } from "mobx-react";
import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// plane internal packages
// types
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
// ui
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
@@ -5,10 +5,12 @@ import { observer } from "mobx-react";
import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// plane internal packages
// types
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
// ui
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
+1 -1
View File
@@ -1,4 +1,4 @@
import { cn } from "@plane/utils";
import { cn } from "@/helpers/common.helper";
type TProps = {
children: React.ReactNode;
@@ -4,9 +4,10 @@ import React, { useState } from "react";
import { Controller, Control } from "react-hook-form";
// icons
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
// ui
import { Input } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { cn } from "@/helpers/common.helper";
type Props = {
control: Control<any>;
@@ -36,7 +37,9 @@ export const ControllerInput: React.FC<Props> = (props) => {
return (
<div className="flex flex-col gap-1">
<h4 className="text-sm text-custom-text-300">{label}</h4>
<h4 className="text-sm text-custom-text-300">
{label}
</h4>
<div className="relative">
<Controller
control={control}
@@ -1,9 +1,14 @@
"use client";
import { FC, useMemo } from "react";
// plane internal packages
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { cn, getPasswordStrength } from "@plane/utils";
// import { CircleCheck } from "lucide-react";
// helpers
import { cn } from "@/helpers/common.helper";
import {
E_PASSWORD_STRENGTH,
// PASSWORD_CRITERIA,
getPasswordStrength,
} from "@/helpers/password.helper";
type TPasswordStrengthMeter = {
password: string;
@@ -4,12 +4,13 @@ import { FC, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
// icons
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
// ui
import { Button, Checkbox, Input, Spinner } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
import { Banner, PasswordStrengthMeter } from "@/components/common";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper";
// services
import { AuthService } from "@/services/auth.service";
+12 -6
View File
@@ -2,18 +2,24 @@
import { FC, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
// services
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { Button, Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common";
// helpers
import { authErrorHandler } from "@/lib/auth-helpers";
// services
import {
authErrorHandler,
EAuthenticationErrorCodes,
EErrorAlertType,
TAuthErrorInfo,
} from "@/helpers/authentication.helper";
import { API_BASE_URL } from "@/helpers/common.helper";
import { AuthService } from "@/services/auth.service";
// local components
import { AuthBanner } from "../authentication";
// ui
// icons
// service initialization
const authService = new AuthService();
@@ -96,7 +102,7 @@ export const InstanceSignInForm: FC = (props) => {
useEffect(() => {
if (errorCode) {
const errorDetail = authErrorHandler(errorCode?.toString() as EAdminAuthErrorCodes);
const errorDetail = authErrorHandler(errorCode?.toString() as EAuthenticationErrorCodes);
if (errorDetail) {
setErrorInfo(errorDetail);
}
+1 -1
View File
@@ -1,13 +1,13 @@
"use client";
import React from "react";
import { resolveGeneralTheme } from "helpers/common.helper";
import { observer } from "mobx-react";
import Image from "next/image";
import Link from "next/link";
import { useTheme as nextUseTheme } from "next-themes";
// ui
import { Button, getButtonStyling } from "@plane/ui";
import { resolveGeneralTheme } from "@plane/utils";
// hooks
import { useTheme } from "@/hooks/store";
// icons
@@ -1,9 +1,9 @@
import { observer } from "mobx-react";
import { ExternalLink } from "lucide-react";
// plane internal packages
import { WEB_BASE_URL } from "@plane/constants";
// helpers
import { Tooltip } from "@plane/ui";
import { getFileURL } from "@plane/utils";
import { WEB_BASE_URL } from "@/helpers/common.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useWorkspace } from "@/hooks/store";
+8
View File
@@ -0,0 +1,8 @@
export const SITE_NAME = "Plane | Simple, extensible, open-source project management tool.";
export const SITE_TITLE = "Plane | Simple, extensible, open-source project management tool.";
export const SITE_DESCRIPTION =
"Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.";
export const SITE_KEYWORDS =
"software development, plan, ship, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration";
export const SITE_URL = "https://app.plane.so/";
export const TWITTER_USER_NAME = "Plane | Simple, extensible, open-source project management tool.";
@@ -1,4 +1,4 @@
export const DEFAULT_SWR_CONFIG = {
export const SWR_CONFIG = {
refreshWhenHidden: false,
revalidateIfStale: false,
revalidateOnFocus: false,
-164
View File
@@ -1,164 +0,0 @@
import { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { KeyRound, Mails } from "lucide-react";
// plane packages
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
import { resolveGeneralTheme } from "@plane/utils";
// components
import {
EmailCodesConfiguration,
GithubConfiguration,
GitlabConfiguration,
GoogleConfiguration,
PasswordLoginConfiguration,
} from "@/components/authentication";
// images
import githubLightModeImage from "@/public/logos/github-black.png";
import githubDarkModeImage from "@/public/logos/github-white.png";
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
import GoogleLogo from "@/public/logos/google-logo.svg";
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
INLINE_EMAIL = "INLINE_EMAIL",
INLINE_PASSWORD = "INLINE_PASSWORD",
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
}
const errorCodeMessages: {
[key in EAdminAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
} = {
// admin
[EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: {
title: `Admin already exists`,
message: () => `Admin already exists. Please try again.`,
},
[EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
title: `Email, password and first name required`,
message: () => `Email, password and first name required. Please try again.`,
},
[EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL]: {
title: `Invalid admin email`,
message: () => `Invalid admin email. Please try again.`,
},
[EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD]: {
title: `Invalid admin password`,
message: () => `Invalid admin password. Please try again.`,
},
[EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
title: `Email and password required`,
message: () => `Email and password required. Please try again.`,
},
[EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
title: `Authentication failed`,
message: () => `Authentication failed. Please try again.`,
},
[EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
title: `Admin user already exists`,
message: () => (
<div>
Admin user already exists.&nbsp;
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
Sign In
</Link>
&nbsp;now.
</div>
),
},
[EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
title: `Admin user does not exist`,
message: () => (
<div>
Admin user does not exist.&nbsp;
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
Sign In
</Link>
&nbsp;now.
</div>
),
},
[EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: {
title: `User account deactivated`,
message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
},
};
export const authErrorHandler = (
errorCode: EAdminAuthErrorCodes,
email?: string | undefined
): TAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL,
EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD,
EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED,
EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST,
EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST,
EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED,
];
if (bannerAlertErrorCodes.includes(errorCode))
return {
type: EErrorAlertType.BANNER_ALERT,
code: errorCode,
title: errorCodeMessages[errorCode]?.title || "Error",
message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
};
return undefined;
};
export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
disabled,
updateConfig,
resolvedTheme,
}) => [
{
key: "unique-codes",
name: "Unique codes",
description:
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
icon: <Mails className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "passwords-login",
name: "Passwords",
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
icon: <KeyRound className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "google",
name: "Google",
description: "Allow members to log in or sign up for Plane with their Google accounts.",
icon: <Image src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "github",
name: "GitHub",
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
icon: (
<Image
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
height={20}
width={20}
alt="GitHub Logo"
/>
),
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "gitlab",
name: "GitLab",
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
];
+2 -1
View File
@@ -1,4 +1,5 @@
import { API_BASE_URL } from "@plane/constants";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
+2 -2
View File
@@ -1,5 +1,4 @@
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// types
import type {
IFormattedInstanceConfiguration,
IInstance,
@@ -8,6 +7,7 @@ import type {
IInstanceInfo,
} from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service";
export class InstanceService extends APIService {
+3 -2
View File
@@ -1,6 +1,7 @@
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// types
import type { IUser } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
+3 -2
View File
@@ -1,6 +1,7 @@
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// types
import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
+2 -2
View File
@@ -1,7 +1,5 @@
import set from "lodash/set";
import { observable, action, computed, makeObservable, runInAction } from "mobx";
// plane internal packages
import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
import {
IInstance,
IInstanceAdmin,
@@ -10,6 +8,8 @@ import {
IInstanceInfo,
IInstanceConfig,
} from "@plane/types";
// helpers
import { EInstanceStatus, TInstanceStatus } from "@/helpers/instance.helper";
// services
import { InstanceService } from "@/services/instance.service";
// root store
+2 -2
View File
@@ -1,7 +1,7 @@
import { action, observable, runInAction, makeObservable } from "mobx";
// plane internal packages
import { EUserStatus, TUserStatus } from "@plane/constants";
import { IUser } from "@plane/types";
// helpers
import { EUserStatus, TUserStatus } from "@/helpers/user.helper";
// services
import { AuthService } from "@/services/auth.service";
import { UserService } from "@/services/user.service";
+203
View File
@@ -0,0 +1,203 @@
import { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { KeyRound, Mails } from "lucide-react";
// types
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
// components
import {
EmailCodesConfiguration,
GithubConfiguration,
GitlabConfiguration,
GoogleConfiguration,
PasswordLoginConfiguration,
} from "@/components/authentication";
// helpers
import { SUPPORT_EMAIL, resolveGeneralTheme } from "@/helpers/common.helper";
// images
import githubLightModeImage from "@/public/logos/github-black.png";
import githubDarkModeImage from "@/public/logos/github-white.png";
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
import GoogleLogo from "@/public/logos/google-logo.svg";
export enum EPageTypes {
PUBLIC = "PUBLIC",
NON_AUTHENTICATED = "NON_AUTHENTICATED",
SET_PASSWORD = "SET_PASSWORD",
ONBOARDING = "ONBOARDING",
AUTHENTICATED = "AUTHENTICATED",
}
export enum EAuthModes {
SIGN_IN = "SIGN_IN",
SIGN_UP = "SIGN_UP",
}
export enum EAuthSteps {
EMAIL = "EMAIL",
PASSWORD = "PASSWORD",
UNIQUE_CODE = "UNIQUE_CODE",
}
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
INLINE_EMAIL = "INLINE_EMAIL",
INLINE_PASSWORD = "INLINE_PASSWORD",
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
}
export enum EAuthenticationErrorCodes {
// Admin
ADMIN_ALREADY_EXIST = "5150",
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
INVALID_ADMIN_EMAIL = "5160",
INVALID_ADMIN_PASSWORD = "5165",
REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
ADMIN_AUTHENTICATION_FAILED = "5175",
ADMIN_USER_ALREADY_EXIST = "5180",
ADMIN_USER_DOES_NOT_EXIST = "5185",
ADMIN_USER_DEACTIVATED = "5190",
}
export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAuthenticationErrorCodes;
title: string;
message: ReactNode;
};
const errorCodeMessages: {
[key in EAuthenticationErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
} = {
// admin
[EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: {
title: `Admin already exists`,
message: () => `Admin already exists. Please try again.`,
},
[EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
title: `Email, password and first name required`,
message: () => `Email, password and first name required. Please try again.`,
},
[EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: {
title: `Invalid admin email`,
message: () => `Invalid admin email. Please try again.`,
},
[EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: {
title: `Invalid admin password`,
message: () => `Invalid admin password. Please try again.`,
},
[EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
title: `Email and password required`,
message: () => `Email and password required. Please try again.`,
},
[EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
title: `Authentication failed`,
message: () => `Authentication failed. Please try again.`,
},
[EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
title: `Admin user already exists`,
message: () => (
<div>
Admin user already exists.&nbsp;
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
Sign In
</Link>
&nbsp;now.
</div>
),
},
[EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
title: `Admin user does not exist`,
message: () => (
<div>
Admin user does not exist.&nbsp;
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
Sign In
</Link>
&nbsp;now.
</div>
),
},
[EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED]: {
title: `User account deactivated`,
message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
},
};
export const authErrorHandler = (
errorCode: EAuthenticationErrorCodes,
email?: string | undefined
): TAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST,
EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL,
EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD,
EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD,
EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED,
EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST,
EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST,
EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED,
];
if (bannerAlertErrorCodes.includes(errorCode))
return {
type: EErrorAlertType.BANNER_ALERT,
code: errorCode,
title: errorCodeMessages[errorCode]?.title || "Error",
message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
};
return undefined;
};
export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
disabled,
updateConfig,
resolvedTheme,
}) => [
{
key: "unique-codes",
name: "Unique codes",
description:
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
icon: <Mails className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "passwords-login",
name: "Passwords",
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
icon: <KeyRound className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "google",
name: "Google",
description: "Allow members to log in or sign up for Plane with their Google accounts.",
icon: <Image src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "github",
name: "GitHub",
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
icon: (
<Image
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
height={20}
width={20}
alt="GitHub Logo"
/>
),
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "gitlab",
name: "GitLab",
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
];
+20
View File
@@ -0,0 +1,20 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || "";
export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "";
export const ASSET_PREFIX = ADMIN_BASE_PATH;
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
export const resolveGeneralTheme = (resolvedTheme: string | undefined) =>
resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system";
+14
View File
@@ -0,0 +1,14 @@
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
/**
* @description combine the file path with the base URL
* @param {string} path
* @returns {string} final URL with the base URL
*/
export const getFileURL = (path: string): string | undefined => {
if (!path) return undefined;
const isValidURL = path.startsWith("http");
if (isValidURL) return path;
return `${API_BASE_URL}${path}`;
};
+2
View File
@@ -0,0 +1,2 @@
export * from "./instance.helper";
export * from "./user.helper";
+67
View File
@@ -0,0 +1,67 @@
import zxcvbn from "zxcvbn";
export enum E_PASSWORD_STRENGTH {
EMPTY = "empty",
LENGTH_NOT_VALID = "length_not_valid",
STRENGTH_NOT_VALID = "strength_not_valid",
STRENGTH_VALID = "strength_valid",
}
const PASSWORD_MIN_LENGTH = 8;
// const PASSWORD_NUMBER_REGEX = /\d/;
// const PASSWORD_CHAR_CAPS_REGEX = /[A-Z]/;
// const PASSWORD_SPECIAL_CHAR_REGEX = /[`!@#$%^&*()_\-+=\[\]{};':"\\|,.<>\/?~ ]/;
export const PASSWORD_CRITERIA = [
{
key: "min_8_char",
label: "Min 8 characters",
isCriteriaValid: (password: string) => password.length >= PASSWORD_MIN_LENGTH,
},
// {
// key: "min_1_upper_case",
// label: "Min 1 upper-case letter",
// isCriteriaValid: (password: string) => PASSWORD_NUMBER_REGEX.test(password),
// },
// {
// key: "min_1_number",
// label: "Min 1 number",
// isCriteriaValid: (password: string) => PASSWORD_CHAR_CAPS_REGEX.test(password),
// },
// {
// key: "min_1_special_char",
// label: "Min 1 special character",
// isCriteriaValid: (password: string) => PASSWORD_SPECIAL_CHAR_REGEX.test(password),
// },
];
export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => {
let passwordStrength: E_PASSWORD_STRENGTH = E_PASSWORD_STRENGTH.EMPTY;
if (!password || password === "" || password.length <= 0) {
return passwordStrength;
}
if (password.length >= PASSWORD_MIN_LENGTH) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
} else {
passwordStrength = E_PASSWORD_STRENGTH.LENGTH_NOT_VALID;
return passwordStrength;
}
const passwordCriteriaValidation = PASSWORD_CRITERIA.map((criteria) => criteria.isCriteriaValid(password)).every(
(criterion) => criterion
);
const passwordStrengthScore = zxcvbn(password).score;
if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
return passwordStrength;
}
if (passwordCriteriaValidation === true && passwordStrengthScore >= 3) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_VALID;
}
return passwordStrength;
};
+21
View File
@@ -0,0 +1,21 @@
/**
* @description
* This function test whether a URL is valid or not.
*
* It accepts URLs with or without the protocol.
* @param {string} url
* @returns {boolean}
* @example
* checkURLValidity("https://example.com") => true
* checkURLValidity("example.com") => true
* checkURLValidity("example") => false
*/
export const checkURLValidity = (url: string): boolean => {
if (!url) return false;
// regex to support complex query parameters and fragments
const urlPattern =
/^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i;
return urlPattern.test(url);
};
+1
View File
@@ -5,6 +5,7 @@
"baseUrl": ".",
"paths": {
"@/*": ["core/*"],
"@/helpers/*": ["helpers/*"],
"@/public/*": ["public/*"],
"@/plane-admin/*": ["ce/*"]
}
+4 -24
View File
@@ -237,37 +237,17 @@ class IssueSerializer(BaseSerializer):
from .user import UserLiteSerializer
data["assignees"] = UserLiteSerializer(
User.objects.filter(
pk__in=IssueAssignee.objects.filter(issue=instance).values_list(
"assignee_id", flat=True
)
),
many=True,
instance.assignees.all(), many=True
).data
else:
data["assignees"] = [
str(assignee)
for assignee in IssueAssignee.objects.filter(
issue=instance
).values_list("assignee_id", flat=True)
str(assignee.id) for assignee in instance.assignees.all()
]
if "labels" in self.fields:
if "labels" in self.expand:
data["labels"] = LabelSerializer(
Label.objects.filter(
pk__in=IssueLabel.objects.filter(issue=instance).values_list(
"label_id", flat=True
)
),
many=True,
).data
data["labels"] = LabelSerializer(instance.labels.all(), many=True).data
else:
data["labels"] = [
str(label)
for label in IssueLabel.objects.filter(issue=instance).values_list(
"label_id", flat=True
)
]
data["labels"] = [str(label.id) for label in instance.labels.all()]
return data
+11
View File
@@ -109,6 +109,16 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
)
# Create or get state
state, _ = State.objects.get_or_create(
name="Triage",
group="triage",
description="Default state for managing all Intake Issues",
project_id=project_id,
color="#ff7700",
is_triage=True,
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
@@ -118,6 +128,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
),
priority=request.data.get("issue", {}).get("priority", "none"),
project_id=project_id,
state=state,
)
# create an intake issue
+1 -3
View File
@@ -258,9 +258,7 @@ class ProjectAPIEndpoint(BaseAPIView):
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
intake_view = request.data.get(
"inbox_view", request.data.get("intake_view", project.intake_view)
)
intake_view = request.data.get("inbox_view", project.intake_view)
if project.archived_at:
return Response(
-2
View File
@@ -17,7 +17,6 @@ from .user import urlpatterns as user_urls
from .views import urlpatterns as view_urls
from .webhook import urlpatterns as webhook_urls
from .workspace import urlpatterns as workspace_urls
from .timezone import urlpatterns as timezone_urls
urlpatterns = [
*analytic_urls,
@@ -39,5 +38,4 @@ urlpatterns = [
*workspace_urls,
*api_urls,
*webhook_urls,
*timezone_urls,
]
+1 -6
View File
@@ -1,7 +1,7 @@
from django.urls import path
from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint, SearchEndpoint
from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint
urlpatterns = [
@@ -15,9 +15,4 @@ urlpatterns = [
IssueSearchEndpoint.as_view(),
name="project-issue-search",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/entity-search/",
SearchEndpoint.as_view(),
name="entity-search",
),
]
-8
View File
@@ -1,8 +0,0 @@
from django.urls import path
from plane.app.views import TimezoneEndpoint
urlpatterns = [
# timezone endpoint
path("timezones/", TimezoneEndpoint.as_view(), name="timezone-list")
]
+1 -3
View File
@@ -158,7 +158,7 @@ from .page.base import (
)
from .page.version import PageVersionEndpoint
from .search.base import GlobalSearchEndpoint, SearchEndpoint
from .search.base import GlobalSearchEndpoint
from .search.issue import IssueSearchEndpoint
@@ -204,5 +204,3 @@ from .error_404 import custom_404_view
from .notification.base import MarkAllReadNotificationViewSet
from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint
from .timezone.base import TimezoneEndpoint
+2 -228
View File
@@ -2,21 +2,10 @@
import re
# Django imports
from django.db import models
from django.db.models import (
Q,
OuterRef,
Subquery,
Value,
UUIDField,
CharField,
When,
Case,
)
from django.db.models import Q, OuterRef, Subquery, Value, UUIDField, CharField
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce, Concat
from django.utils import timezone
from django.db.models.functions import Coalesce
# Third party imports
from rest_framework import status
@@ -32,7 +21,6 @@ from plane.db.models import (
Module,
Page,
IssueView,
ProjectMember,
ProjectPage,
)
@@ -249,217 +237,3 @@ class GlobalSearchEndpoint(BaseAPIView):
func = MODELS_MAPPER.get(model, None)
results[model] = func(query, slug, project_id, workspace_search)
return Response({"results": results}, status=status.HTTP_200_OK)
class SearchEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
query = request.query_params.get("query", False)
query_types = request.query_params.get("query_type", "user_mention").split(",")
query_types = [qt.strip() for qt in query_types]
count = int(request.query_params.get("count", 5))
response_data = {}
for query_type in query_types:
if query_type == "user_mention":
fields = [
"member__first_name",
"member__last_name",
"member__display_name",
]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
users = (
ProjectMember.objects.filter(
q, is_active=True, project_id=project_id, workspace__slug=slug, member__is_bot=False
)
.annotate(
member__avatar_url=Case(
When(
member__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"member__avatar_asset",
Value("/"),
),
),
When(
member__avatar_asset__isnull=True, then="member__avatar"
),
default=Value(None),
output_field=models.CharField(),
)
)
.order_by("-created_at")
.values("member__avatar_url", "member__display_name", "member__id")[
:count
]
)
response_data["user_mention"] = list(users)
elif query_type == "project":
fields = ["name", "identifier"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user)
| Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name", "id", "identifier", "logo_props", "workspace__slug"
)[:count]
)
response_data["project"] = list(projects)
elif query_type == "issue":
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
if query:
for field in fields:
if field == "sequence_id":
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
issues = (
Issue.issue_objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
project_id=project_id,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"priority",
"state_id",
"type_id",
)[:count]
)
response_data["issue"] = list(issues)
elif query_type == "cycle":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
cycles = (
Cycle.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
)
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
output_field=CharField(),
)
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"status",
"workspace__slug",
)[:count]
)
response_data["cycle"] = list(cycles)
elif query_type == "module":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
modules = (
Module.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"status",
"workspace__slug",
)[:count]
)
response_data["module"] = list(modules)
elif query_type == "page":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = (
Page.objects.filter(
q,
projects__project_projectmember__member=self.request.user,
projects__project_projectmember__is_active=True,
projects__id=project_id,
workspace__slug=slug,
access=0,
)
.order_by("-created_at")
.distinct()
.values(
"name", "id", "logo_props", "projects__id", "workspace__slug"
)[:count]
)
response_data["page"] = list(pages)
else:
return Response(
{"error": f"Invalid query type: {query_type}"},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(response_data, status=status.HTTP_200_OK)
-247
View File
@@ -1,247 +0,0 @@
# Python imports
import pytz
from datetime import datetime
# Django imports
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
# Module imports
from plane.authentication.rate_limit import AuthenticationThrottle
class TimezoneEndpoint(APIView):
permission_classes = [AllowAny]
throttle_classes = [AuthenticationThrottle]
@method_decorator(cache_page(60 * 60 * 24))
def get(self, request):
timezone_mapping = {
"-1100": [
("Midway Island", "Pacific/Midway"),
("American Samoa", "Pacific/Pago_Pago"),
],
"-1000": [
("Hawaii", "Pacific/Honolulu"),
("Aleutian Islands", "America/Adak"),
],
"-0930": [("Marquesas Islands", "Pacific/Marquesas")],
"-0900": [
("Alaska", "America/Anchorage"),
("Gambier Islands", "Pacific/Gambier"),
],
"-0800": [
("Pacific Time (US and Canada)", "America/Los_Angeles"),
("Baja California", "America/Tijuana"),
],
"-0700": [
("Mountain Time (US and Canada)", "America/Denver"),
("Arizona", "America/Phoenix"),
("Chihuahua, Mazatlan", "America/Chihuahua"),
],
"-0600": [
("Central Time (US and Canada)", "America/Chicago"),
("Saskatchewan", "America/Regina"),
("Guadalajara, Mexico City, Monterrey", "America/Mexico_City"),
("Tegucigalpa, Honduras", "America/Tegucigalpa"),
("Costa Rica", "America/Costa_Rica"),
],
"-0500": [
("Eastern Time (US and Canada)", "America/New_York"),
("Lima", "America/Lima"),
("Bogota", "America/Bogota"),
("Quito", "America/Guayaquil"),
("Chetumal", "America/Cancun"),
],
"-0430": [("Caracas (Old Venezuela Time)", "America/Caracas")],
"-0400": [
("Atlantic Time (Canada)", "America/Halifax"),
("Caracas", "America/Caracas"),
("Santiago", "America/Santiago"),
("La Paz", "America/La_Paz"),
("Manaus", "America/Manaus"),
("Georgetown", "America/Guyana"),
("Bermuda", "Atlantic/Bermuda"),
],
"-0330": [("Newfoundland Time (Canada)", "America/St_Johns")],
"-0300": [
("Buenos Aires", "America/Argentina/Buenos_Aires"),
("Brasilia", "America/Sao_Paulo"),
("Greenland", "America/Godthab"),
("Montevideo", "America/Montevideo"),
("Falkland Islands", "Atlantic/Stanley"),
],
"-0200": [
(
"South Georgia and the South Sandwich Islands",
"Atlantic/South_Georgia",
)
],
"-0100": [
("Azores", "Atlantic/Azores"),
("Cape Verde Islands", "Atlantic/Cape_Verde"),
],
"+0000": [
("Dublin", "Europe/Dublin"),
("Reykjavik", "Atlantic/Reykjavik"),
("Lisbon", "Europe/Lisbon"),
("Monrovia", "Africa/Monrovia"),
("Casablanca", "Africa/Casablanca"),
],
"+0100": [
("Central European Time (Berlin, Rome, Paris)", "Europe/Paris"),
("West Central Africa", "Africa/Lagos"),
("Algiers", "Africa/Algiers"),
("Lagos", "Africa/Lagos"),
("Tunis", "Africa/Tunis"),
],
"+0200": [
("Eastern European Time (Cairo, Helsinki, Kyiv)", "Europe/Kiev"),
("Athens", "Europe/Athens"),
("Jerusalem", "Asia/Jerusalem"),
("Johannesburg", "Africa/Johannesburg"),
("Harare, Pretoria", "Africa/Harare"),
],
"+0300": [
("Moscow Time", "Europe/Moscow"),
("Baghdad", "Asia/Baghdad"),
("Nairobi", "Africa/Nairobi"),
("Kuwait, Riyadh", "Asia/Riyadh"),
],
"+0330": [("Tehran", "Asia/Tehran")],
"+0400": [
("Abu Dhabi", "Asia/Dubai"),
("Baku", "Asia/Baku"),
("Yerevan", "Asia/Yerevan"),
("Astrakhan", "Europe/Astrakhan"),
("Tbilisi", "Asia/Tbilisi"),
("Mauritius", "Indian/Mauritius"),
],
"+0500": [
("Islamabad", "Asia/Karachi"),
("Karachi", "Asia/Karachi"),
("Tashkent", "Asia/Tashkent"),
("Yekaterinburg", "Asia/Yekaterinburg"),
("Maldives", "Indian/Maldives"),
("Chagos", "Indian/Chagos"),
],
"+0530": [
("Chennai", "Asia/Kolkata"),
("Kolkata", "Asia/Kolkata"),
("Mumbai", "Asia/Kolkata"),
("New Delhi", "Asia/Kolkata"),
("Sri Jayawardenepura", "Asia/Colombo"),
],
"+0545": [("Kathmandu", "Asia/Kathmandu")],
"+0600": [
("Dhaka", "Asia/Dhaka"),
("Almaty", "Asia/Almaty"),
("Bishkek", "Asia/Bishkek"),
("Thimphu", "Asia/Thimphu"),
],
"+0630": [
("Yangon (Rangoon)", "Asia/Yangon"),
("Cocos Islands", "Indian/Cocos"),
],
"+0700": [
("Bangkok", "Asia/Bangkok"),
("Hanoi", "Asia/Ho_Chi_Minh"),
("Jakarta", "Asia/Jakarta"),
("Novosibirsk", "Asia/Novosibirsk"),
("Krasnoyarsk", "Asia/Krasnoyarsk"),
],
"+0800": [
("Beijing", "Asia/Shanghai"),
("Singapore", "Asia/Singapore"),
("Perth", "Australia/Perth"),
("Hong Kong", "Asia/Hong_Kong"),
("Ulaanbaatar", "Asia/Ulaanbaatar"),
("Palau", "Pacific/Palau"),
],
"+0845": [("Eucla", "Australia/Eucla")],
"+0900": [
("Tokyo", "Asia/Tokyo"),
("Seoul", "Asia/Seoul"),
("Yakutsk", "Asia/Yakutsk"),
],
"+0930": [
("Adelaide", "Australia/Adelaide"),
("Darwin", "Australia/Darwin"),
],
"+1000": [
("Sydney", "Australia/Sydney"),
("Brisbane", "Australia/Brisbane"),
("Guam", "Pacific/Guam"),
("Vladivostok", "Asia/Vladivostok"),
("Tahiti", "Pacific/Tahiti"),
],
"+1030": [("Lord Howe Island", "Australia/Lord_Howe")],
"+1100": [
("Solomon Islands", "Pacific/Guadalcanal"),
("Magadan", "Asia/Magadan"),
("Norfolk Island", "Pacific/Norfolk"),
("Bougainville Island", "Pacific/Bougainville"),
("Chokurdakh", "Asia/Srednekolymsk"),
],
"+1200": [
("Auckland", "Pacific/Auckland"),
("Wellington", "Pacific/Auckland"),
("Fiji Islands", "Pacific/Fiji"),
("Anadyr", "Asia/Anadyr"),
],
"+1245": [("Chatham Islands", "Pacific/Chatham")],
"+1300": [("Nuku'alofa", "Pacific/Tongatapu"), ("Samoa", "Pacific/Apia")],
"+1400": [("Kiritimati Island", "Pacific/Kiritimati")],
}
timezone_list = []
now = datetime.now()
# Process timezone mapping
for offset, locations in timezone_mapping.items():
sign = "-" if offset.startswith("-") else "+"
hours = offset[1:3]
minutes = offset[3:] if len(offset) > 3 else "00"
for friendly_name, tz_identifier in locations:
try:
tz = pytz.timezone(tz_identifier)
current_offset = now.astimezone(tz).strftime("%z")
# converting and formatting UTC offset to GMT offset
current_utc_offset = now.astimezone(tz).utcoffset()
total_seconds = int(current_utc_offset.total_seconds())
hours_offset = total_seconds // 3600
minutes_offset = abs(total_seconds % 3600) // 60
gmt_offset = (
f"GMT{'+' if hours_offset >= 0 else '-'}"
f"{abs(hours_offset):02}:{minutes_offset:02}"
)
timezone_value = {
"offset": int(current_offset),
"utc_offset": f"UTC{sign}{hours}:{minutes}",
"gmt_offset": gmt_offset,
"value": tz_identifier,
"label": f"{friendly_name}",
}
timezone_list.append(timezone_value)
except pytz.exceptions.UnknownTimeZoneError:
continue
# Sort by offset and then by label
timezone_list.sort(key=lambda x: (x["offset"], x["label"]))
# Remove offset from final output
for tz in timezone_list:
del tz["offset"]
return Response({"timezones": timezone_list}, status=status.HTTP_200_OK)
+9 -9
View File
@@ -32,6 +32,7 @@ from bs4 import BeautifulSoup
def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
aggregated_issue_mentions = []
for mention_id in new_mentions:
aggregated_issue_mentions.append(
IssueMention(
@@ -124,9 +125,7 @@ def extract_mentions(issue_instance):
data = json.loads(issue_instance)
html = data.get("description_html")
soup = BeautifulSoup(html, "html.parser")
mention_tags = soup.find_all(
"mention-component", attrs={"entity_name": "user_mention"}
)
mention_tags = soup.find_all("mention-component", attrs={"target": "users"})
mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags]
@@ -140,9 +139,7 @@ def extract_comment_mentions(comment_value):
try:
mentions = []
soup = BeautifulSoup(comment_value, "html.parser")
mentions_tags = soup.find_all(
"mention-component", attrs={"entity_name": "user_mention"}
)
mentions_tags = soup.find_all("mention-component", attrs={"target": "users"})
for mention_tag in mentions_tags:
mentions.append(mention_tag["entity_identifier"])
return list(set(mentions))
@@ -258,9 +255,12 @@ def notifications(
new_mentions = get_new_mentions(
requested_instance=requested_data, current_instance=current_instance
)
new_mentions = list(
set(new_mentions) & {str(member) for member in project_members}
)
new_mentions = [
str(mention)
for mention in new_mentions
if mention in set(project_members)
]
removed_mention = get_removed_mentions(
requested_instance=requested_data, current_instance=current_instance
)
-1
View File
@@ -20,7 +20,6 @@
"@hocuspocus/extension-logger": "^2.11.3",
"@hocuspocus/extension-redis": "^2.13.5",
"@hocuspocus/server": "^2.11.3",
"@plane/constants": "*",
"@plane/editor": "*",
"@plane/types": "*",
"@sentry/node": "^8.28.0",
-3
View File
@@ -1,3 +0,0 @@
export enum AI_EDITOR_TASKS {
ASK_ANYTHING = "ASK_ANYTHING",
}
+2 -63
View File
@@ -1,36 +1,3 @@
export enum E_PASSWORD_STRENGTH {
EMPTY = "empty",
LENGTH_NOT_VALID = "length_not_valid",
STRENGTH_NOT_VALID = "strength_not_valid",
STRENGTH_VALID = "strength_valid",
}
export const PASSWORD_MIN_LENGTH = 8;
export const PASSWORD_CRITERIA = [
{
key: "min_8_char",
label: "Min 8 characters",
isCriteriaValid: (password: string) =>
password.length >= PASSWORD_MIN_LENGTH,
},
// {
// key: "min_1_upper_case",
// label: "Min 1 upper-case letter",
// isCriteriaValid: (password: string) => PASSWORD_NUMBER_REGEX.test(password),
// },
// {
// key: "min_1_number",
// label: "Min 1 number",
// isCriteriaValid: (password: string) => PASSWORD_CHAR_CAPS_REGEX.test(password),
// },
// {
// key: "min_1_special_char",
// label: "Min 1 special character",
// isCriteriaValid: (password: string) => PASSWORD_SPECIAL_CHAR_REGEX.test(password),
// },
];
export enum EAuthPageTypes {
PUBLIC = "PUBLIC",
NON_AUTHENTICATED = "NON_AUTHENTICATED",
@@ -39,14 +6,6 @@ export enum EAuthPageTypes {
AUTHENTICATED = "AUTHENTICATED",
}
export enum EPageTypes {
INIT = "INIT",
PUBLIC = "PUBLIC",
NON_AUTHENTICATED = "NON_AUTHENTICATED",
ONBOARDING = "ONBOARDING",
AUTHENTICATED = "AUTHENTICATED",
}
export enum EAuthModes {
SIGN_IN = "SIGN_IN",
SIGN_UP = "SIGN_UP",
@@ -58,35 +17,15 @@ export enum EAuthSteps {
UNIQUE_CODE = "UNIQUE_CODE",
}
// TODO: remove this
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
TOAST_ALERT = "TOAST_ALERT",
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
INLINE_EMAIL = "INLINE_EMAIL",
INLINE_PASSWORD = "INLINE_PASSWORD",
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
}
export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAdminAuthErrorCodes;
title: string;
message: any;
};
export enum EAdminAuthErrorCodes {
// Admin
ADMIN_ALREADY_EXIST = "5150",
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
INVALID_ADMIN_EMAIL = "5160",
INVALID_ADMIN_PASSWORD = "5165",
REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
ADMIN_AUTHENTICATION_FAILED = "5175",
ADMIN_USER_ALREADY_EXIST = "5180",
ADMIN_USER_DOES_NOT_EXIST = "5185",
ADMIN_USER_DEACTIVATED = "5190",
}
export enum EAuthErrorCodes {
// Global
INSTANCE_NOT_CONFIGURED = "5000",
@@ -135,7 +74,7 @@ export enum EAuthErrorCodes {
INCORRECT_OLD_PASSWORD = "5135",
MISSING_PASSWORD = "5138",
INVALID_NEW_PASSWORD = "5140",
// set password
// set passowrd
PASSWORD_ALREADY_SET = "5145",
// Admin
ADMIN_ALREADY_EXIST = "5150",
+8 -15
View File
@@ -1,25 +1,18 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "/";
export const API_URL = encodeURI(`${API_BASE_URL}${API_BASE_PATH}`);
// PI Base Url
export const PI_BASE_URL = process.env.NEXT_PUBLIC_PI_BASE_URL || "";
// God Mode Admin App Base Url
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "/";
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}`);
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}/`);
// Publish App Base Url
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "/";
export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}`);
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}/`);
// Live App Base Url
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "/";
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}`);
// Web App Base Url
export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || "";
export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "/";
export const WEB_URL = encodeURI(`${WEB_BASE_URL}${WEB_BASE_PATH}`);
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}/`);
// plane website url
export const WEBSITE_URL =
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
// support email
export const SUPPORT_EMAIL =
process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
-7
View File
@@ -1,11 +1,4 @@
export * from "./ai";
export * from "./auth";
export * from "./endpoints";
export * from "./file";
export * from "./instance";
export * from "./issue";
export * from "./metadata";
export * from "./state";
export * from "./swr";
export * from "./user";
export * from "./workspace";
-139
View File
@@ -1,25 +1,5 @@
import { List, Kanban } from "lucide-react";
export const ALL_ISSUES = "All Issues";
export type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none";
export type TIssueFilterKeys = "priority" | "state" | "labels";
export type TIssueLayout =
| "list"
| "kanban"
| "calendar"
| "spreadsheet"
| "gantt";
export type TIssueFilterPriorityObject = {
key: TIssuePriorities;
title: string;
className: string;
icon: string;
};
export enum EIssueGroupByToServerOptions {
"state" = "state_id",
"priority" = "priority",
@@ -64,122 +44,3 @@ export enum EIssueServiceType {
ISSUES = "issues",
EPICS = "epics",
}
export enum EIssueLayoutTypes {
LIST = "list",
KANBAN = "kanban",
CALENDAR = "calendar",
GANTT = "gantt_chart",
SPREADSHEET = "spreadsheet",
}
export enum EIssuesStoreType {
GLOBAL = "GLOBAL",
PROFILE = "PROFILE",
TEAM = "TEAM",
PROJECT = "PROJECT",
CYCLE = "CYCLE",
MODULE = "MODULE",
TEAM_VIEW = "TEAM_VIEW",
PROJECT_VIEW = "PROJECT_VIEW",
ARCHIVED = "ARCHIVED",
DRAFT = "DRAFT",
DEFAULT = "DEFAULT",
WORKSPACE_DRAFT = "WORKSPACE_DRAFT",
EPIC = "EPIC",
}
export enum EIssueFilterType {
FILTERS = "filters",
DISPLAY_FILTERS = "display_filters",
DISPLAY_PROPERTIES = "display_properties",
KANBAN_FILTERS = "kanban_filters",
}
export enum EIssueCommentAccessSpecifier {
EXTERNAL = "EXTERNAL",
INTERNAL = "INTERNAL",
}
export enum EIssueListRow {
HEADER = "HEADER",
ISSUE = "ISSUE",
NO_ISSUES = "NO_ISSUES",
QUICK_ADD = "QUICK_ADD",
}
export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
[key in TIssueLayout]: Record<"filters", TIssueFilterKeys[]>;
} = {
list: {
filters: ["priority", "state", "labels"],
},
kanban: {
filters: ["priority", "state", "labels"],
},
calendar: {
filters: ["priority", "state", "labels"],
},
spreadsheet: {
filters: ["priority", "state", "labels"],
},
gantt: {
filters: ["priority", "state", "labels"],
},
};
export const ISSUE_PRIORITIES: {
key: TIssuePriorities;
title: string;
}[] = [
{ key: "urgent", title: "Urgent" },
{ key: "high", title: "High" },
{ key: "medium", title: "Medium" },
{ key: "low", title: "Low" },
{ key: "none", title: "None" },
];
export const ISSUE_PRIORITY_FILTERS: TIssueFilterPriorityObject[] = [
{
key: "urgent",
title: "Urgent",
className: "bg-red-500 border-red-500 text-white",
icon: "error",
},
{
key: "high",
title: "High",
className: "text-orange-500 border-custom-border-300",
icon: "signal_cellular_alt",
},
{
key: "medium",
title: "Medium",
className: "text-yellow-500 border-custom-border-300",
icon: "signal_cellular_alt_2_bar",
},
{
key: "low",
title: "Low",
className: "text-green-500 border-custom-border-300",
icon: "signal_cellular_alt_1_bar",
},
{
key: "none",
title: "None",
className: "text-gray-500 border-custom-border-300",
icon: "block",
},
];
export const SITES_ISSUE_LAYOUTS: {
key: TIssueLayout;
title: string;
icon: any;
}[] = [
{ key: "list", title: "List", icon: List },
{ key: "kanban", title: "Kanban", icon: Kanban },
// { key: "calendar", title: "Calendar", icon: Calendar },
// { key: "spreadsheet", title: "Spreadsheet", icon: Sheet },
// { key: "gantt", title: "Gantt chart", icon: GanttChartSquare },
];
-23
View File
@@ -1,23 +0,0 @@
export const SITE_NAME =
"Plane | Simple, extensible, open-source project management tool.";
export const SITE_TITLE =
"Plane | Simple, extensible, open-source project management tool.";
export const SITE_DESCRIPTION =
"Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.";
export const SITE_KEYWORDS =
"software development, plan, ship, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration";
export const SITE_URL = "https://app.plane.so/";
export const TWITTER_USER_NAME =
"Plane | Simple, extensible, open-source project management tool.";
// Plane Sites Metadata
export const SPACE_SITE_NAME =
"Plane Publish | Make your Plane boards and roadmaps pubic with just one-click. ";
export const SPACE_SITE_TITLE =
"Plane Publish | Make your Plane boards public with one-click";
export const SPACE_SITE_DESCRIPTION =
"Plane Publish is a customer feedback management tool built on top of plane.so";
export const SPACE_SITE_KEYWORDS =
"software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration";
export const SPACE_SITE_URL = "https://app.plane.so/";
export const SPACE_TWITTER_USER_NAME = "planepowers";
-1
View File
@@ -37,7 +37,6 @@
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@hocuspocus/provider": "^2.13.5",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@tiptap/core": "^2.1.13",
@@ -10,7 +10,7 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import { EditorReadOnlyRefApi, TDisplayConfig, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TExtensions, TFileHandler } from "@/types";
interface IDocumentReadOnlyEditor {
disabledExtensions: TExtensions[];
@@ -23,7 +23,9 @@ interface IDocumentReadOnlyEditor {
fileHandler: Pick<TFileHandler, "getAssetSrc">;
tabIndex?: number;
handleEditorReady?: (value: boolean) => void;
mentionHandler: TReadOnlyMentionHandler;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
};
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
}
@@ -1,4 +1,4 @@
import { Editor, Extensions } from "@tiptap/core";
import { Editor, Extension } from "@tiptap/core";
// components
import { EditorContainer } from "@/components/editors";
// constants
@@ -12,7 +12,7 @@ import { EditorContentWrapper } from "./editor-content";
type Props = IEditorProps & {
children?: (editor: Editor) => React.ReactNode;
extensions: Extensions;
extensions: Extension<any, any>[];
};
export const EditorWrapper: React.FC<Props> = (props) => {
@@ -1,10 +1,7 @@
import { NodeType } from "@tiptap/pm/model";
import { EditorState, Transaction } from "@tiptap/pm/state";
import { Editor, findParentNode, getNodeType } from "@tiptap/react";
import { Copy, LucideIcon, Repeat2, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
import { Editor } from "@tiptap/react";
import tippy, { Instance } from "tippy.js";
import { MultipleSelection } from "@/extensions/selection/multi-selection-tracker-new";
import { Copy, LucideIcon, Trash2 } from "lucide-react";
interface BlockMenuProps {
editor: Editor;
@@ -146,26 +143,6 @@ export const BlockMenu = (props: BlockMenuProps) => {
popup.current?.hide();
},
},
{
key: "turn-into",
icon: Repeat2,
label: "Turn into Heading 1",
isDisabled: !(editor.view.state.selection instanceof MultipleSelection),
onClick: (e) => {
editor.commands.toggleHeading({ level: 1 });
// toggleBulletList(editor.view.state, editor.view.dispatch);
// const toggleListType = (listType: NodeType) => (state: EditorState, dispatch?: (tr: Transaction) => void) => {
// const { schema, selection } = state;
// console.log("selection", selection instanceof MultipleSelection);
// // apply on all the ranges
// selection.ranges.forEach(({ $from, $to }) => {
// editor.commands.toggleOrderedList();
// });
// };
//
// toggleListType(editor.schema.nodes.bullet_list)(editor.view.state, editor.view.dispatch);
},
},
];
return (
@@ -175,7 +152,7 @@ export const BlockMenu = (props: BlockMenuProps) => {
>
{MENU_ITEMS.map((item) => {
// Skip rendering the button if it should be disabled
if (item.isDisabled) {
if (item.isDisabled && item.key === "duplicate") {
return null;
}
@@ -195,56 +172,3 @@ export const BlockMenu = (props: BlockMenuProps) => {
</div>
);
};
const toggleListType = (listType: NodeType) => (state: EditorState, dispatch?: (tr: Transaction) => void) => {
console.log(listType);
const { schema, selection } = state;
if (!(selection instanceof MultipleSelection)) {
// Fall back to default behavior for non-MultipleSelection
return false;
}
if (dispatch) {
const tr = state.tr;
const ranges = [...selection.ranges].sort((a, b) => a.$from.pos - b.$from.pos);
ranges.forEach(({ $from, $to }) => {
state.tr.setNodeMarkup($from.pos + 1, getNodeType("orderedList", schema));
// state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
// if (node.type === schema.nodes.paragraph) {
// const parentList = findParentNode(
// (p) => p.type === schema.nodes["bulletList"] || p.type === schema.nodes["orderedList"]
// )(state.selection);
//
// if (parentList) {
// if (parentList.node.type !== listType) {
// // Change list type
// tr.setNodeMarkup(parentList.pos, listType);
// }
// } else {
// console.log("should not happen");
// // Wrap in new list
// const listItem = schema.nodes["listItem"].create(null, node);
// const list = listType.create(null, [listItem]);
// tr.replaceRangeWith(pos, pos + node.nodeSize, list);
// }
// }
// return false;
// });
});
if (tr.docChanged) {
dispatch(tr);
return true;
}
}
return false;
};
const toggleBulletList = (state: EditorState, dispatch?: (tr: Transaction) => void) =>
toggleListType(state.schema.nodes["bulletList"])(state, dispatch);
const toggleNumberedList = (state: EditorState, dispatch?: (tr: Transaction) => void) =>
toggleListType(state.schema.nodes["orderedList"])(state, dispatch);
@@ -1,185 +1,3 @@
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
CaseSensitive,
Code2,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
Image,
Italic,
List,
ListOrdered,
ListTodo,
LucideIcon,
Strikethrough,
Table,
TextQuote,
Underline,
} from "lucide-react";
import { TCommandExtraProps, TEditorCommands } from "@/types/editor";
export type TEditorTypes = "lite" | "document";
// Utility type to enforce the necessary extra props or make extraProps optional
export type ExtraPropsForCommand<T extends TEditorCommands> = T extends keyof TCommandExtraProps
? TCommandExtraProps[T]
: object; // Default to empty object for commands without extra props
export type ToolbarMenuItem<T extends TEditorCommands = TEditorCommands> = {
itemKey: T;
renderKey: string;
name: string;
icon: LucideIcon;
shortcut?: string[];
editors: TEditorTypes[];
extraProps?: ExtraPropsForCommand<T>;
};
export const TYPOGRAPHY_ITEMS: ToolbarMenuItem<"text" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6">[] = [
{ itemKey: "text", renderKey: "text", name: "Text", icon: CaseSensitive, editors: ["document"] },
{ itemKey: "h1", renderKey: "h1", name: "Heading 1", icon: Heading1, editors: ["document"] },
{ itemKey: "h2", renderKey: "h2", name: "Heading 2", icon: Heading2, editors: ["document"] },
{ itemKey: "h3", renderKey: "h3", name: "Heading 3", icon: Heading3, editors: ["document"] },
{ itemKey: "h4", renderKey: "h4", name: "Heading 4", icon: Heading4, editors: ["document"] },
{ itemKey: "h5", renderKey: "h5", name: "Heading 5", icon: Heading5, editors: ["document"] },
{ itemKey: "h6", renderKey: "h6", name: "Heading 6", icon: Heading6, editors: ["document"] },
];
export const TEXT_ALIGNMENT_ITEMS: ToolbarMenuItem<"text-align">[] = [
{
itemKey: "text-align",
renderKey: "text-align-left",
name: "Left align",
icon: AlignLeft,
shortcut: ["Cmd", "Shift", "L"],
editors: ["lite", "document"],
extraProps: {
alignment: "left",
},
},
{
itemKey: "text-align",
renderKey: "text-align-center",
name: "Center align",
icon: AlignCenter,
shortcut: ["Cmd", "Shift", "E"],
editors: ["lite", "document"],
extraProps: {
alignment: "center",
},
},
{
itemKey: "text-align",
renderKey: "text-align-right",
name: "Right align",
icon: AlignRight,
shortcut: ["Cmd", "Shift", "R"],
editors: ["lite", "document"],
extraProps: {
alignment: "right",
},
},
];
const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strikethrough">[] = [
{
itemKey: "bold",
renderKey: "bold",
name: "Bold",
icon: Bold,
shortcut: ["Cmd", "B"],
editors: ["lite", "document"],
},
{
itemKey: "italic",
renderKey: "italic",
name: "Italic",
icon: Italic,
shortcut: ["Cmd", "I"],
editors: ["lite", "document"],
},
{
itemKey: "underline",
renderKey: "underline",
name: "Underline",
icon: Underline,
shortcut: ["Cmd", "U"],
editors: ["lite", "document"],
},
{
itemKey: "strikethrough",
renderKey: "strikethrough",
name: "Strikethrough",
icon: Strikethrough,
shortcut: ["Cmd", "Shift", "S"],
editors: ["lite", "document"],
},
];
const LIST_ITEMS: ToolbarMenuItem<"bulleted-list" | "numbered-list" | "to-do-list">[] = [
{
itemKey: "bulleted-list",
renderKey: "bulleted-list",
name: "Bulleted list",
icon: List,
shortcut: ["Cmd", "Shift", "7"],
editors: ["lite", "document"],
},
{
itemKey: "numbered-list",
renderKey: "numbered-list",
name: "Numbered list",
icon: ListOrdered,
shortcut: ["Cmd", "Shift", "8"],
editors: ["lite", "document"],
},
{
itemKey: "to-do-list",
renderKey: "to-do-list",
name: "To-do list",
icon: ListTodo,
shortcut: ["Cmd", "Shift", "9"],
editors: ["lite", "document"],
},
];
export const USER_ACTION_ITEMS: ToolbarMenuItem<"quote" | "code">[] = [
{ itemKey: "quote", renderKey: "quote", name: "Quote", icon: TextQuote, editors: ["lite", "document"] },
{ itemKey: "code", renderKey: "code", name: "Code", icon: Code2, editors: ["lite", "document"] },
];
export const COMPLEX_ITEMS: ToolbarMenuItem<"table" | "image">[] = [
{ itemKey: "table", renderKey: "table", name: "Table", icon: Table, editors: ["document"] },
{ itemKey: "image", renderKey: "image", name: "Image", icon: Image, editors: ["lite", "document"] },
];
export const TOOLBAR_ITEMS: {
[editorType in TEditorTypes]: {
[key: string]: ToolbarMenuItem[];
};
} = {
lite: {
basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("lite")),
alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("lite")),
list: LIST_ITEMS.filter((item) => item.editors.includes("lite")),
userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("lite")),
complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("lite")),
},
document: {
basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("document")),
alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("document")),
list: LIST_ITEMS.filter((item) => item.editors.includes("document")),
userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("document")),
complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("document")),
},
};
export const COLORS_LIST: {
key: string;
label: string;
@@ -13,7 +13,7 @@ import { CustomHorizontalRule } from "./horizontal-rule";
import { ImageExtensionWithoutProps } from "./image";
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props";
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
import { CustomTextAlignExtension } from "./text-align";
@@ -97,7 +97,7 @@ export const CoreEditorExtensionsWithoutProps = [
TableHeader,
TableCell,
TableRow,
CustomMentionExtensionConfig,
CustomMentionWithoutProps(),
CustomTextAlignExtension,
CustomCalloutExtensionConfig,
CustomColorExtension,
@@ -1,5 +1,5 @@
import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
// extensions
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
@@ -14,77 +14,46 @@ type Props = {
toggleFullScreenMode: (val: boolean) => void;
};
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 2;
const ZOOM_SPEED = 0.05;
const ZOOM_STEPS = [0.5, 1, 1.5, 2];
const MAGNIFICATION_VALUES = [0.5, 0.75, 1, 1.5, 1.75, 2];
export const ImageFullScreenAction: React.FC<Props> = (props) => {
const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props;
const { src, width, aspectRatio } = image;
const [magnification, setMagnification] = useState<number>(1);
const [initialMagnification, setInitialMagnification] = useState(1);
const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0 });
const dragOffset = useRef({ x: 0, y: 0 });
// states
const [magnification, setMagnification] = useState(1);
// refs
const modalRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
// derived values
const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]);
const setImageRef = useCallback(
(node: HTMLImageElement | null) => {
if (!node || !isFullScreenEnabled) return;
imgRef.current = node;
const viewportWidth = window.innerWidth * 0.9;
const viewportHeight = window.innerHeight * 0.75;
const imageWidth = widthInNumber;
const imageHeight = imageWidth / aspectRatio;
const widthRatio = viewportWidth / imageWidth;
const heightRatio = viewportHeight / imageHeight;
setInitialMagnification(Math.min(widthRatio, heightRatio));
setMagnification(1);
// Reset image position
node.style.left = "0px";
node.style.top = "0px";
},
[isFullScreenEnabled, widthInNumber, aspectRatio]
);
// close handler
const handleClose = useCallback(() => {
if (isDragging) return;
toggleFullScreenMode(false);
setMagnification(1);
setInitialMagnification(1);
}, [isDragging, toggleFullScreenMode]);
const handleMagnification = useCallback((direction: "increase" | "decrease") => {
setMagnification((prev) => {
// Find the appropriate target zoom level based on current magnification
let targetZoom: number;
if (direction === "increase") {
targetZoom = ZOOM_STEPS.find((step) => step > prev) ?? MAX_ZOOM;
} else {
// Reverse the array to find the next lower step
targetZoom = [...ZOOM_STEPS].reverse().find((step) => step < prev) ?? MIN_ZOOM;
}
// Reset position when zoom matches initial magnification
if (targetZoom === 1 && imgRef.current) {
imgRef.current.style.left = "0px";
imgRef.current.style.top = "0px";
}
return targetZoom;
});
}, []);
setTimeout(() => {
setMagnification(1);
}, 200);
}, [toggleFullScreenMode]);
// download handler
const handleOpenInNewTab = () => {
const link = document.createElement("a");
link.href = src;
link.target = "_blank";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// magnification decrease handler
const handleDecreaseMagnification = useCallback(() => {
const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification);
if (currentIndex === 0) return;
setMagnification(MAGNIFICATION_VALUES[currentIndex - 1]);
}, [magnification]);
// magnification increase handler
const handleIncreaseMagnification = useCallback(() => {
const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification);
if (currentIndex === MAGNIFICATION_VALUES.length - 1) return;
setMagnification(MAGNIFICATION_VALUES[currentIndex + 1]);
}, [magnification]);
// keydown handler
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "+" || e.key === "=" || e.key === "-") {
@@ -92,113 +61,43 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
e.stopPropagation();
if (e.key === "Escape") handleClose();
if (e.key === "+" || e.key === "=") handleMagnification("increase");
if (e.key === "-") handleMagnification("decrease");
if (e.key === "+" || e.key === "=") handleIncreaseMagnification();
if (e.key === "-") handleDecreaseMagnification();
}
},
[handleClose, handleMagnification]
[handleClose, handleDecreaseMagnification, handleIncreaseMagnification]
);
// click outside handler
const handleClickOutside = useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (modalRef.current && e.target === modalRef.current) {
handleClose();
}
},
[handleClose]
);
// register keydown listener
useEffect(() => {
if (isFullScreenEnabled) {
document.addEventListener("keydown", handleKeyDown);
const handleMouseDown = (e: React.MouseEvent) => {
if (!imgRef.current) return;
const imgWidth = imgRef.current.offsetWidth * magnification;
const imgHeight = imgRef.current.offsetHeight * magnification;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (imgWidth > viewportWidth || imgHeight > viewportHeight) {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY };
dragOffset.current = {
x: parseInt(imgRef.current.style.left || "0"),
y: parseInt(imgRef.current.style.top || "0"),
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}
};
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging || !imgRef.current) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
// Apply the scale factor to the drag movement
const scaledDx = dx / magnification;
const scaledDy = dy / magnification;
imgRef.current.style.left = `${dragOffset.current.x + scaledDx}px`;
imgRef.current.style.top = `${dragOffset.current.y + scaledDy}px`;
},
[isDragging, magnification]
);
const handleMouseUp = useCallback(() => {
if (!isDragging || !imgRef.current) return;
setIsDragging(false);
}, [isDragging]);
const handleWheel = useCallback(
(e: WheelEvent) => {
if (!imgRef.current || !isFullScreenEnabled) return;
e.preventDefault();
// Handle pinch-to-zoom
if (e.ctrlKey) {
const delta = e.deltaY;
setMagnification((prev) => {
const newZoom = prev * (1 - delta * ZOOM_SPEED);
const clampedZoom = Math.min(Math.max(newZoom, MIN_ZOOM), MAX_ZOOM);
// Reset position when zoom matches initial magnification
if (clampedZoom === 1 && imgRef.current) {
imgRef.current.style.left = "0px";
imgRef.current.style.top = "0px";
}
return clampedZoom;
});
return;
}
},
[isFullScreenEnabled, magnification]
);
// Event listeners
useEffect(() => {
if (!isFullScreenEnabled) return;
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("wheel", handleWheel, { passive: false });
return () => {
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("wheel", handleWheel);
};
}, [isFullScreenEnabled, handleKeyDown, handleMouseMove, handleMouseUp, handleWheel]);
}, [handleKeyDown, isFullScreenEnabled]);
return (
<>
<div
className={cn("fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none transition-opacity", {
"opacity-100 pointer-events-auto": isFullScreenEnabled,
"cursor-default": !isDragging,
"cursor-grabbing": isDragging,
})}
className={cn(
"fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none cursor-default transition-opacity",
{
"opacity-100 pointer-events-auto": isFullScreenEnabled,
}
)}
>
<div
ref={modalRef}
onMouseDown={(e) => e.target === modalRef.current && handleClose()}
className="relative size-full grid place-items-center overflow-hidden"
>
<div ref={modalRef} onClick={handleClickOutside} className="relative size-full grid place-items-center">
<button
type="button"
onClick={handleClose}
@@ -207,49 +106,41 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
<X className="size-8 text-white/60 hover:text-white transition-colors" />
</button>
<img
ref={setImageRef}
src={src}
className="read-only-image rounded-lg"
className="read-only-image rounded-lg transition-all duration-200"
style={{
width: `${widthInNumber * initialMagnification}px`,
maxWidth: "none",
maxHeight: "none",
width: `${widthInNumber * magnification}px`,
aspectRatio,
position: "relative",
transform: `scale(${magnification})`,
transformOrigin: "center",
transition: "width 0.2s ease, transform 0.2s ease",
}}
onMouseDown={handleMouseDown}
/>
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20 bg-black">
<div className="flex items-center">
<button
type="button"
onClick={() => handleMagnification("decrease")}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification <= MIN_ZOOM}
>
<Minus className="size-4" />
</button>
<span className="text-sm w-12 text-center text-white">{Math.round(100 * magnification)}%</span>
<button
type="button"
onClick={() => handleMagnification("increase")}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification >= MAX_ZOOM}
>
<Plus className="size-4" />
</button>
</div>
</div>
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20 bg-black">
<div className="flex items-center">
<button
type="button"
onClick={() => window.open(src, "_blank")}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
onClick={handleDecreaseMagnification}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification === MAGNIFICATION_VALUES[0]}
>
<ExternalLink className="size-4" />
<Minus className="size-4" />
</button>
<span className="text-sm w-12 text-center text-white">{(100 * magnification).toFixed(0)}%</span>
<button
type="button"
onClick={handleIncreaseMagnification}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification === MAGNIFICATION_VALUES[MAGNIFICATION_VALUES.length - 1]}
>
<Plus className="size-4" />
</button>
</div>
<button
type="button"
onClick={handleOpenInNewTab}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
>
<ExternalLink className="size-4" />
</button>
</div>
</div>
<button
@@ -18,7 +18,7 @@ import {
CustomImageExtension,
CustomKeymap,
CustomLinkExtension,
CustomMentionExtension,
CustomMention,
CustomQuoteExtension,
CustomTextAlignExtension,
CustomTypographyExtension,
@@ -33,23 +33,25 @@ import {
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
import { IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
// plane editor extensions
import { multipleSelectionExtension } from "./selection/multi-selection-tracker-new";
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
type TArguments = {
disabledExtensions: TExtensions[];
enableHistory: boolean;
fileHandler: TFileHandler;
mentionHandler: TMentionHandler;
mentionConfig: {
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
};
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
editable: boolean;
};
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex } = args;
const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex, editable } = args;
return [
StarterKit.configure({
@@ -83,8 +85,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
},
},
dropcursor: {
class:
"text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]",
class: "text-custom-text-300",
},
...(enableHistory ? {} : { history: false }),
}),
@@ -145,7 +146,11 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
TableHeader,
TableCell,
TableRow,
CustomMentionExtension(mentionHandler),
CustomMention({
mentionSuggestions: editable ? mentionConfig.mentionSuggestions : undefined,
mentionHighlights: mentionConfig.mentionHighlights,
readonly: !editable,
}),
Placeholder.configure({
placeholder: ({ editor, node }) => {
if (!editor.isEditable) return;
@@ -178,6 +183,5 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
...CoreEditorAdditionalExtensions({
disabledExtensions,
}),
multipleSelectionExtension,
];
};
@@ -1,48 +0,0 @@
import { mergeAttributes } from "@tiptap/core";
import Mention, { MentionOptions } from "@tiptap/extension-mention";
// types
import { TMentionHandler } from "@/types";
// local types
import { EMentionComponentAttributeNames } from "./types";
export type TMentionExtensionOptions = MentionOptions & {
renderComponent: TMentionHandler["renderComponent"];
};
export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOptions>({
addAttributes() {
return {
[EMentionComponentAttributeNames.ID]: {
default: null,
},
[EMentionComponentAttributeNames.ENTITY_IDENTIFIER]: {
default: null,
},
[EMentionComponentAttributeNames.ENTITY_NAME]: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "mention-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["mention-component", mergeAttributes(HTMLAttributes)];
},
HTMLAttributes: {
class: "mention",
},
addStorage(this) {
return {
mentionsOpen: false,
};
},
});
@@ -1,31 +1,154 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { Editor, mergeAttributes } from "@tiptap/core";
import Mention, { MentionOptions } from "@tiptap/extension-mention";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
import { Node } from "@tiptap/pm/model";
import { ReactNodeViewRenderer, ReactRenderer } from "@tiptap/react";
import tippy from "tippy.js";
// extensions
import { MentionList, MentionNodeView } from "@/extensions";
// types
import { TMentionHandler } from "@/types";
// extension config
import { CustomMentionExtensionConfig } from "./extension-config";
// node view
import { MentionNodeView } from "./mention-node-view";
// utils
import { renderMentionsDropdown } from "./utils";
import { IMentionHighlight, IMentionSuggestion } from "@/types";
export const CustomMentionExtension = (props: TMentionHandler) => {
const { searchCallback, renderComponent } = props;
return CustomMentionExtensionConfig.extend({
addOptions(this) {
interface CustomMentionOptions extends MentionOptions {
mentionHighlights: () => Promise<IMentionHighlight[]>;
readonly?: boolean;
}
export const CustomMention = ({
mentionHighlights,
mentionSuggestions,
readonly,
}: {
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
readonly: boolean;
}) =>
Mention.extend<CustomMentionOptions>({
addStorage(this) {
return {
...this.parent?.(),
renderComponent,
mentionsOpen: false,
markdown: {
serialize(state: MarkdownSerializerState, node: Node) {
const { attrs } = node;
const label = `@${state.esc(attrs.label)}`;
const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
const safeRedirectionPath = state.esc(attrs.redirect_uri);
const url = `${originUrl}${safeRedirectionPath}`;
state.write(`[${label}](${url})`);
},
},
};
},
addAttributes() {
return {
id: {
default: null,
},
label: {
default: null,
},
target: {
default: null,
},
self: {
default: false,
},
redirect_uri: {
default: "/",
},
entity_identifier: {
default: null,
},
entity_name: {
default: null,
},
};
},
addNodeView() {
return ReactNodeViewRenderer(MentionNodeView);
},
parseHTML() {
return [
{
tag: "mention-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["mention-component", mergeAttributes(HTMLAttributes)];
},
}).configure({
HTMLAttributes: {
class: "mention",
},
readonly: readonly,
mentionHighlights,
suggestion: {
render: renderMentionsDropdown({
searchCallback,
}),
// @ts-expect-error - Tiptap types are incorrect
render: () => {
if (!mentionSuggestions) return;
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
if (!props.clientRect) {
return;
}
component = new ReactRenderer(MentionList, {
props: { ...props, mentionSuggestions },
editor: props.editor,
});
props.editor.storage.mentionsOpen = true;
// @ts-expect-error - Tippy types are incorrect
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () =>
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'),
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
if (!props.clientRect) {
return;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
if (navigationKeys.includes(props.event.key)) {
// @ts-expect-error - Tippy types are incorrect
component?.ref?.onKeyDown(props);
event?.stopPropagation();
return true;
}
return false;
},
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
props.editor.storage.mentionsOpen = false;
popup?.[0].destroy();
component?.destroy();
},
};
},
},
});
};
@@ -1,2 +1,4 @@
export * from "./extension";
export * from "./extension-config";
export * from "./mention-node-view";
export * from "./mentions-list";
export * from "./mentions-without-props";
@@ -1,26 +1,49 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
// extension config
import { TMentionExtensionOptions } from "./extension-config";
// extension types
import { EMentionComponentAttributeNames, TMentionComponentAttributes } from "./types";
// TODO: fix all warnings
type Props = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TMentionComponentAttributes;
/* eslint-disable react/display-name */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { useEffect, useState } from "react";
import { NodeViewWrapper } from "@tiptap/react";
// plane utils
import { cn } from "@plane/utils";
// types
import { IMentionHighlight } from "@/types";
// eslint-disable-next-line import/no-anonymous-default-export
export const MentionNodeView = (props) => {
// TODO: move it to web app
const [highlightsState, setHighlightsState] = useState<IMentionHighlight[]>();
useEffect(() => {
if (!props.extension.options.mentionHighlights) return;
const hightlights = async () => {
const userId = await props.extension.options.mentionHighlights?.();
setHighlightsState(userId);
};
hightlights();
}, [props.extension.options]);
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (!props.node.attrs.redirect_uri) {
event.preventDefault();
}
};
};
export const MentionNodeView = (props: Props) => {
const {
extension,
node: { attrs },
} = props;
return (
<NodeViewWrapper className="mention-component inline w-fit">
{(extension.options as TMentionExtensionOptions).renderComponent({
entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER],
entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention",
})}
<a
href={props.node.attrs.redirect_uri || "#"}
target="_blank"
className={cn("mention rounded bg-custom-primary-100/20 px-1 py-0.5 font-medium text-custom-primary-100", {
"bg-yellow-500/20 text-yellow-500": highlightsState
? highlightsState.includes(props.node.attrs.entity_identifier)
: false,
"cursor-pointer": !props.extension.options.readonly,
})}
>
@{props.node.attrs.label}
</a>
</NodeViewWrapper>
);
};
@@ -1,163 +0,0 @@
"use client";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
import { Editor } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// plane utils
import { cn } from "@plane/utils";
// helpers
import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy";
// types
import { TMentionHandler, TMentionSection, TMentionSuggestion } from "@/types";
export type MentionsListDropdownProps = {
command: (item: TMentionSuggestion) => void;
query: string;
editor: Editor;
} & Pick<TMentionHandler, "searchCallback">;
export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps, ref) => {
const { command, query, searchCallback } = props;
// states
const [sections, setSections] = useState<TMentionSection[]>([]);
const [selectedIndex, setSelectedIndex] = useState({
section: 0,
item: 0,
});
const [isLoading, setIsLoading] = useState(false);
// refs
const commandListContainer = useRef<HTMLDivElement>(null);
const selectItem = useCallback(
(sectionIndex: number, itemIndex: number) => {
try {
const item = sections?.[sectionIndex]?.items?.[itemIndex];
const transactionId = uuidv4();
if (item) {
command({
...item,
id: transactionId,
});
}
} catch (error) {
console.error("Error selecting mention item:", error);
}
},
[command, sections]
);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (!DROPDOWN_NAVIGATION_KEYS.includes(event.key)) return;
event.preventDefault();
if (event.key === "Enter") {
selectItem(selectedIndex.section, selectedIndex.item);
return;
}
const newIndex = getNextValidIndex({
event,
sections,
selectedIndex,
});
setSelectedIndex(newIndex);
},
}));
// initialize the select index to 0 by default
useEffect(() => {
setSelectedIndex({
section: 0,
item: 0,
});
}, [sections]);
// fetch mention sections based on query
useEffect(() => {
const fetchSuggestions = async () => {
setIsLoading(true);
try {
const sectionsResponse = await searchCallback?.(query);
setSections(sectionsResponse);
} catch (error) {
console.error("Failed to fetch suggestions:", error);
} finally {
setIsLoading(false);
}
};
fetchSuggestions();
}, [query, searchCallback]);
// scroll to the dropdown item when navigating via keyboard
useLayoutEffect(() => {
const container = commandListContainer?.current;
if (!container) return;
const item = container.querySelector(`#mention-item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement;
if (item) {
const containerRect = container.getBoundingClientRect();
const itemRect = item.getBoundingClientRect();
const isItemInView = itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom;
if (!isItemInView) {
item.scrollIntoView({ block: "nearest" });
}
}
}, [selectedIndex]);
return (
<div
ref={commandListContainer}
className="z-10 max-h-[90vh] w-[14rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2"
>
{isLoading ? (
<div className="text-center text-sm text-custom-text-400">Loading...</div>
) : sections.length ? (
sections.map((section, sectionIndex) => (
<div key={section.key} className="space-y-2">
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
{section.items.map((item, itemIndex) => {
const isSelected = sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item;
return (
<button
key={item.id}
id={`mention-item-${sectionIndex}-${itemIndex}`}
type="button"
className={cn(
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200",
{
"bg-custom-background-80": isSelected,
}
)}
onClick={(e) => {
e.preventDefault();
selectItem(sectionIndex, itemIndex);
}}
onMouseEnter={() =>
setSelectedIndex({
section: sectionIndex,
item: itemIndex,
})
}
>
<span className="size-5 grid place-items-center flex-shrink-0">{item.icon}</span>
{item.subTitle && (
<h5 className="whitespace-nowrap text-xs text-custom-text-300 flex-shrink-0">{item.subTitle}</h5>
)}
<p className="flex-grow truncate">{item.title}</p>
</button>
);
})}
</div>
))
) : (
<div className="text-center text-sm text-custom-text-400">No results</div>
)}
</div>
);
});
MentionsListDropdown.displayName = "MentionsListDropdown";
@@ -0,0 +1,171 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
import { Editor } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// ui
import { Avatar } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// types
import { IMentionSuggestion } from "@/types";
interface MentionListProps {
command: (item: {
id: string;
label: string;
entity_name: string;
entity_identifier: string;
target: string;
redirect_uri: string;
}) => void;
query: string;
editor: Editor;
mentionSuggestions: () => Promise<IMentionSuggestion[]>;
}
export const MentionList = forwardRef((props: MentionListProps, ref) => {
const { query, mentionSuggestions } = props;
const [items, setItems] = useState<IMentionSuggestion[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchSuggestions = async () => {
setIsLoading(true);
try {
const suggestions = await mentionSuggestions();
const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => {
const transactionId = uuidv4();
return {
...suggestion,
id: transactionId,
};
});
const filteredSuggestions = mappedSuggestions.filter((suggestion) =>
suggestion.title.toLowerCase().startsWith(query.toLowerCase())
);
setItems(filteredSuggestions);
} catch (error) {
console.error("Failed to fetch suggestions:", error);
} finally {
setIsLoading(false);
}
};
fetchSuggestions();
}, [query, mentionSuggestions]);
const selectItem = (index: number) => {
try {
const item = items[index];
if (item) {
props.command({
id: item.id,
label: item.title,
entity_identifier: item.entity_identifier,
entity_name: item.entity_name,
target: "users",
redirect_uri: item.redirect_uri,
});
}
} catch (error) {
console.error("Error selecting item:", error);
}
};
const commandListContainer = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const container = commandListContainer?.current;
const item = container?.children[selectedIndex] as HTMLElement;
if (item && container) updateScrollView(container, item);
}, [selectedIndex]);
const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
const itemHeight = item ? item.offsetHeight : 0;
const top = item.offsetTop;
const bottom = top + itemHeight;
if (top < container.scrollTop) {
container.scrollTop -= container.scrollTop - top + 5;
} else if (bottom > containerHeight + container.scrollTop) {
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
}
};
const upHandler = () => {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => {
setSelectedIndex(0);
}, [items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
return true;
}
return false;
},
}));
return (
<div
ref={commandListContainer}
className="mentions max-h-48 min-w-[12rem] rounded-md bg-custom-background-100 border-[0.5px] border-custom-border-300 px-2 py-2.5 text-xs shadow-custom-shadow-rg overflow-y-scroll"
>
{isLoading ? (
<div className="text-center text-custom-text-400">Loading...</div>
) : items.length ? (
items.map((item, index) => (
<button
key={item.id}
type="button"
className={cn(
"w-full text-left flex cursor-pointer items-center gap-2 rounded px-1 py-1.5 hover:bg-custom-background-80 text-custom-text-200",
{
"bg-custom-background-80": index === selectedIndex,
}
)}
onClick={() => selectItem(index)}
>
<Avatar name={item?.title} src={item?.avatar} />
<span className="flex-grow truncate">{item.title}</span>
</button>
))
) : (
<div className="text-center text-custom-text-400">No results</div>
)}
</div>
);
});
MentionList.displayName = "MentionList";
@@ -0,0 +1,51 @@
import { mergeAttributes } from "@tiptap/core";
import Mention, { MentionOptions } from "@tiptap/extension-mention";
// types
import { IMentionHighlight } from "@/types";
interface CustomMentionOptions extends MentionOptions {
mentionHighlights: () => Promise<IMentionHighlight[]>;
readonly?: boolean;
}
export const CustomMentionWithoutProps = () =>
Mention.extend<CustomMentionOptions>({
addAttributes() {
return {
id: {
default: null,
},
label: {
default: null,
},
target: {
default: null,
},
self: {
default: false,
},
redirect_uri: {
default: "/",
},
entity_identifier: {
default: null,
},
entity_name: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "mention-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["mention-component", mergeAttributes(HTMLAttributes)];
},
HTMLAttributes: {
class: "mention",
},
});
@@ -1,14 +0,0 @@
// plane types
import { TSearchEntities } from "@plane/types";
export enum EMentionComponentAttributeNames {
ID = "id",
ENTITY_IDENTIFIER = "entity_identifier",
ENTITY_NAME = "entity_name",
}
export type TMentionComponentAttributes = {
[EMentionComponentAttributeNames.ID]: string | null;
[EMentionComponentAttributeNames.ENTITY_IDENTIFIER]: string | null;
[EMentionComponentAttributeNames.ENTITY_NAME]: TSearchEntities | null;
};
@@ -1,72 +0,0 @@
import { Editor } from "@tiptap/core";
import { SuggestionOptions } from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import tippy from "tippy.js";
// helpers
import { CommandListInstance } from "@/helpers/tippy";
// types
import { TMentionHandler } from "@/types";
// local components
import { MentionsListDropdown, MentionsListDropdownProps } from "./mentions-list-dropdown";
export const renderMentionsDropdown =
(props: Pick<TMentionHandler, "searchCallback">): SuggestionOptions["render"] =>
// @ts-expect-error - Tiptap types are incorrect
() => {
const { searchCallback } = props;
let component: ReactRenderer<CommandListInstance, MentionsListDropdownProps> | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
if (!searchCallback) return;
if (!props.clientRect) return;
component = new ReactRenderer<CommandListInstance, MentionsListDropdownProps>(MentionsListDropdown, {
props: {
...props,
searchCallback,
},
editor: props.editor,
});
props.editor.storage.mentionsOpen = true;
// @ts-expect-error - Tippy types are incorrect
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () =>
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'),
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0]?.hide();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
if (navigationKeys.includes(props.event.key)) {
props.event?.stopPropagation();
if (component?.ref?.onKeyDown(props)) {
return true;
}
}
return false;
},
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
props.editor.storage.mentionsOpen = false;
popup?.[0]?.destroy();
component?.destroy();
},
};
};
@@ -19,7 +19,7 @@ import {
TableCell,
TableRow,
Table,
CustomMentionExtension,
CustomMention,
CustomReadOnlyImageExtension,
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
@@ -28,18 +28,20 @@ import {
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
import { TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
import { IMentionHighlight, TExtensions, TFileHandler } from "@/types";
// plane editor extensions
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
type Props = {
disabledExtensions: TExtensions[];
fileHandler: Pick<TFileHandler, "getAssetSrc">;
mentionHandler: TReadOnlyMentionHandler;
mentionConfig: {
mentionHighlights?: () => Promise<IMentionHighlight[]>;
};
};
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
const { disabledExtensions, fileHandler, mentionHandler } = props;
const { disabledExtensions, fileHandler, mentionConfig } = props;
return [
StarterKit.configure({
@@ -130,7 +132,10 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
TableHeader,
TableCell,
TableRow,
CustomMentionExtension(mentionHandler),
CustomMention({
mentionHighlights: mentionConfig.mentionHighlights,
readonly: true,
}),
CharacterCount,
CustomColorExtension,
CustomTextAlignExtension,
@@ -1,55 +0,0 @@
import { Node } from "@tiptap/pm/model";
import { Selection, SelectionRange } from "@tiptap/pm/state";
import { Mappable } from "@tiptap/pm/transform";
export class MultiRangeSelection extends Selection {
ranges: SelectionRange[];
constructor(ranges: SelectionRange[]) {
// ProseMirror's `Selection` requires a "from" and a "to",
// but in a multi-range scenario, we use the first and last positions.
super(ranges[0].$from, ranges[ranges.length - 1].$to, ranges);
this.ranges = ranges;
}
map(doc: Node, mapping: Mappable): Selection {
// You would typically map positions here if positions can change,
// but for simplicity we return the same set of ranges.
// For safer usage, you would re-map each ranges start and end.
return new MultiRangeSelection(this.ranges);
}
eq(other: Selection): boolean {
if (!(other instanceof MultiRangeSelection)) return false;
if (this.ranges.length !== other.ranges.length) return false;
for (let i = 0; i < this.ranges.length; i++) {
if (
this.ranges[i].$from.pos !== other.ranges[i].$from.pos ||
this.ranges[i].$to.pos !== other.ranges[i].$to.pos
) {
return false;
}
}
return true;
}
toJSON(): any {
return {
type: "multiRangeSelection",
ranges: this.ranges.map((range) => ({
from: range.$from.pos,
to: range.$to.pos,
})),
};
}
static create(ranges: SelectionRange[]) {
return new MultiRangeSelection(ranges);
}
static fromJSON(doc: Node, json: any) {
const ranges = json.ranges.map((r: any) => new SelectionRange(doc.resolve(r.from), doc.resolve(r.to)));
return this.create(ranges);
}
}
@@ -1,416 +0,0 @@
import { Dispatch, Extension } from "@tiptap/core";
import { Node, ResolvedPos, Slice, Fragment } from "@tiptap/pm/model";
import { EditorState, Plugin, PluginKey, Selection, TextSelection, Transaction } from "@tiptap/pm/state";
import { EditorView, Decoration, DecorationSet } from "@tiptap/pm/view";
const MultipleSelectionPluginKey = new PluginKey("multipleSelection");
interface BlockSelectionState {
ranges: Array<[number, number]> | null;
lastSelectedRange?: { from: number; to: number };
}
let activeCursor: HTMLElement | null = null;
const lastActiveSelection = {
top: 0,
left: 0,
};
let isDragging = false;
let selectNodesHandler: (event: MouseEvent) => void;
let scrollAnimationFrame: number | null = null;
let lastClientY = 0;
let currentScrollSpeed = 0;
const maxScrollSpeed = 20;
const acceleration = 0.5;
let cachedScrollParent = null;
function easeOutQuadAnimation(t: number) {
return t * (2 - t);
}
const scrollParentCache = new WeakMap();
const getScrollParent = (element) => {
if (cachedScrollParent) {
return cachedScrollParent;
}
if (!element) return null;
let currentParent = element.parentElement;
while (currentParent) {
if (isScrollable(currentParent)) {
cachedScrollParent = currentParent;
return cachedScrollParent;
}
currentParent = currentParent.parentElement;
}
cachedScrollParent = document.scrollingElement || document.documentElement;
return cachedScrollParent;
};
const isScrollable = (node) => {
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
return false;
}
const style = getComputedStyle(node);
return ["overflow", "overflow-y"].some((propertyName) => {
const value = style.getPropertyValue(propertyName);
return value === "auto" || value === "scroll";
});
};
const updateCursorPosition = (event: MouseEvent, view: EditorView) => {
if (!activeCursor) return;
const x = event.pageX;
const y = event.pageY;
let newHeight = y - lastActiveSelection.top;
let newWidth = x - lastActiveSelection.left;
if (newHeight < 0) {
activeCursor.style.marginTop = `${newHeight}px`;
newHeight *= -1;
} else {
activeCursor.style.marginTop = "0px";
}
if (newWidth < 0) {
activeCursor.style.marginLeft = `${newWidth}px`;
newWidth *= -1;
} else {
activeCursor.style.marginLeft = "0px";
}
activeCursor.style.height = `${newHeight}px`;
activeCursor.style.width = `${newWidth}px`;
lastClientY = event.clientY;
if (!scrollAnimationFrame) {
scrollAnimationFrame = requestAnimationFrame(scroll);
}
};
function scroll() {
if (!isDragging) {
currentScrollSpeed = 0;
scrollAnimationFrame = null;
return;
}
const editorContainer = document.querySelector(".editor-container");
const scrollableParent = getScrollParent(editorContainer);
if (!scrollableParent) {
scrollAnimationFrame = requestAnimationFrame(scroll);
return;
}
const scrollRegionUp = 150;
const scrollRegionDown = scrollableParent.clientHeight - 100;
let targetScrollAmount = 0;
if (lastClientY < scrollRegionUp) {
const ratio = easeOutQuadAnimation((scrollRegionUp - lastClientY) / scrollRegionUp);
targetScrollAmount = -maxScrollSpeed * ratio;
} else if (lastClientY > scrollRegionDown) {
const ratio = easeOutQuadAnimation(
(lastClientY - scrollRegionDown) / (scrollableParent.clientHeight - scrollRegionDown)
);
targetScrollAmount = maxScrollSpeed * ratio;
}
currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * acceleration;
if (Math.abs(currentScrollSpeed) > 0.1) {
scrollableParent.scrollBy(0, currentScrollSpeed);
}
scrollAnimationFrame = requestAnimationFrame(scroll);
}
const getNodesInRect = (view: EditorView, rect: DOMRect) => {
const ranges: Array<[number, number]> = [];
const { doc } = view.state;
const isInRect = (pos: number) => {
const coords = view.coordsAtPos(pos);
return (
coords &&
coords.top < rect.bottom &&
coords.bottom > rect.top &&
coords.left < rect.right &&
coords.right > rect.left
);
};
doc.nodesBetween(0, doc.content.size, (node, pos) => {
const resolvedPos = view.state.doc.resolve(pos);
if (resolvedPos.depth === 1) {
const start = resolvedPos.before(1);
const end = resolvedPos.after(1);
if (isInRect(start) || isInRect(end)) {
ranges.push([start, end]);
}
return false;
}
return true;
});
return ranges;
};
const removeActiveUser = (view?: EditorView) => {
if (!activeCursor) return;
activeCursor.remove();
activeCursor = null;
document.removeEventListener("mousemove", selectNodesHandler);
document.removeEventListener("mouseup", () => removeActiveUser(view));
if (scrollAnimationFrame) {
cancelAnimationFrame(scrollAnimationFrame);
scrollAnimationFrame = null;
}
isDragging = false;
};
export class MultipleSelection extends Selection {
ranges: Array<{ $from: ResolvedPos; $to: ResolvedPos }>;
constructor(ranges: Array<{ $from: ResolvedPos; $to: ResolvedPos }>) {
const $anchor = ranges[0].$from;
const $head = ranges[ranges.length - 1].$to;
super($anchor, $head);
this.ranges = ranges;
}
map(doc: Node, mapping: any) {
const newRanges = this.ranges.map(({ $from, $to }) => ({
$from: doc.resolve(mapping.map($from.pos)),
$to: doc.resolve(mapping.map($to.pos)),
}));
return new MultipleSelection(newRanges);
}
eq(other: Selection): boolean {
if (!(other instanceof MultipleSelection) || other.ranges.length !== this.ranges.length) {
return false;
}
return this.ranges.every(
(range, i) => range.$from.pos === other.ranges[i].$from.pos && range.$to.pos === other.ranges[i].$to.pos
);
}
content() {
const nodes: Node[] = [];
this.ranges.forEach(({ $from, $to }) => {
const slice = $from.doc.slice($from.pos, $to.pos);
nodes.push(...slice.content.content);
});
return new Slice(Fragment.from(nodes), 0, 0);
}
replace(tr: any, content: Slice = Slice.empty) {
const ranges = this.ranges;
for (let i = 0; i < ranges.length; i++) {
const { $from, $to } = ranges[i];
const mappedFrom = tr.mapping.map($from.pos);
const mappedTo = tr.mapping.map($to.pos);
tr.replace(mappedFrom, mappedTo, i ? Slice.empty : content);
}
const lastFrom = tr.mapping.map(ranges[ranges.length - 1].$from.pos);
tr.setSelection(TextSelection.create(tr.doc, lastFrom));
}
toJSON() {
return {
type: "multiple",
ranges: this.ranges.map(({ $from, $to }) => ({ from: $from.pos, to: $to.pos })),
};
}
static fromJSON(doc: Node, json: any) {
if (json.type !== "multiple") throw new Error("Invalid input for MultipleSelection.fromJSON");
const ranges = json.ranges.map(({ from, to }) => ({
$from: doc.resolve(from),
$to: doc.resolve(to),
}));
return new MultipleSelection(ranges);
}
static create(doc: Node, ranges: Array<[number, number]>) {
return new MultipleSelection(
ranges.map(([from, to]) => ({
$from: doc.resolve(from),
$to: doc.resolve(to),
}))
);
}
}
// Register the new selection type
Selection.jsonID("multiple", MultipleSelection);
const createMultipleSelectionPlugin = () =>
new Plugin<BlockSelectionState>({
key: MultipleSelectionPluginKey,
state: {
init() {
return { ranges: null };
},
apply(tr, state) {
const action = tr.getMeta(MultipleSelectionPluginKey);
if (action) {
return action;
}
return state;
},
},
props: {
handleDOMEvents: {
mousedown(view: EditorView, event: MouseEvent) {
if (!isDragging) {
const pluginState = MultipleSelectionPluginKey.getState(view.state);
if (pluginState?.ranges?.length || pluginState?.lastSelectedRange) {
view.dispatch(
view.state.tr.setMeta(MultipleSelectionPluginKey, {
ranges: null,
lastSelectedRange: undefined,
})
);
console.log("view.state.selection instanceof MultipleSelection", view.state.selection);
// if (view.state.selection instanceof MultipleSelection) {
// let tr: Transaction;
// view.state.selection.ranges.forEach(({ $from, $to }) => {
// console.log("from", $from.pos, "to", $to.pos);
// // Example: Delete content in each range
// // tr = view.state.tr.delete($from.pos, $to.pos);
// });
// view.dispatch(tr);
// }
return false;
}
}
if (event.target !== view.dom) {
return false;
}
removeActiveUser(view);
activeCursor = document.createElement("div");
activeCursor.className = "multipleSelectionCursor";
activeCursor.style.width = "0px";
activeCursor.style.height = "0px";
activeCursor.style.borderRadius = "2px";
activeCursor.style.border = "1px solid rgba(var(--color-primary-100), 0.2)";
activeCursor.style.background = "rgba(var(--color-primary-100), 0.2)";
activeCursor.style.opacity = "0.5";
activeCursor.style.position = "absolute";
activeCursor.style.top = `${event.pageY}px`;
activeCursor.style.left = `${event.pageX}px`;
activeCursor.style.pointerEvents = "none";
activeCursor.style.zIndex = "1000";
lastActiveSelection.top = event.pageY;
lastActiveSelection.left = event.pageX;
document.body.appendChild(activeCursor);
isDragging = true;
lastClientY = event.clientY;
selectNodesHandler = (e: MouseEvent) => {
updateCursorPosition(e, view);
if (activeCursor) {
const rect = activeCursor.getBoundingClientRect();
const ranges = getNodesInRect(view, rect);
view.dispatch(
view.state.tr
.setMeta(MultipleSelectionPluginKey, { ranges })
.setSelection(MultipleSelection.create(view.state.doc, ranges))
);
}
};
document.addEventListener("mousemove", selectNodesHandler);
document.addEventListener("mouseup", () => removeActiveUser(view));
event.preventDefault();
return true;
},
},
decorations(state) {
const pluginState = this.getState(state);
if (pluginState?.ranges?.length) {
return DecorationSet.create(
state.doc,
pluginState.ranges.map(([from, to]) =>
Decoration.node(from, to, {
class: "ProseMirror-selectednode",
})
)
);
}
return null;
},
},
appendTransaction(transactions, oldState, newState) {
const oldPluginState = this.getState(oldState);
const newPluginState = this.getState(newState);
if (newPluginState?.ranges?.length && !oldPluginState?.ranges?.length) {
return newState.tr.setSelection(MultipleSelection.create(newState.doc, newPluginState.ranges));
}
return null;
},
view() {
return {
destroy() {
removeActiveUser();
},
};
},
});
export const multipleSelectionExtension = Extension.create({
name: "multipleSelection",
addProseMirrorPlugins() {
return [createMultipleSelectionPlugin()];
},
});
export const deleteSelectedNodes = (state: EditorState, dispatch: Dispatch) => {
const { selection } = state;
if (!(selection instanceof MultipleSelection)) return false;
if (dispatch) {
const tr = state.tr;
// Sort ranges in reverse order (from end to start)
const sortedRanges = [...selection.ranges].sort((a, b) => b.$from.pos - a.$from.pos);
// Delete ranges from end to start to avoid position shifts
sortedRanges.forEach(({ $from, $to }) => {
tr.delete($from.pos, $to.pos);
});
dispatch(tr);
}
return true;
};
@@ -1,309 +0,0 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView, Decoration, DecorationSet } from "@tiptap/pm/view";
const MultipleSelectionPluginKey = new PluginKey("multipleSelection");
interface BlockSelectionState {
ranges: Array<[number, number]> | null;
lastSelectedRange?: { from: number; to: number };
}
let activeCursor: HTMLElement | null = null;
const lastActiveSelection = {
top: 0,
left: 0,
};
let isDragging = false;
let selectNodesHandler: (event: MouseEvent) => void;
let scrollAnimationFrame: number | null = null;
let lastClientY = 0;
let currentScrollSpeed = 0;
const maxScrollSpeed = 20;
const acceleration = 0.5;
let cachedScrollParent = null;
function easeOutQuadAnimation(t: number) {
return t * (2 - t);
}
const scrollParentCache = new WeakMap();
const getScrollParent = (element) => {
if (cachedScrollParent) {
return cachedScrollParent;
}
if (!element) return null;
let currentParent = element.parentElement;
while (currentParent) {
if (isScrollable(currentParent)) {
cachedScrollParent = currentParent;
return cachedScrollParent;
}
currentParent = currentParent.parentElement;
}
cachedScrollParent = document.scrollingElement || document.documentElement;
return cachedScrollParent;
};
const isScrollable = (node) => {
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
return false;
}
const style = getComputedStyle(node);
return ["overflow", "overflow-y"].some((propertyName) => {
const value = style.getPropertyValue(propertyName);
return value === "auto" || value === "scroll";
});
};
const updateCursorPosition = (event: MouseEvent, view: EditorView) => {
if (!activeCursor) return;
const x = event.pageX;
const y = event.pageY;
let newHeight = y - lastActiveSelection.top;
let newWidth = x - lastActiveSelection.left;
if (newHeight < 0) {
activeCursor.style.marginTop = `${newHeight}px`;
newHeight *= -1;
} else {
activeCursor.style.marginTop = "0px";
}
if (newWidth < 0) {
activeCursor.style.marginLeft = `${newWidth}px`;
newWidth *= -1;
} else {
activeCursor.style.marginLeft = "0px";
}
activeCursor.style.height = `${newHeight}px`;
activeCursor.style.width = `${newWidth}px`;
lastClientY = event.clientY;
if (!scrollAnimationFrame) {
scrollAnimationFrame = requestAnimationFrame(scroll);
}
};
function scroll() {
if (!isDragging) {
currentScrollSpeed = 0;
scrollAnimationFrame = null;
return;
}
const editorContainer = document.querySelector(".editor-container"); // Assuming the editor container has a class 'ProseMirror'
const scrollableParent = getScrollParent(editorContainer);
if (!scrollableParent) {
scrollAnimationFrame = requestAnimationFrame(scroll);
return;
}
console.log("scrollableParent", scrollableParent);
const scrollRegionUp = 150;
const scrollRegionDown = scrollableParent.clientHeight - 100;
let targetScrollAmount = 0;
if (lastClientY < scrollRegionUp) {
const ratio = easeOutQuadAnimation((scrollRegionUp - lastClientY) / scrollRegionUp);
targetScrollAmount = -maxScrollSpeed * ratio;
} else if (lastClientY > scrollRegionDown) {
const ratio = easeOutQuadAnimation(
(lastClientY - scrollRegionDown) / (scrollableParent.clientHeight - scrollRegionDown)
);
targetScrollAmount = maxScrollSpeed * ratio;
}
currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * acceleration;
if (Math.abs(currentScrollSpeed) > 0.1) {
scrollableParent.scrollBy(0, currentScrollSpeed);
}
scrollAnimationFrame = requestAnimationFrame(scroll);
}
const getNodesInRect = (view: EditorView, rect: DOMRect) => {
const ranges: Array<[number, number]> = [];
const { doc } = view.state;
const isInRect = (pos: number) => {
const coords = view.coordsAtPos(pos);
return (
coords &&
coords.top < rect.bottom &&
coords.bottom > rect.top &&
coords.left < rect.right &&
coords.right > rect.left
);
};
doc.nodesBetween(0, doc.content.size, (node, pos) => {
const resolvedPos = view.state.doc.resolve(pos);
if (resolvedPos.depth === 1) {
const start = resolvedPos.before(1);
const end = resolvedPos.after(1);
if (isInRect(start) || isInRect(end)) {
ranges.push([start, end]);
}
return false;
}
return true;
});
return ranges;
};
const removeActiveUser = (view?: EditorView) => {
if (!activeCursor) return;
activeCursor.remove();
activeCursor = null;
document.removeEventListener("mousemove", selectNodesHandler);
document.removeEventListener("mouseup", () => removeActiveUser(view));
if (scrollAnimationFrame) {
cancelAnimationFrame(scrollAnimationFrame);
scrollAnimationFrame = null;
}
isDragging = false;
};
const createMultipleSelectionPlugin = () =>
new Plugin<BlockSelectionState>({
key: MultipleSelectionPluginKey,
state: {
init() {
return { ranges: null };
},
apply(tr, state) {
const action = tr.getMeta(MultipleSelectionPluginKey);
if (action) {
return action;
}
return state;
},
},
props: {
handleDOMEvents: {
mousedown(view: EditorView, event: MouseEvent) {
if (!isDragging) {
const pluginState = MultipleSelectionPluginKey.getState(view.state);
if (pluginState?.ranges?.length || pluginState?.lastSelectedRange) {
view.dispatch(
view.state.tr.setMeta(MultipleSelectionPluginKey, {
ranges: null,
lastSelectedRange: undefined,
})
);
return false;
}
}
if (event.target !== view.dom) {
return false;
}
removeActiveUser(view);
activeCursor = document.createElement("div");
activeCursor.className = "multipleSelectionCursor";
activeCursor.style.width = "0px";
activeCursor.style.height = "0px";
activeCursor.style.borderRadius = "2px";
activeCursor.style.border = "1px solid rgba(var(--color-primary-100), 0.2)";
activeCursor.style.background = "rgba(var(--color-primary-100), 0.2)";
activeCursor.style.opacity = "0.5";
activeCursor.style.position = "absolute";
activeCursor.style.top = `${event.pageY}px`;
activeCursor.style.left = `${event.pageX}px`;
activeCursor.style.pointerEvents = "none";
activeCursor.style.zIndex = "1000";
lastActiveSelection.top = event.pageY;
lastActiveSelection.left = event.pageX;
document.body.appendChild(activeCursor);
isDragging = true;
lastClientY = event.clientY;
selectNodesHandler = (e: MouseEvent) => {
updateCursorPosition(e, view);
if (activeCursor) {
const rect = activeCursor.getBoundingClientRect();
const ranges = getNodesInRect(view, rect);
view.dispatch(
view.state.tr.setMeta(MultipleSelectionPluginKey, {
ranges,
lastSelectedRange: undefined,
})
);
}
};
document.addEventListener("mousemove", selectNodesHandler);
document.addEventListener("mouseup", () => removeActiveUser(view));
event.preventDefault();
return true;
},
},
decorations(state) {
const pluginState = this.getState(state);
if (pluginState?.ranges?.length) {
return DecorationSet.create(
state.doc,
pluginState.ranges.map(([from, to]) =>
Decoration.node(from, to, {
class: "ProseMirror-selectednode",
})
)
);
}
if (pluginState?.lastSelectedRange) {
const { from, to } = pluginState.lastSelectedRange;
return DecorationSet.create(state.doc, [
Decoration.node(from, to, {
class: "ProseMirror-selectednode",
}),
]);
}
return null;
},
},
view() {
return {
destroy() {
removeActiveUser();
},
};
},
});
export const multipleSelectionExtension = Extension.create({
name: "multipleSelection",
addProseMirrorPlugins() {
return [createMultipleSelectionPlugin()];
},
});
@@ -1,57 +0,0 @@
import { Extension } from "@tiptap/core";
import { SelectionRange } from "@tiptap/pm/state";
import { MultiRangeSelection } from "./MultiRangeSelection";
import { createMultiSelectionDecorationPlugin } from "./plugin";
export const multipleSelectionExtension = Extension.create({
name: "multiSelectionExtension",
addStorage() {
return {
selectedRanges: [] as SelectionRange[],
};
},
addProseMirrorPlugins() {
return [createMultiSelectionDecorationPlugin()];
},
onCreate() {
const editorView = this.editor.view;
const storage = this.storage;
// Listen for mousedown: If not holding ctrl, reset selection ranges
editorView.dom.addEventListener("mousedown", (event) => {
console.log("event.ctrlKey", event.metaKey);
if (!event.metaKey) {
storage.selectedRanges = [];
}
});
// Listen for mouseup: If holding ctrl, add to ranges, else reset
editorView.dom.addEventListener("mouseup", (event) => {
const { state, dispatch } = editorView;
const { selection } = state;
// Only proceed if its a normal TextSelection and user is holding ctrl
if (event.metaKey) {
const newRange = selection.ranges[0];
// Accumulate ranges
storage.selectedRanges.push(newRange);
// Sort them by start position
storage.selectedRanges.sort((r1, r2) => r1.$from.pos - r2.$from.pos);
// Construct a new multi-range selection
const tr = state.tr;
const multiSelection = MultiRangeSelection.create(storage.selectedRanges);
tr.setSelection(multiSelection);
dispatch(tr);
} else {
// Single selection: just store that range
storage.selectedRanges = [selection.ranges[0]];
}
});
},
});
@@ -1,146 +0,0 @@
import { Editor, Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
const MultipleSelectionPluginKey = new PluginKey("multipleSelection");
let activeCursor: HTMLElement | null = null;
const lastActiveSelection = {
top: 0,
left: 0,
};
const getNodesInRect = (view: EditorView, rect: DOMRect) => {
const nodes: number[] = [];
const { doc } = view.state;
// Helper to check if a position is inside the selection rectangle
const isInRect = (pos: number) => {
const coords = view.coordsAtPos(pos);
return (
coords &&
coords.top < rect.bottom &&
coords.bottom > rect.top &&
coords.left < rect.right &&
coords.right > rect.left
);
};
// Iterate through the document to find nodes at depth 1
doc.nodesBetween(0, doc.content.size, (node, pos) => {
const resolvedPos = view.state.doc.resolve(pos);
// Only process nodes at depth 1 (direct children of the document)
if (resolvedPos.depth === 1) {
// Check both start and end of the node to ensure proper selection
const start = resolvedPos.before(1);
const end = resolvedPos.after(1);
// If either the start or end of the node is in the selection rectangle
if (isInRect(start) || isInRect(end)) {
nodes.push(start);
}
// Don't descend into this node's children
return false;
}
return true;
});
return nodes;
};
const updateCursorPosition = (view: EditorView, editor: Editor) => (event: MouseEvent) => {
if (!activeCursor) return;
let newHeight = event.y - lastActiveSelection.top;
let newWidth = event.x - lastActiveSelection.left;
// Update cursor dimensions and position
if (newHeight < 0) {
activeCursor.style.marginTop = `${newHeight}px`;
newHeight *= -1;
} else {
activeCursor.style.marginTop = "0px";
}
if (newWidth < 0) {
activeCursor.style.marginLeft = `${newWidth}px`;
newWidth *= -1;
} else {
activeCursor.style.marginLeft = "0px";
}
activeCursor.style.height = `${newHeight}px`;
activeCursor.style.width = `${newWidth}px`;
// Get selection rectangle
const selectionRect = activeCursor.getBoundingClientRect();
// Find nodes within rectangle
const nodesToSelect = getNodesInRect(view, selectionRect);
// __AUTO_GENERATED_PRINT_VAR_START__
console.log("updateCursorPosition#(anon) nodesToSelect: ", nodesToSelect); // __AUTO_GENERATED_PRINT_VAR_END__
// Create node selections using Tiptap commands
if (nodesToSelect.length > 0) {
// Select each node in sequence
nodesToSelect.forEach((pos) => {
editor.commands.setNodeSelection(pos);
});
}
};
const removeActiveUser = () => {
if (!activeCursor) return;
activeCursor.remove();
activeCursor = null;
document.removeEventListener("mouseup", removeActiveUser);
};
const createMultipleSelectionPlugin = (editor: Editor) =>
new Plugin({
key: MultipleSelectionPluginKey,
props: {
handleDOMEvents: {
mousedown(view: EditorView, event: MouseEvent) {
if (event.target !== view.dom) return false;
removeActiveUser();
activeCursor = document.createElement("div");
activeCursor.className = "multipleSelectionCursor";
activeCursor.style.width = "0px";
activeCursor.style.height = "0px";
activeCursor.style.borderRadius = "2px";
activeCursor.style.border = "1px solid rgba(var(--color-primary-100), 0.2)";
activeCursor.style.background = "rgba(var(--color-primary-100), 0.2)";
activeCursor.style.opacity = "0.5";
activeCursor.style.position = "absolute";
activeCursor.style.top = `${event.y}px`;
activeCursor.style.left = `${event.x}px`;
activeCursor.style.pointerEvents = "none";
lastActiveSelection.top = event.y;
lastActiveSelection.left = event.x;
document.body.appendChild(activeCursor);
// Pass view to updateCursorPosition
document.addEventListener("mousemove", updateCursorPosition(view, editor));
document.addEventListener("mouseup", removeActiveUser);
return false;
},
},
},
destroy() {
removeActiveUser();
},
});
export const multipleSelectionExtension = Extension.create({
name: "multipleSelection",
addProseMirrorPlugins(this) {
return [createMultipleSelectionPlugin(this.editor)];
},
});
@@ -1,248 +0,0 @@
import { Editor, Extension } from "@tiptap/core";
import { Plugin, PluginKey, Selection, TextSelection } from "prosemirror-state";
import { EditorView, Decoration, DecorationSet } from "prosemirror-view";
import { ResolvedPos } from "prosemirror-model";
const MultipleSelectionPluginKey = new PluginKey("multipleSelection");
// Custom Selection Class
class MultipleNodeSelection extends Selection {
constructor($from: ResolvedPos, $to: ResolvedPos) {
super($from, $to);
}
map(doc: any, mapping: any) {
const $from = doc.resolve(mapping.map(this.$from.pos));
const $to = doc.resolve(mapping.map(this.$to.pos));
return new MultipleNodeSelection($from, $to);
}
content() {
return this.$from.node(0).slice(this.$from.pos, this.$to.pos, true);
}
eq(other: Selection): boolean {
return (
other instanceof MultipleNodeSelection && other.$from.pos === this.$from.pos && other.$to.pos === this.$to.pos
);
}
static create(doc: any, from: number, to: number) {
const $from = doc.resolve(from);
const $to = doc.resolve(to);
return new MultipleNodeSelection($from, $to);
}
replaceWith(tr: any, node: any) {
super.replaceWith(tr, node);
}
}
interface BlockSelectionState {
ranges: Array<[number, number]> | null;
lastSelectedRange?: { from: number; to: number };
}
let activeCursor: HTMLElement | null = null;
const lastActiveSelection = {
top: 0,
left: 0,
};
const updateCursorPosition = (event: MouseEvent) => {
if (!activeCursor) return;
let newHeight = event.y - lastActiveSelection.top;
let newWidth = event.x - lastActiveSelection.left;
if (newHeight < 0) {
activeCursor.style.marginTop = `${newHeight}px`;
newHeight *= -1;
} else {
activeCursor.style.marginTop = "0px";
}
if (newWidth < 0) {
activeCursor.style.marginLeft = `${newWidth}px`;
newWidth *= -1;
} else {
activeCursor.style.marginLeft = "0px";
}
activeCursor.style.height = `${newHeight}px`;
activeCursor.style.width = `${newWidth}px`;
};
const getNodesInRect = (view: EditorView, rect: DOMRect) => {
const ranges: Array<[number, number]> = [];
const { doc } = view.state;
const isInRect = (pos: number) => {
const coords = view.coordsAtPos(pos);
return (
coords &&
coords.top < rect.bottom &&
coords.bottom > rect.top &&
coords.left < rect.right &&
coords.right > rect.left
);
};
doc.nodesBetween(0, doc.content.size, (node, pos) => {
const resolvedPos = view.state.doc.resolve(pos);
if (resolvedPos.depth === 1) {
const start = resolvedPos.before(1);
const end = resolvedPos.after(1);
if (isInRect(start) || isInRect(end)) {
ranges.push([start, end]);
}
return false;
}
return true;
});
return ranges;
};
const removeActiveUser = () => {
if (!activeCursor) return;
activeCursor.remove();
activeCursor = null;
document.removeEventListener("mousemove", selectNodesHandler);
document.removeEventListener("mouseup", removeActiveUser);
if (lastView) {
const pluginState = MultipleSelectionPluginKey.getState(lastView.state);
const ranges = pluginState?.ranges;
if (ranges && ranges.length > 0) {
// Sort ranges by position
const sortedRanges = [...ranges].sort(([a], [b]) => a - b);
const from = sortedRanges[0][0];
const to = sortedRanges[sortedRanges.length - 1][1];
const tr = lastView.state.tr;
// Create our custom selection
const selection = MultipleNodeSelection.create(lastView.state.doc, from, to);
tr.setSelection(selection).setMeta(MultipleSelectionPluginKey, {
ranges: sortedRanges,
lastSelectedRange: { from, to },
});
lastView.dispatch(tr);
}
}
};
let selectNodesHandler: (event: MouseEvent) => void;
let lastView: EditorView | null = null;
const createMultipleSelectionPlugin = () =>
new Plugin<BlockSelectionState>({
key: MultipleSelectionPluginKey,
state: {
init() {
return { ranges: null };
},
apply(tr, state) {
const action = tr.getMeta(MultipleSelectionPluginKey);
if (action) {
return action;
}
return state;
},
},
props: {
handleDOMEvents: {
mousedown(view: EditorView, event: MouseEvent) {
if (event.target !== view.dom) {
return false;
}
lastView = view;
removeActiveUser();
activeCursor = document.createElement("div");
activeCursor.className = "multipleSelectionCursor";
activeCursor.style.width = "0px";
activeCursor.style.height = "0px";
activeCursor.style.borderRadius = "2px";
activeCursor.style.border = "1px solid rgba(var(--color-primary-100), 0.2)";
activeCursor.style.background = "rgba(var(--color-primary-100), 0.2)";
activeCursor.style.opacity = "0.5";
activeCursor.style.position = "absolute";
activeCursor.style.top = `${event.y}px`;
activeCursor.style.left = `${event.x}px`;
activeCursor.style.pointerEvents = "none";
activeCursor.style.zIndex = "1000";
lastActiveSelection.top = event.y;
lastActiveSelection.left = event.x;
document.body.appendChild(activeCursor);
selectNodesHandler = (e: MouseEvent) => {
updateCursorPosition(e);
if (activeCursor) {
const rect = activeCursor.getBoundingClientRect();
const ranges = getNodesInRect(view, rect);
view.dispatch(
view.state.tr.setMeta(MultipleSelectionPluginKey, {
ranges,
lastSelectedRange: undefined,
})
);
}
};
document.addEventListener("mousemove", selectNodesHandler);
document.addEventListener("mouseup", removeActiveUser);
event.preventDefault();
return true;
},
},
decorations(state) {
const pluginState = this.getState(state);
if (pluginState?.ranges?.length) {
return DecorationSet.create(
state.doc,
pluginState.ranges.map(([from, to]) =>
Decoration.node(from, to, {
class: "custom-selection",
})
)
);
}
return null;
},
},
view() {
return {
destroy() {
removeActiveUser();
lastView = null;
},
};
},
});
// Register our custom selection with ProseMirror
Selection.jsonID("multipleNode", MultipleNodeSelection);
export const multipleSelectionExtension = Extension.create({
name: "multipleSelection",
addProseMirrorPlugins() {
return [createMultipleSelectionPlugin()];
},
});
@@ -1,44 +0,0 @@
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { MultiRangeSelection } from "./MultiRangeSelection";
export const multiSelectionDecorationPluginKey = new PluginKey("multiSelectionDecorationPlugin");
export function createMultiSelectionDecorationPlugin() {
return new Plugin({
key: multiSelectionDecorationPluginKey,
props: {
decorations(state) {
const { doc, selection } = state;
// Only create decorations if we have a MultiRangeSelection
if (!(selection instanceof MultiRangeSelection)) {
return null;
}
const decorations: Decoration[] = [];
// (1) Decorate all nodes so that their ::selection is transparent,
// because we only want to highlight the actual selection range
doc.nodesBetween(0, doc.nodeSize - 2, (node, pos) => {
decorations.push(
Decoration.inline(pos, pos + node.nodeSize, {
class: "multiple-selection",
})
);
});
// (2) Decorate the actual selection ranges in a visible style
selection.ranges.forEach((range) => {
decorations.push(
Decoration.inline(range.$from.pos, range.$to.pos, {
class: "selected-text",
})
);
});
return DecorationSet.create(doc, decorations);
},
},
});
}
@@ -42,7 +42,7 @@ export const SideMenuExtension = (props: Props) => {
ai: aiEnabled,
dragDrop: dragDropEnabled,
},
scrollThreshold: { up: 200, down: 150 },
scrollThreshold: { up: 200, down: 100 },
}),
];
},
@@ -1,18 +1,14 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
import { Editor } from "@tiptap/core";
// helpers
import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
// components
import { TSlashCommandSection } from "./command-items-list";
import { CommandMenuItem } from "./command-menu-item";
export type SlashCommandsMenuProps = {
editor: Editor;
items: TSlashCommandSection[];
command: any;
};
export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) => {
export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
const { items: sections, command } = props;
// states
const [selectedIndex, setSelectedIndex] = useState({
@@ -45,12 +41,12 @@ export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref)
if (nextItem < 0) {
nextSection = currentSection - 1;
if (nextSection < 0) nextSection = sections.length - 1;
nextItem = sections[nextSection]?.items?.length - 1;
nextItem = sections[nextSection]?.items.length - 1;
}
}
if (e.key === "ArrowDown") {
nextItem = currentItem + 1;
if (nextItem >= sections[currentSection]?.items?.length) {
if (nextItem >= sections[currentSection].items.length) {
nextSection = currentSection + 1;
if (nextSection >= sections.length) nextSection = 0;
nextItem = 0;
@@ -88,26 +84,7 @@ export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref)
item?.scrollIntoView({ block: "nearest" });
}, [sections, selectedIndex]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (!DROPDOWN_NAVIGATION_KEYS.includes(event.key)) return;
event.preventDefault();
if (event.key === "Enter") {
selectItem(selectedIndex.section, selectedIndex.item);
return;
}
const newIndex = getNextValidIndex({
event,
sections,
selectedIndex,
});
setSelectedIndex(newIndex);
},
}));
const areSearchResultsEmpty = sections.map((s) => s.items?.length).reduce((acc, curr) => acc + curr, 0) === 0;
const areSearchResultsEmpty = sections.map((s) => s.items.length).reduce((acc, curr) => acc + curr, 0) === 0;
if (areSearchResultsEmpty) return null;
@@ -121,7 +98,7 @@ export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref)
<div key={section.key} className="space-y-2">
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
<div>
{section.items?.map((item, itemIndex) => (
{section.items.map((item, itemIndex) => (
<CommandMenuItem
key={item.key}
isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item}
@@ -145,6 +122,4 @@ export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref)
))}
</div>
);
});
SlashCommandsMenu.displayName = "SlashCommandsMenu";
};
@@ -2,8 +2,6 @@ import { Editor, Range, Extension } from "@tiptap/core";
import { ReactRenderer } from "@tiptap/react";
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
import tippy from "tippy.js";
// helpers
import { CommandListInstance } from "@/helpers/tippy";
// types
import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types";
// components
@@ -57,12 +55,16 @@ const Command = Extension.create<SlashCommandOptions>({
},
});
interface CommandListInstance {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
const renderItems = () => {
let component: ReactRenderer<CommandListInstance, SlashCommandsMenuProps> | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component = new ReactRenderer<CommandListInstance, SlashCommandsMenuProps>(SlashCommandsMenu, {
component = new ReactRenderer(SlashCommandsMenu, {
props,
editor: props.editor,
});
@@ -89,8 +91,10 @@ const renderItems = () => {
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
if (component?.ref?.onKeyDown(props)) {
return true;
}
@@ -5,7 +5,6 @@ import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-
import { findTableAncestor } from "@/helpers/common";
// types
import { InsertImageComponentProps } from "@/extensions";
import { EditorState, NodeSelection, Transaction } from "@tiptap/pm/state";
export const setText = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("paragraph").run();
@@ -43,48 +42,10 @@ export const toggleHeadingSix = (editor: Editor, range?: Range) => {
};
export const toggleBold = (editor: Editor, range?: Range) => {
const { view } = editor;
const { from, to } = view.state.selection;
const tr = createNodeSelectionsAtDepth(editor.state, from, to, 1);
if (tr) editor.view.dispatch(tr);
// if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
// else editor.chain().focus().toggleBold().run();
if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
else editor.chain().focus().toggleBold().run();
};
/**
* Creates multiple NodeSelections for nodes within a range at a specified depth
*
* @param state - The current EditorState
* @param from - Starting position of the range
* @param to - Ending position of the range
* @param depth - The depth at which to select nodes (e.g. 1 for direct children of the parent)
* @returns Transaction with the NodeSelections or null if invalid
*/
function createNodeSelectionsAtDepth(state: EditorState, from: number, to: number, depth: number): Transaction | null {
const { doc, tr } = state;
let hasSelections = false;
console.log("asafdasf", doc, from, to);
// Collect positions of nodes at specified depth within range
doc.nodesBetween(from, to, (node, pos, parent, index) => {
console.log("node", node, parent, doc.resolve(pos).depth, depth);
// Only process nodes at the specified depth
if (parent) {
console.log("node inside if", node);
// Create a NodeSelection if the node is selectable
if (NodeSelection.isSelectable(node)) {
console.log("creating node selection", node);
tr.setSelection(NodeSelection.create(doc, pos));
hasSelections = true;
}
}
// Return true to continue traversing
return true;
});
return hasSelections ? tr : null;
}
export const toggleItalic = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleItalic().run();
else editor.chain().focus().toggleItalic().run();
-58
View File
@@ -1,58 +0,0 @@
export type CommandListInstance = {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
};
type TArgs = {
event: KeyboardEvent;
sections: {
items: any[];
}[];
selectedIndex: {
section: number;
item: number;
};
};
export const DROPDOWN_NAVIGATION_KEYS = ["ArrowUp", "ArrowDown", "Enter"];
export const getNextValidIndex = (
args: TArgs
):
| {
section: number;
item: number;
}
| undefined => {
const { event, sections, selectedIndex } = args;
const direction = event.key === "ArrowUp" ? "up" : "down";
if (!sections.length) return { section: 0, item: 0 };
// next available selection
let nextSection = selectedIndex.section;
let nextItem = selectedIndex.item;
if (direction === "up") {
nextItem--;
if (nextItem < 0) {
// Move to previous section
nextSection--;
if (nextSection < 0) {
// Wrap to last section
nextSection = sections?.length - 1;
}
nextItem = sections?.[nextSection]?.items?.length - 1;
}
} else {
nextItem++;
if (nextItem >= sections?.[nextSection]?.items?.length) {
// Move to next section
nextSection++;
if (nextSection >= sections?.length) {
// Wrap to first section
nextSection = 0;
}
nextItem = 0;
}
}
return { section: nextSection, item: nextItem };
};
+12 -5
View File
@@ -3,7 +3,7 @@ import { HocuspocusProvider } from "@hocuspocus/provider";
import { DOMSerializer } from "@tiptap/pm/model";
import { Selection } from "@tiptap/pm/state";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Editor, Extensions } from "@tiptap/react";
import { useEditor as useTiptapEditor, Editor } from "@tiptap/react";
import * as Y from "yjs";
// components
import { EditorMenuItem, getEditorMenuItems } from "@/components/menus";
@@ -19,10 +19,11 @@ import { CoreEditorProps } from "@/props";
import type {
TDocumentEventsServer,
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
TEditorCommands,
TFileHandler,
TExtensions,
TMentionHandler,
} from "@/types";
export interface CustomEditorProps {
@@ -31,13 +32,16 @@ export interface CustomEditorProps {
editorProps?: EditorProps;
enableHistory: boolean;
disabledExtensions: TExtensions[];
extensions?: Extensions;
extensions?: any;
fileHandler: TFileHandler;
forwardedRef?: MutableRefObject<EditorRefApi | null>;
handleEditorReady?: (value: boolean) => void;
id?: string;
initialValue?: string;
mentionHandler: TMentionHandler;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions?: () => Promise<IMentionSuggestion[]>;
};
onChange?: (json: object, html: string) => void;
onTransaction?: () => void;
autofocus?: boolean;
@@ -92,7 +96,10 @@ export const useEditor = (props: CustomEditorProps) => {
disabledExtensions,
enableHistory,
fileHandler,
mentionHandler,
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights,
},
placeholder,
tabIndex,
}),
@@ -1,7 +1,7 @@
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useCustomEditor, Editor, Extensions } from "@tiptap/react";
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import * as Y from "yjs";
// extensions
import { CoreReadOnlyEditorExtensions } from "@/extensions";
@@ -13,22 +13,24 @@ import { CoreReadOnlyEditorProps } from "@/props";
// types
import type {
EditorReadOnlyRefApi,
IMentionHighlight,
TExtensions,
TDocumentEventsServer,
TFileHandler,
TReadOnlyMentionHandler,
} from "@/types";
interface CustomReadOnlyEditorProps {
disabledExtensions: TExtensions[];
editorClassName: string;
editorProps?: EditorProps;
extensions?: Extensions;
extensions?: any;
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
initialValue?: string;
fileHandler: Pick<TFileHandler, "getAssetSrc">;
handleEditorReady?: (value: boolean) => void;
mentionHandler: TReadOnlyMentionHandler;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
};
provider?: HocuspocusProvider;
}
@@ -61,7 +63,9 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
extensions: [
...CoreReadOnlyEditorExtensions({
disabledExtensions,
mentionHandler,
mentionConfig: {
mentionHighlights: mentionHandler.highlights,
},
fileHandler,
}),
...extensions,
+180 -253
View File
@@ -1,5 +1,5 @@
import { Fragment, Slice, Node, Schema } from "@tiptap/pm/model";
import { NodeSelection } from "@tiptap/pm/state";
import { Fragment, Slice, Node } from "@tiptap/pm/model";
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
// @ts-expect-error __serializeForClipboard's is not exported
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
// extensions
@@ -8,29 +8,6 @@ import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
const verticalEllipsisIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis-vertical"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>';
const generalSelectors = [
"li",
"p:not(:first-child)",
".code-block",
"blockquote",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
".image-component",
".image-upload-component",
".editor-callout-component",
].join(", ");
const maxScrollSpeed = 20;
const acceleration = 0.5;
const scrollParentCache = new WeakMap();
function easeOutQuadAnimation(t: number) {
return t * (2 - t);
}
const createDragHandleElement = (): HTMLElement => {
const dragHandleElement = document.createElement("button");
dragHandleElement.type = "button";
@@ -53,39 +30,21 @@ const createDragHandleElement = (): HTMLElement => {
return dragHandleElement;
};
const isScrollable = (node: HTMLElement | SVGElement) => {
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
return false;
}
const style = getComputedStyle(node);
return ["overflow", "overflow-y"].some((propertyName) => {
const value = style.getPropertyValue(propertyName);
return value === "auto" || value === "scroll";
});
};
const getScrollParent = (node: HTMLElement | SVGElement) => {
if (scrollParentCache.has(node)) {
return scrollParentCache.get(node);
}
let currentParent = node.parentElement;
while (currentParent) {
if (isScrollable(currentParent)) {
scrollParentCache.set(node, currentParent);
return currentParent;
}
currentParent = currentParent.parentElement;
}
const result = document.scrollingElement || document.documentElement;
scrollParentCache.set(node, result);
return result;
};
export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [
"li",
"p:not(:first-child)",
".code-block",
"blockquote",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
".image-component",
".image-upload-component",
".editor-callout-component",
].join(", ");
for (const elem of elements) {
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
@@ -126,74 +85,140 @@ const nodePosAtDOMForBlockQuotes = (node: Element, view: EditorView) => {
})?.inside;
};
const calcNodePos = (pos: number, view: EditorView, node: Element) => {
const maxPos = view.state.doc.content.size;
const safePos = Math.max(0, Math.min(pos, maxPos));
const $pos = view.state.doc.resolve(safePos);
if ($pos.depth > 1) {
if (node.matches("ul li, ol li")) {
// only for nested lists
const newPos = $pos.before($pos.depth);
return Math.max(0, Math.min(newPos, maxPos));
}
}
return safePos;
};
export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => {
let listType = "";
let isDragging = false;
let lastClientY = 0;
let scrollAnimationFrame = null;
let isDraggedOutsideWindow: "top" | "bottom" | boolean = false;
let isMouseInsideWhileDragging = false;
let currentScrollSpeed = 0;
const handleClick = (event: MouseEvent, view: EditorView) => {
handleNodeSelection(event, view, false, options);
};
const handleDragStart = (event: DragEvent, view: EditorView) => {
const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options);
listType = listTypeFromDragStart;
isDragging = true;
lastClientY = event.clientY;
scroll();
};
view.focus();
const handleDragEnd = <TEvent extends DragEvent | FocusEvent>(event: TEvent, view?: EditorView) => {
event.preventDefault();
isDragging = false;
isMouseInsideWhileDragging = false;
if (scrollAnimationFrame) {
cancelAnimationFrame(scrollAnimationFrame);
scrollAnimationFrame = null;
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view, node);
const { from, to } = view.state.selection;
const diff = from - to;
const fromSelectionPos = calcNodePos(from, view, node);
let differentNodeSelected = false;
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
else {
// TODO FIX ERROR
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
// Check if the node where the drag event started is part of the current selection
differentNodeSelected = !(
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
);
}
view?.dom.classList.remove("dragging");
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
const endSelection = NodeSelection.create(view.state.doc, to - 1);
const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
view.dispatch(view.state.tr.setSelection(multiNodeSelection));
} else {
// TODO FIX ERROR
const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") {
listType = node.parentElement!.tagName;
}
if (node.matches("blockquote")) {
let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view);
if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return;
const docSize = view.state.doc.content.size;
nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize));
if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) {
// TODO FIX ERROR
const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
}
const slice = view.state.selection.content();
const { dom, text } = __serializeForClipboard(view, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData("text/html", dom.innerHTML);
event.dataTransfer.setData("text/plain", text);
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setDragImage(node, 0, 0);
view.dragging = { slice, move: event.ctrlKey };
};
function scroll() {
if (!isDragging) {
currentScrollSpeed = 0;
const handleClick = (event: MouseEvent, view: EditorView) => {
view.focus();
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
if (node.matches("blockquote")) {
let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view);
if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return;
const docSize = view.state.doc.content.size;
nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize));
if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) {
// TODO FIX ERROR
const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
return;
}
const scrollableParent = getScrollParent(dragHandleElement);
console.log("scrollableParent drag hanlde", scrollableParent);
if (!scrollableParent) return;
let nodePos = nodePosAtDOM(node, view, options);
const scrollRegionUp = options.scrollThreshold.up;
const scrollRegionDown = window.innerHeight - options.scrollThreshold.down;
if (nodePos === null || nodePos === undefined) return;
let targetScrollAmount = 0;
// Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied
nodePos = calcNodePos(nodePos, view, node);
if (isDraggedOutsideWindow === "top") {
targetScrollAmount = -maxScrollSpeed * 5;
} else if (isDraggedOutsideWindow === "bottom") {
targetScrollAmount = maxScrollSpeed * 5;
} else if (lastClientY < scrollRegionUp) {
const ratio = easeOutQuadAnimation((scrollRegionUp - lastClientY) / options.scrollThreshold.up);
targetScrollAmount = -maxScrollSpeed * ratio;
} else if (lastClientY > scrollRegionDown) {
const ratio = easeOutQuadAnimation((lastClientY - scrollRegionDown) / options.scrollThreshold.down);
targetScrollAmount = maxScrollSpeed * ratio;
}
// TODO FIX ERROR
// Use NodeSelection to select the node at the calculated position
const nodeSelection = NodeSelection.create(view.state.doc, nodePos);
currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * acceleration;
if (Math.abs(currentScrollSpeed) > 0.1) {
scrollableParent.scrollBy({ top: currentScrollSpeed });
}
scrollAnimationFrame = requestAnimationFrame(scroll);
}
// Dispatch the transaction to update the selection
view.dispatch(view.state.tr.setSelection(nodeSelection));
};
let dragHandleElement: HTMLElement | null = null;
// drag handle view actions
@@ -206,46 +231,51 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
const view = (view: EditorView, sideMenu: HTMLDivElement | null) => {
dragHandleElement = createDragHandleElement();
dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view));
dragHandleElement.addEventListener("dragend", (e) => handleDragEnd(e, view));
dragHandleElement.addEventListener("click", (e) => handleClick(e, view));
dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view));
const dragOverHandler = (e: DragEvent) => {
e.preventDefault();
if (isDragging) {
lastClientY = e.clientY;
const isScrollable = (node: HTMLElement | SVGElement) => {
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
return false;
}
const style = getComputedStyle(node);
return ["overflow", "overflow-y"].some((propertyName) => {
const value = style.getPropertyValue(propertyName);
return value === "auto" || value === "scroll";
});
};
const mouseMoveHandler = (e: MouseEvent) => {
if (isMouseInsideWhileDragging) {
handleDragEnd(e, view);
}
};
const dragLeaveHandler = (e: DragEvent) => {
if (e.clientY <= 0 || e.clientX <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
isMouseInsideWhileDragging = true;
const windowMiddleY = window.innerHeight / 2;
if (lastClientY < windowMiddleY) {
isDraggedOutsideWindow = "top";
} else {
isDraggedOutsideWindow = "bottom";
const getScrollParent = (node: HTMLElement | SVGElement) => {
let currentParent = node.parentElement;
while (currentParent) {
if (isScrollable(currentParent)) {
return currentParent;
}
currentParent = currentParent.parentElement;
}
return document.scrollingElement || document.documentElement;
};
const dragEnterHandler = () => {
isDraggedOutsideWindow = false;
};
const maxScrollSpeed = 100;
window.addEventListener("dragleave", dragLeaveHandler);
window.addEventListener("dragenter", dragEnterHandler);
dragHandleElement.addEventListener("drag", (e) => {
hideDragHandle();
const scrollableParent = getScrollParent(dragHandleElement);
if (!scrollableParent) return;
const scrollThreshold = options.scrollThreshold;
document.addEventListener("dragover", dragOverHandler);
document.addEventListener("mousemove", mouseMoveHandler);
if (e.clientY < scrollThreshold.up) {
const overflow = scrollThreshold.up - e.clientY;
const ratio = Math.min(overflow / scrollThreshold.up, 1);
const scrollAmount = -maxScrollSpeed * ratio;
scrollableParent.scrollBy({ top: scrollAmount });
} else if (window.innerHeight - e.clientY < scrollThreshold.down) {
const overflow = e.clientY - (window.innerHeight - scrollThreshold.down);
const ratio = Math.min(overflow / scrollThreshold.down, 1);
const scrollAmount = maxScrollSpeed * ratio;
scrollableParent.scrollBy({ top: scrollAmount });
}
});
hideDragHandle();
@@ -255,15 +285,6 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
destroy: () => {
dragHandleElement?.remove?.();
dragHandleElement = null;
isDragging = false;
if (scrollAnimationFrame) {
cancelAnimationFrame(scrollAnimationFrame);
scrollAnimationFrame = null;
}
window.removeEventListener("dragleave", dragLeaveHandler);
window.removeEventListener("dragenter", dragEnterHandler);
document.removeEventListener("dragover", dragOverHandler);
document.removeEventListener("mousemove", mouseMoveHandler);
},
};
};
@@ -292,36 +313,29 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
const resolvedPos = view.state.doc.resolve(dropPos.pos);
let isDroppedInsideList = false;
let dropDepth = 0;
// Traverse up the document tree to find if we're inside a list item
for (let i = resolvedPos.depth; i > 0; i--) {
if (resolvedPos.node(i).type.name === "listItem") {
isDroppedInsideList = true;
dropDepth = i;
break;
}
}
// Handle nested list items and task items
if (droppedNode.type.name === "listItem") {
let slice = view.state.selection.content();
let newFragment = slice.content;
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === "listItem" &&
!isDroppedInsideList &&
listType == "OL"
) {
const text = droppedNode.textContent;
if (!text) return;
const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text));
const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph);
// If dropping outside a list or at a different depth, adjust the structure
if (!isDroppedInsideList || dropDepth !== resolvedPos.depth) {
// Flatten the structure if needed
newFragment = flattenListStructure(newFragment, view.state.schema);
}
// Wrap in appropriate list type if dropped outside a list
if (!isDroppedInsideList) {
const listNodeType =
listType === "OL" ? view.state.schema.nodes.orderedList : view.state.schema.nodes.bulletList;
newFragment = Fragment.from(listNodeType.create(null, newFragment));
}
slice = new Slice(newFragment, slice.openStart, slice.openEnd);
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem);
const slice = new Slice(Fragment.from(newList), 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
},
@@ -335,90 +349,3 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
domEvents,
};
};
// Helper function to flatten nested list structure
function flattenListStructure(fragment: Fragment, schema: Schema): Fragment {
const result: Node[] = [];
fragment.forEach((node) => {
if (node.type === schema.nodes.listItem || node.type === schema.nodes.taskItem) {
result.push(node);
if (
node.content.firstChild &&
(node.content.firstChild.type === schema.nodes.bulletList ||
node.content.firstChild.type === schema.nodes.orderedList)
) {
const sublist = node.content.firstChild;
const flattened = flattenListStructure(sublist.content, schema);
flattened.forEach((subNode) => result.push(subNode));
}
}
});
return Fragment.from(result);
}
const handleNodeSelection = (
event: MouseEvent | DragEvent,
view: EditorView,
isDragStart: boolean,
options: SideMenuPluginProps
) => {
let listType = "";
view.focus();
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
// Handle blockquotes separately
if (node.matches("blockquote")) {
draggedNodePos = nodePosAtDOMForBlockQuotes(node, view);
if (draggedNodePos === null || draggedNodePos === undefined) return;
} else {
// Resolve the position to get the parent node
const $pos = view.state.doc.resolve(draggedNodePos);
// If it's a nested list item or task item, move up to the item level
if (($pos.parent.type.name === "listItem" || $pos.parent.type.name === "taskItem") && $pos.depth > 1) {
draggedNodePos = $pos.before($pos.depth);
}
}
const docSize = view.state.doc.content.size;
draggedNodePos = Math.max(0, Math.min(draggedNodePos, docSize));
// Use NodeSelection to select the node at the calculated position
// const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos);
//
// // Dispatch the transaction to update the selection
// view.dispatch(view.state.tr.setSelection(nodeSelection));
// if (isDragStart) {
// // Additional logic for drag start
// if (event instanceof DragEvent && !event.dataTransfer) return;
//
// if (nodeSelection.node.type.name === "listItem" || nodeSelection.node.type.name === "taskItem") {
// listType = node.closest("ol, ul")?.tagName || "";
// }
//
// const slice = view.state.selection.content();
// const { dom, text } = __serializeForClipboard(view, slice);
//
// if (event instanceof DragEvent) {
// event.dataTransfer.clearData();
// event.dataTransfer.setData("text/html", dom.innerHTML);
// event.dataTransfer.setData("text/plain", text);
// event.dataTransfer.effectAllowed = "copyMove";
// event.dataTransfer.setDragImage(node, 0, 0);
// }
//
// view.dragging = { slice, move: event.ctrlKey };
// }
return { listType };
};
@@ -6,10 +6,10 @@ import { TEmbedConfig } from "@/plane-editor/types";
import {
EditorReadOnlyRefApi,
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
TExtensions,
TFileHandler,
TMentionHandler,
TReadOnlyMentionHandler,
TRealtimeConfig,
TUserDetails,
} from "@/types";
@@ -27,6 +27,10 @@ type TCollaborativeEditorHookProps = {
extensions?: Extensions;
handleEditorReady?: (value: boolean) => void;
id: string;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions?: () => Promise<IMentionSuggestion[]>;
};
realtimeConfig: TRealtimeConfig;
serverHandler?: TServerHandler;
user: TUserDetails;
@@ -37,7 +41,6 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
embedHandler?: TEmbedConfig;
fileHandler: TFileHandler;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
mentionHandler: TMentionHandler;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
};
@@ -45,5 +48,4 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
fileHandler: Pick<TFileHandler, "getAssetSrc">;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
mentionHandler: TReadOnlyMentionHandler;
};
+12 -7
View File
@@ -1,9 +1,11 @@
import { Extensions, JSONContent } from "@tiptap/core";
import { JSONContent } from "@tiptap/core";
import { Selection } from "@tiptap/pm/state";
// helpers
import { IMarking } from "@/helpers/scroll-to-node";
// types
import {
IMentionHighlight,
IMentionSuggestion,
TAIHandler,
TDisplayConfig,
TDocumentEventEmitter,
@@ -11,8 +13,6 @@ import {
TEmbedConfig,
TExtensions,
TFileHandler,
TMentionHandler,
TReadOnlyMentionHandler,
TServerHandler,
} from "@/types";
import { TTextAlign } from "@/extensions";
@@ -114,7 +114,10 @@ export interface IEditorProps {
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
id: string;
initialValue: string;
mentionHandler: TMentionHandler;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions?: () => Promise<IMentionSuggestion[]>;
};
onChange?: (json: object, html: string) => void;
onTransaction?: () => void;
handleEditorReady?: (value: boolean) => void;
@@ -125,10 +128,10 @@ export interface IEditorProps {
value?: string | null;
}
export interface ILiteTextEditor extends IEditorProps {
extensions?: Extensions;
extensions?: any[];
}
export interface IRichTextEditor extends IEditorProps {
extensions?: Extensions;
extensions?: any[];
bubbleMenuEnabled?: boolean;
dragDropEnabled?: boolean;
}
@@ -155,7 +158,9 @@ export interface IReadOnlyEditorProps {
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
id: string;
initialValue: string;
mentionHandler: TReadOnlyMentionHandler;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
};
}
export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps;
+1 -1
View File
@@ -5,7 +5,7 @@ export * from "./editor";
export * from "./embed";
export * from "./extensions";
export * from "./image";
export * from "./mention";
export * from "./mention-suggestion";
export * from "./slash-commands-suggestion";
export * from "@/plane-editor/types";
export * from "./document-collaborative-events";
@@ -0,0 +1,12 @@
export type IMentionSuggestion = {
id: string;
type: string;
entity_name: string;
entity_identifier: string;
avatar: string;
title: string;
subtitle: string;
redirect_uri: string;
};
export type IMentionHighlight = string;
-27
View File
@@ -1,27 +0,0 @@
// plane types
import { TSearchEntities } from "@plane/types";
export type TMentionSuggestion = {
entity_identifier: string;
entity_name: TSearchEntities;
icon: React.ReactNode;
id: string;
subTitle?: string;
title: string;
};
export type TMentionSection = {
key: string;
title?: string;
items: TMentionSuggestion[];
};
export type TMentionComponentProps = Pick<TMentionSuggestion, "entity_identifier" | "entity_name">;
export type TReadOnlyMentionHandler = {
renderComponent: (props: TMentionComponentProps) => React.ReactNode;
};
export type TMentionHandler = TReadOnlyMentionHandler & {
searchCallback?: (query: string) => Promise<TMentionSection[]>;
};
+2 -3
View File
@@ -51,10 +51,9 @@
&::after {
content: "";
position: absolute;
top: 50%;
transform: translateY(-50%);
top: 0;
left: calc(-1 * var(--horizontal-offset));
height: calc(100% - 1.5px);
height: 100%;
width: calc(100% + (var(--horizontal-offset) * 2));
background-color: rgba(var(--color-primary-100), 0.2);
border-radius: 4px;

Some files were not shown because too many files have changed in this diff Show More