Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 733b725b5e | |||
| 3bee051ee9 | |||
| 6b902d458e |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -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.
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
|
||||
Sign In
|
||||
</Link>
|
||||
now.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
|
||||
title: `Admin user does not exist`,
|
||||
message: () => (
|
||||
<div>
|
||||
Admin user does not exist.
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
|
||||
Sign In
|
||||
</Link>
|
||||
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} />,
|
||||
},
|
||||
];
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
|
||||
Sign In
|
||||
</Link>
|
||||
now.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
|
||||
title: `Admin user does not exist`,
|
||||
message: () => (
|
||||
<div>
|
||||
Admin user does not exist.
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
|
||||
Sign In
|
||||
</Link>
|
||||
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} />,
|
||||
},
|
||||
];
|
||||
@@ -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";
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./instance.helper";
|
||||
export * from "./user.helper";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -5,6 +5,7 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["core/*"],
|
||||
"@/helpers/*": ["helpers/*"],
|
||||
"@/public/*": ["public/*"],
|
||||
"@/plane-admin/*": ["ce/*"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export enum AI_EDITOR_TASKS {
|
||||
ASK_ANYTHING = "ASK_ANYTHING",
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
|
||||
+84
-193
@@ -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 range’s 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 it’s 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();
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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[]>;
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user