mirror of
https://github.com/docmost/docmost.git
synced 2025-11-18 18:51:46 +10:00
Merge branch 'main' into ai-vector
This commit is contained in:
@ -495,5 +495,10 @@
|
|||||||
"Page restored successfully": "Page restored successfully",
|
"Page restored successfully": "Page restored successfully",
|
||||||
"Deleted by": "Deleted by",
|
"Deleted by": "Deleted by",
|
||||||
"Deleted at": "Deleted at",
|
"Deleted at": "Deleted at",
|
||||||
"Preview": "Preview"
|
"Preview": "Preview",
|
||||||
|
"Subpages": "Subpages",
|
||||||
|
"Failed to load subpages": "Failed to load subpages",
|
||||||
|
"No subpages": "No subpages",
|
||||||
|
"Subpages (Child pages)": "Subpages (Child pages)",
|
||||||
|
"List all subpages of the current page": "List all subpages of the current page"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,14 @@ import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import {
|
||||||
|
SearchControl,
|
||||||
|
SearchMobileControl,
|
||||||
|
} from "@/features/search/components/search-control.tsx";
|
||||||
|
import {
|
||||||
|
searchSpotlight,
|
||||||
|
shareSearchSpotlight,
|
||||||
|
} from "@/features/search/constants.ts";
|
||||||
|
|
||||||
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
||||||
|
|
||||||
@ -79,6 +87,15 @@ export function AppHeader() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Group visibleFrom="sm">
|
||||||
|
<SearchControl onClick={searchSpotlight.open} />
|
||||||
|
</Group>
|
||||||
|
<Group hiddenFrom="sm">
|
||||||
|
<SearchMobileControl onSearch={searchSpotlight.open} />
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Group px={"xl"} wrap="nowrap">
|
<Group px={"xl"} wrap="nowrap">
|
||||||
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@ -1,16 +1,23 @@
|
|||||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet, useParams } from "react-router-dom";
|
||||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||||
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
||||||
|
import React from "react";
|
||||||
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const { spaceSlug } = useParams();
|
||||||
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<GlobalAppShell>
|
<GlobalAppShell>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
{isCloud() && <PosthogUser />}
|
{isCloud() && <PosthogUser />}
|
||||||
|
<SearchSpotlight spaceId={space?.id} />
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,11 @@ export interface EmojiPickerInterface {
|
|||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
removeEmojiAction: () => void;
|
removeEmojiAction: () => void;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
actionIconProps?: {
|
||||||
|
size?: string;
|
||||||
|
variant?: string;
|
||||||
|
c?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmojiPicker({
|
function EmojiPicker({
|
||||||
@ -22,6 +27,7 @@ function EmojiPicker({
|
|||||||
icon,
|
icon,
|
||||||
removeEmojiAction,
|
removeEmojiAction,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
actionIconProps,
|
||||||
}: EmojiPickerInterface) {
|
}: EmojiPickerInterface) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, handlers] = useDisclosure(false);
|
const [opened, handlers] = useDisclosure(false);
|
||||||
@ -64,7 +70,12 @@ function EmojiPicker({
|
|||||||
closeOnEscape={true}
|
closeOnEscape={true}
|
||||||
>
|
>
|
||||||
<Popover.Target ref={setTarget}>
|
<Popover.Target ref={setTarget}>
|
||||||
<ActionIcon c="gray" variant="transparent" onClick={handlers.toggle}>
|
<ActionIcon
|
||||||
|
c={actionIconProps?.c || "gray"}
|
||||||
|
variant={actionIconProps?.variant || "transparent"}
|
||||||
|
size={actionIconProps?.size}
|
||||||
|
onClick={handlers.toggle}
|
||||||
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|||||||
124
apps/client/src/ee/components/ldap-login-modal.tsx
Normal file
124
apps/client/src/ee/components/ldap-login-modal.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Modal, TextInput, PasswordInput, Button, Stack } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { zodResolver } from "mantine-form-zod-resolver";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IAuthProvider } from "@/ee/security/types/security.types";
|
||||||
|
import APP_ROUTE from "@/lib/app-route";
|
||||||
|
import { ldapLogin } from "@/ee/security/services/ldap-auth-service";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
username: z.string().min(1, { message: "Username is required" }),
|
||||||
|
password: z.string().min(1, { message: "Password is required" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface LdapLoginModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
provider: IAuthProvider;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LdapLoginModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
provider,
|
||||||
|
workspaceId,
|
||||||
|
}: LdapLoginModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validate: zodResolver(formSchema),
|
||||||
|
initialValues: {
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (values: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ldapLogin({
|
||||||
|
username: values.username,
|
||||||
|
password: values.password,
|
||||||
|
providerId: provider.id,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle MFA like the regular login
|
||||||
|
if (response?.userHasMfa) {
|
||||||
|
onClose();
|
||||||
|
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
||||||
|
} else if (response?.requiresMfaSetup) {
|
||||||
|
onClose();
|
||||||
|
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
navigate(APP_ROUTE.HOME);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setIsLoading(false);
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || "Authentication failed";
|
||||||
|
setError(errorMessage);
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
message: errorMessage,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
form.reset();
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={`LDAP Login - ${provider.name}`}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
id="ldap-username"
|
||||||
|
type="text"
|
||||||
|
label={t("LDAP username")}
|
||||||
|
placeholder="Enter your LDAP username"
|
||||||
|
variant="filled"
|
||||||
|
disabled={isLoading}
|
||||||
|
data-autofocus
|
||||||
|
{...form.getInputProps("username")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label={t("LDAP password")}
|
||||||
|
placeholder={t("Enter your LDAP password")}
|
||||||
|
variant="filled"
|
||||||
|
disabled={isLoading}
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" fullWidth mt="md" loading={isLoading}>
|
||||||
|
{t("Sign in with LDAP")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,29 +1,62 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import { Button, Divider, Stack } from "@mantine/core";
|
import { Button, Divider, Stack } from "@mantine/core";
|
||||||
import { IconLock } from "@tabler/icons-react";
|
import { IconLock, IconServer } from "@tabler/icons-react";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
|
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
|
||||||
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
||||||
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
|
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
|
||||||
|
|
||||||
export default function SsoLogin() {
|
export default function SsoLogin() {
|
||||||
const { data, isLoading } = useWorkspacePublicDataQuery();
|
const { data, isLoading } = useWorkspacePublicDataQuery();
|
||||||
|
const [ldapModalOpened, setLdapModalOpened] = useState(false);
|
||||||
|
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
|
||||||
|
|
||||||
if (!data?.authProviders || data?.authProviders?.length === 0) {
|
if (!data?.authProviders || data?.authProviders?.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSsoLogin = (provider: IAuthProvider) => {
|
const handleSsoLogin = (provider: IAuthProvider) => {
|
||||||
window.location.href = buildSsoLoginUrl({
|
if (provider.type === SSO_PROVIDER.LDAP) {
|
||||||
providerId: provider.id,
|
// Open modal for LDAP instead of redirecting
|
||||||
type: provider.type,
|
setSelectedLdapProvider(provider);
|
||||||
workspaceId: data.id,
|
setLdapModalOpened(true);
|
||||||
});
|
} else {
|
||||||
|
// Redirect for other SSO providers
|
||||||
|
window.location.href = buildSsoLoginUrl({
|
||||||
|
providerId: provider.id,
|
||||||
|
type: provider.type,
|
||||||
|
workspaceId: data.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProviderIcon = (provider: IAuthProvider) => {
|
||||||
|
if (provider.type === SSO_PROVIDER.GOOGLE) {
|
||||||
|
return <GoogleIcon size={16} />;
|
||||||
|
} else if (provider.type === SSO_PROVIDER.LDAP) {
|
||||||
|
return <IconServer size={16} />;
|
||||||
|
} else {
|
||||||
|
return <IconLock size={16} />;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{selectedLdapProvider && (
|
||||||
|
<LdapLoginModal
|
||||||
|
opened={ldapModalOpened}
|
||||||
|
onClose={() => {
|
||||||
|
setLdapModalOpened(false);
|
||||||
|
setSelectedLdapProvider(null);
|
||||||
|
}}
|
||||||
|
provider={selectedLdapProvider}
|
||||||
|
workspaceId={data.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{(isCloud() || data.hasLicenseKey) && (
|
{(isCloud() || data.hasLicenseKey) && (
|
||||||
<>
|
<>
|
||||||
<Stack align="stretch" justify="center" gap="sm">
|
<Stack align="stretch" justify="center" gap="sm">
|
||||||
@ -31,13 +64,7 @@ export default function SsoLogin() {
|
|||||||
<div key={provider.id}>
|
<div key={provider.id}>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleSsoLogin(provider)}
|
onClick={() => handleSsoLogin(provider)}
|
||||||
leftSection={
|
leftSection={getProviderIcon(provider)}
|
||||||
provider.type === SSO_PROVIDER.GOOGLE ? (
|
|
||||||
<GoogleIcon size={16} />
|
|
||||||
) : (
|
|
||||||
<IconLock size={16} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
variant="default"
|
variant="default"
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export function MfaBackupCodeInput({
|
|||||||
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
|
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
|
||||||
error={error}
|
error={error}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
data-autofocus
|
||||||
maxLength={8}
|
maxLength={8}
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@ -25,23 +25,30 @@ import { regenerateBackupCodes } from "@/ee/mfa";
|
|||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
import { zodResolver } from "mantine-form-zod-resolver";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
||||||
|
|
||||||
interface MfaBackupCodesModalProps {
|
interface MfaBackupCodesModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function MfaBackupCodesModal({
|
export function MfaBackupCodesModal({
|
||||||
opened,
|
opened,
|
||||||
onClose,
|
onClose,
|
||||||
}: MfaBackupCodesModalProps) {
|
}: MfaBackupCodesModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data: currentUser } = useCurrentUser();
|
||||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||||
const [showNewCodes, setShowNewCodes] = useState(false);
|
const [showNewCodes, setShowNewCodes] = useState(false);
|
||||||
|
const requiresPassword = !currentUser?.user?.hasGeneratedPassword;
|
||||||
|
|
||||||
|
const formSchema = requiresPassword
|
||||||
|
? z.object({
|
||||||
|
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
||||||
|
})
|
||||||
|
: z.object({
|
||||||
|
confirmPassword: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
validate: zodResolver(formSchema),
|
validate: zodResolver(formSchema),
|
||||||
@ -51,7 +58,7 @@ export function MfaBackupCodesModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const regenerateMutation = useMutation({
|
const regenerateMutation = useMutation({
|
||||||
mutationFn: (data: { confirmPassword: string }) =>
|
mutationFn: (data: { confirmPassword?: string }) =>
|
||||||
regenerateBackupCodes(data),
|
regenerateBackupCodes(data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setBackupCodes(data.backupCodes);
|
setBackupCodes(data.backupCodes);
|
||||||
@ -73,8 +80,12 @@ export function MfaBackupCodesModal({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRegenerate = (values: { confirmPassword: string }) => {
|
const handleRegenerate = (values: { confirmPassword?: string }) => {
|
||||||
regenerateMutation.mutate(values);
|
// Only send confirmPassword if it's required (non-SSO users)
|
||||||
|
const payload = requiresPassword
|
||||||
|
? { confirmPassword: values.confirmPassword }
|
||||||
|
: {};
|
||||||
|
regenerateMutation.mutate(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@ -114,12 +125,16 @@ export function MfaBackupCodesModal({
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<PasswordInput
|
{requiresPassword && (
|
||||||
label={t("Confirm password")}
|
<PasswordInput
|
||||||
placeholder={t("Enter your password")}
|
label={t("Confirm password")}
|
||||||
variant="filled"
|
placeholder={t("Enter your password")}
|
||||||
{...form.getInputProps("confirmPassword")}
|
variant="filled"
|
||||||
/>
|
{...form.getInputProps("confirmPassword")}
|
||||||
|
autoFocus
|
||||||
|
data-autofocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@ -97,6 +97,7 @@ export function MfaChallenge() {
|
|||||||
length={6}
|
length={6}
|
||||||
type="number"
|
type="number"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
data-autofocus
|
||||||
oneTimeCode
|
oneTimeCode
|
||||||
{...form.getInputProps("code")}
|
{...form.getInputProps("code")}
|
||||||
error={!!form.errors.code}
|
error={!!form.errors.code}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { disableMfa } from "@/ee/mfa";
|
import { disableMfa } from "@/ee/mfa";
|
||||||
|
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
||||||
|
|
||||||
interface MfaDisableModalProps {
|
interface MfaDisableModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
@ -22,16 +23,22 @@ interface MfaDisableModalProps {
|
|||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function MfaDisableModal({
|
export function MfaDisableModal({
|
||||||
opened,
|
opened,
|
||||||
onClose,
|
onClose,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: MfaDisableModalProps) {
|
}: MfaDisableModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data: currentUser } = useCurrentUser();
|
||||||
|
const requiresPassword = !currentUser?.user?.hasGeneratedPassword;
|
||||||
|
|
||||||
|
const formSchema = requiresPassword
|
||||||
|
? z.object({
|
||||||
|
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
||||||
|
})
|
||||||
|
: z.object({
|
||||||
|
confirmPassword: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
validate: zodResolver(formSchema),
|
validate: zodResolver(formSchema),
|
||||||
@ -54,8 +61,12 @@ export function MfaDisableModal({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (values: { confirmPassword: string }) => {
|
const handleSubmit = async (values: { confirmPassword?: string }) => {
|
||||||
await disableMutation.mutateAsync(values);
|
// Only send confirmPassword if it's required (non-SSO users)
|
||||||
|
const payload = requiresPassword
|
||||||
|
? { confirmPassword: values.confirmPassword }
|
||||||
|
: {};
|
||||||
|
await disableMutation.mutateAsync(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@ -85,18 +96,23 @@ export function MfaDisableModal({
|
|||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Text size="sm">
|
{requiresPassword && (
|
||||||
{t(
|
<>
|
||||||
"Please enter your password to disable two-factor authentication:",
|
<Text size="sm">
|
||||||
)}
|
{t(
|
||||||
</Text>
|
"Please enter your password to disable two-factor authentication:",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t("Password")}
|
label={t("Password")}
|
||||||
placeholder={t("Enter your password")}
|
placeholder={t("Enter your password")}
|
||||||
{...form.getInputProps("confirmPassword")}
|
{...form.getInputProps("confirmPassword")}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
data-autofocus
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -235,6 +235,7 @@ export function MfaSetupModal({
|
|||||||
length={6}
|
length={6}
|
||||||
type="number"
|
type="number"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
data-autofocus
|
||||||
oneTimeCode
|
oneTimeCode
|
||||||
{...form.getInputProps("verificationCode")}
|
{...form.getInputProps("verificationCode")}
|
||||||
styles={{
|
styles={{
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export async function disableMfa(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function regenerateBackupCodes(data: {
|
export async function regenerateBackupCodes(data: {
|
||||||
confirmPassword: string;
|
confirmPassword?: string;
|
||||||
}): Promise<MfaBackupCodesResponse> {
|
}): Promise<MfaBackupCodesResponse> {
|
||||||
const req = await api.post<MfaBackupCodesResponse>(
|
const req = await api.post<MfaBackupCodesResponse>(
|
||||||
"/mfa/generate-backup-codes",
|
"/mfa/generate-backup-codes",
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export interface MfaEnableResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MfaDisableRequest {
|
export interface MfaDisableRequest {
|
||||||
confirmPassword: string;
|
confirmPassword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MfaBackupCodesResponse {
|
export interface MfaBackupCodesResponse {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
|
import { zodResolver } from "mantine-form-zod-resolver";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button, Text, TagsInput } from "@mantine/core";
|
import { Button, Text, TagsInput } from "@mantine/core";
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { Button, Menu, Group } from "@mantine/core";
|
import { Button, Menu, Group } from "@mantine/core";
|
||||||
import { IconChevronDown, IconLock } from "@tabler/icons-react";
|
import { IconChevronDown, IconLock, IconServer } from "@tabler/icons-react";
|
||||||
import { useCreateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
|
import { useCreateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
|
||||||
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
@ -40,6 +40,19 @@ export default function CreateSsoProvider() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateLDAP = async () => {
|
||||||
|
try {
|
||||||
|
const newProvider = await createSsoProviderMutation.mutateAsync({
|
||||||
|
type: SSO_PROVIDER.LDAP,
|
||||||
|
name: "LDAP",
|
||||||
|
});
|
||||||
|
setProvider(newProvider);
|
||||||
|
open();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create LDAP provider", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SsoProviderModal opened={opened} onClose={close} provider={provider} />
|
<SsoProviderModal opened={opened} onClose={close} provider={provider} />
|
||||||
@ -71,6 +84,13 @@ export default function CreateSsoProvider() {
|
|||||||
>
|
>
|
||||||
OpenID (OIDC)
|
OpenID (OIDC)
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
onClick={handleCreateLDAP}
|
||||||
|
leftSection={<IconServer size={16} />}
|
||||||
|
>
|
||||||
|
LDAP / Active Directory
|
||||||
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
228
apps/client/src/ee/security/components/sso-ldap-form.tsx
Normal file
228
apps/client/src/ee/security/components/sso-ldap-form.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { zodResolver } from "mantine-form-zod-resolver";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
Textarea,
|
||||||
|
Text,
|
||||||
|
Accordion,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import classes from "@/ee/security/components/sso.module.css";
|
||||||
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useUpdateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
|
||||||
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
const ssoSchema = z.object({
|
||||||
|
name: z.string().min(1, "Display name is required"),
|
||||||
|
ldapUrl: z.string().url().startsWith("ldap", "Must be an LDAP URL"),
|
||||||
|
ldapBindDn: z.string().min(1, "Bind DN is required"),
|
||||||
|
ldapBindPassword: z.string().min(1, "Bind password is required"),
|
||||||
|
ldapBaseDn: z.string().min(1, "Base DN is required"),
|
||||||
|
ldapUserSearchFilter: z.string().optional(),
|
||||||
|
ldapTlsEnabled: z.boolean(),
|
||||||
|
ldapTlsCaCert: z.string().optional(),
|
||||||
|
isEnabled: z.boolean(),
|
||||||
|
allowSignup: z.boolean(),
|
||||||
|
groupSync: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SSOFormValues = z.infer<typeof ssoSchema>;
|
||||||
|
|
||||||
|
interface SsoFormProps {
|
||||||
|
provider: IAuthProvider;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SsoLDAPForm({ provider, onClose }: SsoFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const updateSsoProviderMutation = useUpdateSsoProviderMutation();
|
||||||
|
|
||||||
|
const form = useForm<SSOFormValues>({
|
||||||
|
initialValues: {
|
||||||
|
name: provider.name || "",
|
||||||
|
ldapUrl: provider.ldapUrl || "",
|
||||||
|
ldapBindDn: provider.ldapBindDn || "",
|
||||||
|
ldapBindPassword: provider.ldapBindPassword || "",
|
||||||
|
ldapBaseDn: provider.ldapBaseDn || "",
|
||||||
|
ldapUserSearchFilter:
|
||||||
|
provider.ldapUserSearchFilter || "(mail={{username}})",
|
||||||
|
ldapTlsEnabled: provider.ldapTlsEnabled || false,
|
||||||
|
ldapTlsCaCert: provider.ldapTlsCaCert || "",
|
||||||
|
isEnabled: provider.isEnabled,
|
||||||
|
allowSignup: provider.allowSignup,
|
||||||
|
groupSync: provider.groupSync || false,
|
||||||
|
},
|
||||||
|
validate: zodResolver(ssoSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (values: SSOFormValues) => {
|
||||||
|
const ssoData: Partial<IAuthProvider> = {
|
||||||
|
providerId: provider.id,
|
||||||
|
};
|
||||||
|
if (form.isDirty("name")) {
|
||||||
|
ssoData.name = values.name;
|
||||||
|
}
|
||||||
|
if (form.isDirty("ldapUrl")) {
|
||||||
|
ssoData.ldapUrl = values.ldapUrl;
|
||||||
|
}
|
||||||
|
if (form.isDirty("ldapBindDn")) {
|
||||||
|
ssoData.ldapBindDn = values.ldapBindDn;
|
||||||
|
}
|
||||||
|
if (form.isDirty("ldapBindPassword")) {
|
||||||
|
ssoData.ldapBindPassword = values.ldapBindPassword;
|
||||||
|
}
|
||||||
|
if (form.isDirty("ldapBaseDn")) {
|
||||||
|
ssoData.ldapBaseDn = values.ldapBaseDn;
|
||||||
|
}
|
||||||
|
if (form.isDirty("ldapUserSearchFilter")) {
|
||||||
|
ssoData.ldapUserSearchFilter = values.ldapUserSearchFilter;
|
||||||
|
}
|
||||||
|
if (form.isDirty("ldapTlsEnabled")) {
|
||||||
|
ssoData.ldapTlsEnabled = values.ldapTlsEnabled;
|
||||||
|
}
|
||||||
|
if (form.isDirty("ldapTlsCaCert")) {
|
||||||
|
ssoData.ldapTlsCaCert = values.ldapTlsCaCert;
|
||||||
|
}
|
||||||
|
if (form.isDirty("isEnabled")) {
|
||||||
|
ssoData.isEnabled = values.isEnabled;
|
||||||
|
}
|
||||||
|
if (form.isDirty("allowSignup")) {
|
||||||
|
ssoData.allowSignup = values.allowSignup;
|
||||||
|
}
|
||||||
|
if (form.isDirty("groupSync")) {
|
||||||
|
ssoData.groupSync = values.groupSync;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateSsoProviderMutation.mutateAsync(ssoData);
|
||||||
|
form.resetDirty();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box maw={600} mx="auto">
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
label="Display name"
|
||||||
|
placeholder="e.g Company LDAP"
|
||||||
|
data-autofocus
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="LDAP Server URL"
|
||||||
|
description="URL of your LDAP server"
|
||||||
|
placeholder="ldap://ldap.example.com:389 or ldaps://ldap.example.com:636"
|
||||||
|
{...form.getInputProps("ldapUrl")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Bind DN"
|
||||||
|
description="Distinguished Name of the service account for searching"
|
||||||
|
placeholder="cn=admin,dc=example,dc=com"
|
||||||
|
{...form.getInputProps("ldapBindDn")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Bind Password"
|
||||||
|
description="Password for the service account"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...form.getInputProps("ldapBindPassword")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Base DN"
|
||||||
|
description="Base DN where user searches will start"
|
||||||
|
placeholder="ou=users,dc=example,dc=com"
|
||||||
|
{...form.getInputProps("ldapBaseDn")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="User Search Filter"
|
||||||
|
description="LDAP filter to find users. Use {{username}} as placeholder"
|
||||||
|
placeholder="(mail={{username}})"
|
||||||
|
{...form.getInputProps("ldapUserSearchFilter")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Accordion variant="separated">
|
||||||
|
<Accordion.Item value="advanced">
|
||||||
|
<Accordion.Control icon={<IconInfoCircle size={20} />}>
|
||||||
|
Advanced Settings
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Text size="sm">{t("Enable TLS/SSL")}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Use secure connection to LDAP server
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
className={classes.switch}
|
||||||
|
checked={form.values.ldapTlsEnabled}
|
||||||
|
{...form.getInputProps("ldapTlsEnabled")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{form.values.ldapTlsEnabled && (
|
||||||
|
<Textarea
|
||||||
|
label="CA Certificate"
|
||||||
|
description="PEM-encoded CA certificate for TLS verification (optional)"
|
||||||
|
placeholder="-----BEGIN CERTIFICATE-----
|
||||||
|
...
|
||||||
|
-----END CERTIFICATE-----"
|
||||||
|
minRows={4}
|
||||||
|
{...form.getInputProps("ldapTlsCaCert")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>{t("Group sync")}</div>
|
||||||
|
<Switch
|
||||||
|
className={classes.switch}
|
||||||
|
checked={form.values.groupSync}
|
||||||
|
{...form.getInputProps("groupSync")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>{t("Allow signup")}</div>
|
||||||
|
<Switch
|
||||||
|
className={classes.switch}
|
||||||
|
checked={form.values.allowSignup}
|
||||||
|
{...form.getInputProps("allowSignup")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>{t("Enabled")}</div>
|
||||||
|
<Switch
|
||||||
|
className={classes.switch}
|
||||||
|
checked={form.values.isEnabled}
|
||||||
|
{...form.getInputProps("isEnabled")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group mt="md" justify="flex-end">
|
||||||
|
<Button type="submit" disabled={!form.isDirty()}>
|
||||||
|
{t("Save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ const ssoSchema = z.object({
|
|||||||
oidcClientSecret: z.string().min(1, "Client secret is required"),
|
oidcClientSecret: z.string().min(1, "Client secret is required"),
|
||||||
isEnabled: z.boolean(),
|
isEnabled: z.boolean(),
|
||||||
allowSignup: z.boolean(),
|
allowSignup: z.boolean(),
|
||||||
|
groupSync: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type SSOFormValues = z.infer<typeof ssoSchema>;
|
type SSOFormValues = z.infer<typeof ssoSchema>;
|
||||||
@ -36,6 +37,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
|||||||
oidcClientSecret: provider.oidcClientSecret || "",
|
oidcClientSecret: provider.oidcClientSecret || "",
|
||||||
isEnabled: provider.isEnabled,
|
isEnabled: provider.isEnabled,
|
||||||
allowSignup: provider.allowSignup,
|
allowSignup: provider.allowSignup,
|
||||||
|
groupSync: provider.groupSync || false,
|
||||||
},
|
},
|
||||||
validate: zodResolver(ssoSchema),
|
validate: zodResolver(ssoSchema),
|
||||||
});
|
});
|
||||||
@ -67,6 +69,9 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
|||||||
if (form.isDirty("allowSignup")) {
|
if (form.isDirty("allowSignup")) {
|
||||||
ssoData.allowSignup = values.allowSignup;
|
ssoData.allowSignup = values.allowSignup;
|
||||||
}
|
}
|
||||||
|
if (form.isDirty("groupSync")) {
|
||||||
|
ssoData.groupSync = values.groupSync;
|
||||||
|
}
|
||||||
|
|
||||||
await updateSsoProviderMutation.mutateAsync(ssoData);
|
await updateSsoProviderMutation.mutateAsync(ssoData);
|
||||||
form.resetDirty();
|
form.resetDirty();
|
||||||
@ -110,6 +115,15 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
|||||||
{...form.getInputProps("oidcClientSecret")}
|
{...form.getInputProps("oidcClientSecret")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>{t("Group sync")}</div>
|
||||||
|
<Switch
|
||||||
|
className={classes.switch}
|
||||||
|
checked={form.values.groupSync}
|
||||||
|
{...form.getInputProps("groupSync")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<div>{t("Allow signup")}</div>
|
<div>{t("Allow signup")}</div>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { SsoSamlForm } from "@/ee/security/components/sso-saml-form.tsx";
|
|||||||
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
||||||
import { SsoOIDCForm } from "@/ee/security/components/sso-oidc-form.tsx";
|
import { SsoOIDCForm } from "@/ee/security/components/sso-oidc-form.tsx";
|
||||||
import { SsoGoogleForm } from "@/ee/security/components/sso-google-form.tsx";
|
import { SsoGoogleForm } from "@/ee/security/components/sso-google-form.tsx";
|
||||||
|
import { SsoLDAPForm } from "@/ee/security/components/sso-ldap-form.tsx";
|
||||||
|
|
||||||
interface SsoModalProps {
|
interface SsoModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
@ -38,6 +39,10 @@ export default function SsoProviderModal({
|
|||||||
{provider.type === SSO_PROVIDER.GOOGLE && (
|
{provider.type === SSO_PROVIDER.GOOGLE && (
|
||||||
<SsoGoogleForm provider={provider} onClose={onClose} />
|
<SsoGoogleForm provider={provider} onClose={onClose} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{provider.type === SSO_PROVIDER.LDAP && (
|
||||||
|
<SsoLDAPForm provider={provider} onClose={onClose} />
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ const ssoSchema = z.object({
|
|||||||
samlCertificate: z.string().min(1, "SAML Idp Certificate is required"),
|
samlCertificate: z.string().min(1, "SAML Idp Certificate is required"),
|
||||||
isEnabled: z.boolean(),
|
isEnabled: z.boolean(),
|
||||||
allowSignup: z.boolean(),
|
allowSignup: z.boolean(),
|
||||||
|
groupSync: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type SSOFormValues = z.infer<typeof ssoSchema>;
|
type SSOFormValues = z.infer<typeof ssoSchema>;
|
||||||
@ -45,6 +46,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
|||||||
samlCertificate: provider.samlCertificate || "",
|
samlCertificate: provider.samlCertificate || "",
|
||||||
isEnabled: provider.isEnabled,
|
isEnabled: provider.isEnabled,
|
||||||
allowSignup: provider.allowSignup,
|
allowSignup: provider.allowSignup,
|
||||||
|
groupSync: provider.groupSync || false,
|
||||||
},
|
},
|
||||||
validate: zodResolver(ssoSchema),
|
validate: zodResolver(ssoSchema),
|
||||||
});
|
});
|
||||||
@ -75,6 +77,9 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
|||||||
if (form.isDirty("allowSignup")) {
|
if (form.isDirty("allowSignup")) {
|
||||||
ssoData.allowSignup = values.allowSignup;
|
ssoData.allowSignup = values.allowSignup;
|
||||||
}
|
}
|
||||||
|
if (form.isDirty("groupSync")) {
|
||||||
|
ssoData.groupSync = values.groupSync;
|
||||||
|
}
|
||||||
|
|
||||||
await updateSsoProviderMutation.mutateAsync(ssoData);
|
await updateSsoProviderMutation.mutateAsync(ssoData);
|
||||||
form.resetDirty();
|
form.resetDirty();
|
||||||
@ -123,6 +128,15 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
|||||||
{...form.getInputProps("samlCertificate")}
|
{...form.getInputProps("samlCertificate")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>{t("Group sync")}</div>
|
||||||
|
<Switch
|
||||||
|
className={classes.switch}
|
||||||
|
checked={form.values.groupSync}
|
||||||
|
{...form.getInputProps("groupSync")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<div>{t("Allow signup")}</div>
|
<div>{t("Allow signup")}</div>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@ -2,4 +2,5 @@ export enum SSO_PROVIDER {
|
|||||||
OIDC = 'oidc',
|
OIDC = 'oidc',
|
||||||
SAML = 'saml',
|
SAML = 'saml',
|
||||||
GOOGLE = 'google',
|
GOOGLE = 'google',
|
||||||
|
LDAP = 'ldap',
|
||||||
}
|
}
|
||||||
23
apps/client/src/ee/security/services/ldap-auth-service.ts
Normal file
23
apps/client/src/ee/security/services/ldap-auth-service.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import api from "@/lib/api-client.ts";
|
||||||
|
import { ILoginResponse } from "@/features/auth/types/auth.types.ts";
|
||||||
|
|
||||||
|
interface ILdapLogin {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
providerId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ldapLogin(data: ILdapLogin): Promise<ILoginResponse> {
|
||||||
|
const requestData = {
|
||||||
|
username: data.username,
|
||||||
|
password: data.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await api.post<ILoginResponse>(
|
||||||
|
`/sso/ldap/${data.providerId}/login`,
|
||||||
|
requestData
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@ -9,8 +9,17 @@ export interface IAuthProvider {
|
|||||||
oidcIssuer: string;
|
oidcIssuer: string;
|
||||||
oidcClientId: string;
|
oidcClientId: string;
|
||||||
oidcClientSecret: string;
|
oidcClientSecret: string;
|
||||||
|
ldapUrl: string;
|
||||||
|
ldapBindDn: string;
|
||||||
|
ldapBindPassword: string;
|
||||||
|
ldapBaseDn: string;
|
||||||
|
ldapUserSearchFilter: string;
|
||||||
|
ldapUserAttributes: any;
|
||||||
|
ldapTlsEnabled: boolean;
|
||||||
|
ldapTlsCaCert: string;
|
||||||
allowSignup: boolean;
|
allowSignup: boolean;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
|
groupSync: boolean;
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|||||||
@ -9,18 +9,21 @@ import {
|
|||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Tooltip, Divider } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconAlertTriangleFilled,
|
IconAlertTriangleFilled,
|
||||||
IconCircleCheckFilled,
|
IconCircleCheckFilled,
|
||||||
IconCircleXFilled,
|
IconCircleXFilled,
|
||||||
IconInfoCircleFilled,
|
IconInfoCircleFilled,
|
||||||
|
IconMoodSmile,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { CalloutType } from "@docmost/editor-ext";
|
import { CalloutType } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||||
|
|
||||||
export function CalloutMenu({ editor }: EditorMenuProps) {
|
export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const shouldShow = useCallback(
|
const shouldShow = useCallback(
|
||||||
({ state }: ShouldShowProps) => {
|
({ state }: ShouldShowProps) => {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@ -56,6 +59,36 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
[editor],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setCalloutIcon = useCallback(
|
||||||
|
(emoji: any) => {
|
||||||
|
const emojiChar = emoji?.native || emoji?.emoji || emoji;
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.updateCalloutIcon(emojiChar)
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeCalloutIcon = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.updateCalloutIcon("")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const getCurrentIcon = () => {
|
||||||
|
const { selection } = editor.state;
|
||||||
|
const predicate = (node: PMNode) => node.type.name === "callout";
|
||||||
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
const icon = parent?.node.attrs.icon;
|
||||||
|
return icon || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentIcon = getCurrentIcon();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@ -130,6 +163,20 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
<IconCircleXFilled size={18} />
|
<IconCircleXFilled size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Custom emoji")}>
|
||||||
|
<EmojiPicker
|
||||||
|
onEmojiSelect={setCalloutIcon}
|
||||||
|
removeEmojiAction={removeCalloutIcon}
|
||||||
|
readOnly={false}
|
||||||
|
icon={currentIcon || <IconMoodSmile size={18} />}
|
||||||
|
actionIconProps={{
|
||||||
|
size: "lg",
|
||||||
|
variant: "default",
|
||||||
|
c: undefined
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { CalloutType } from "@docmost/editor-ext";
|
|||||||
|
|
||||||
export default function CalloutView(props: NodeViewProps) {
|
export default function CalloutView(props: NodeViewProps) {
|
||||||
const { node } = props;
|
const { node } = props;
|
||||||
const { type } = node.attrs;
|
const { type, icon } = node.attrs;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
@ -19,7 +19,7 @@ export default function CalloutView(props: NodeViewProps) {
|
|||||||
variant="light"
|
variant="light"
|
||||||
title=""
|
title=""
|
||||||
color={getCalloutColor(type)}
|
color={getCalloutColor(type)}
|
||||||
icon={getCalloutIcon(type)}
|
icon={getCalloutIcon(type, icon)}
|
||||||
p="xs"
|
p="xs"
|
||||||
classNames={{
|
classNames={{
|
||||||
message: classes.message,
|
message: classes.message,
|
||||||
@ -32,7 +32,11 @@ export default function CalloutView(props: NodeViewProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCalloutIcon(type: CalloutType) {
|
function getCalloutIcon(type: CalloutType, customIcon?: string) {
|
||||||
|
if (customIcon && customIcon.trim() !== "") {
|
||||||
|
return <span style={{ fontSize: '18px' }}>{customIcon}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "info":
|
case "info":
|
||||||
return <IconInfoCircleFilled />;
|
return <IconInfoCircleFilled />;
|
||||||
|
|||||||
@ -4,11 +4,7 @@ import mermaid from "mermaid";
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import classes from "./code-block.module.css";
|
import classes from "./code-block.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useComputedColorScheme } from "@mantine/core";
|
||||||
mermaid.initialize({
|
|
||||||
startOnLoad: false,
|
|
||||||
suppressErrorRendering: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
interface MermaidViewProps {
|
interface MermaidViewProps {
|
||||||
props: NodeViewProps;
|
props: NodeViewProps;
|
||||||
@ -16,12 +12,22 @@ interface MermaidViewProps {
|
|||||||
|
|
||||||
export default function MermaidView({ props }: MermaidViewProps) {
|
export default function MermaidView({ props }: MermaidViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const computedColorScheme = useComputedColorScheme();
|
||||||
const { node } = props;
|
const { node } = props;
|
||||||
const [preview, setPreview] = useState<string>("");
|
const [preview, setPreview] = useState<string>("");
|
||||||
|
|
||||||
|
// Update Mermaid config when theme changes.
|
||||||
|
useEffect(() => {
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
suppressErrorRendering: true,
|
||||||
|
theme: computedColorScheme === "light" ? "default" : "dark",
|
||||||
|
});
|
||||||
|
}, [computedColorScheme]);
|
||||||
|
|
||||||
|
// Re-render the diagram whenever the node content or theme changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = `mermaid-${uuidv4()}`;
|
const id = `mermaid-${uuidv4()}`;
|
||||||
|
|
||||||
if (node.textContent.length > 0) {
|
if (node.textContent.length > 0) {
|
||||||
mermaid
|
mermaid
|
||||||
.render(id, node.textContent)
|
.render(id, node.textContent)
|
||||||
@ -40,7 +46,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [node.textContent]);
|
}, [node.textContent, computedColorScheme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -17,8 +17,10 @@ import {
|
|||||||
IconTable,
|
IconTable,
|
||||||
IconTypography,
|
IconTypography,
|
||||||
IconMenu4,
|
IconMenu4,
|
||||||
IconCalendar, IconAppWindow,
|
IconCalendar,
|
||||||
} from '@tabler/icons-react';
|
IconAppWindow,
|
||||||
|
IconSitemap,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
SlashMenuGroupedItemsType,
|
SlashMenuGroupedItemsType,
|
||||||
@ -357,6 +359,15 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
.run();
|
.run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Subpages (Child pages)",
|
||||||
|
description: "List all subpages of the current page",
|
||||||
|
searchTerms: ["subpages", "child", "children", "nested", "hierarchy"],
|
||||||
|
icon: IconSitemap,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Iframe embed",
|
title: "Iframe embed",
|
||||||
description: "Embed any Iframe",
|
description: "Embed any Iframe",
|
||||||
|
|||||||
@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
posToDOMRect,
|
||||||
|
findParentNode,
|
||||||
|
} from "@tiptap/react";
|
||||||
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { sticky } from "tippy.js";
|
||||||
|
|
||||||
|
interface SubpagesMenuProps {
|
||||||
|
editor: Editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShouldShowProps {
|
||||||
|
state: any;
|
||||||
|
from?: number;
|
||||||
|
to?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubpagesMenu = React.memo(
|
||||||
|
({ editor }: SubpagesMenuProps): JSX.Element => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.isActive("subpages");
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getReferenceClientRect = useCallback(() => {
|
||||||
|
const { selection } = editor.state;
|
||||||
|
const predicate = (node: PMNode) => node.type.name === "subpages";
|
||||||
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
|
if (parent) {
|
||||||
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
|
return dom.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const deleteNode = useCallback(() => {
|
||||||
|
const { selection } = editor.state;
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.setNodeSelection(selection.from)
|
||||||
|
.deleteSelection()
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseBubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
pluginKey={`subpages-menu}`}
|
||||||
|
updateDelay={0}
|
||||||
|
tippyOptions={{
|
||||||
|
getReferenceClientRect,
|
||||||
|
offset: [0, 8],
|
||||||
|
zIndex: 99,
|
||||||
|
popperOptions: {
|
||||||
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
|
},
|
||||||
|
plugins: [sticky],
|
||||||
|
sticky: "popper",
|
||||||
|
}}
|
||||||
|
shouldShow={shouldShow}
|
||||||
|
>
|
||||||
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={deleteNode}
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
color="red"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SubpagesMenu;
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
|
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
|
||||||
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
|
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import classes from "./subpages.module.css";
|
||||||
|
import styles from "../mention/mention.module.css";
|
||||||
|
import {
|
||||||
|
buildPageUrl,
|
||||||
|
buildSharedPageUrl,
|
||||||
|
} from "@/features/page/page.utils.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
|
||||||
|
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
|
||||||
|
|
||||||
|
export default function SubpagesView(props: NodeViewProps) {
|
||||||
|
const { editor } = props;
|
||||||
|
const { spaceSlug, shareId } = useParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const currentPageId = editor.storage.pageId;
|
||||||
|
|
||||||
|
// Get subpages from shared tree if we're in a shared context
|
||||||
|
const sharedSubpages = useSharedPageSubpages(currentPageId);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useGetSidebarPagesQuery({
|
||||||
|
pageId: currentPageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subpages = useMemo(() => {
|
||||||
|
// If we're in a shared context, use the shared subpages
|
||||||
|
if (shareId && sharedSubpages) {
|
||||||
|
return sharedSubpages.map((node) => ({
|
||||||
|
id: node.value,
|
||||||
|
slugId: node.slugId,
|
||||||
|
title: node.name,
|
||||||
|
icon: node.icon,
|
||||||
|
position: node.position,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use the API data
|
||||||
|
if (!data?.pages) return [];
|
||||||
|
const allPages = data.pages.flatMap((page) => page.items);
|
||||||
|
return sortPositionKeys(allPages);
|
||||||
|
}, [data, shareId, sharedSubpages]);
|
||||||
|
|
||||||
|
if (isLoading && !shareId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !shareId) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper>
|
||||||
|
<Text c="dimmed" size="md" py="md">
|
||||||
|
{t("Failed to load subpages")}
|
||||||
|
</Text>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subpages.length === 0) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper>
|
||||||
|
<div className={classes.container}>
|
||||||
|
<Text c="dimmed" size="md" py="md">
|
||||||
|
{t("No subpages")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper>
|
||||||
|
<div className={classes.container}>
|
||||||
|
<Stack gap={5}>
|
||||||
|
{subpages.map((page) => (
|
||||||
|
<Anchor
|
||||||
|
key={page.id}
|
||||||
|
component={Link}
|
||||||
|
fw={500}
|
||||||
|
to={
|
||||||
|
shareId
|
||||||
|
? buildSharedPageUrl({
|
||||||
|
shareId,
|
||||||
|
pageSlugId: page.slugId,
|
||||||
|
pageTitle: page.title,
|
||||||
|
})
|
||||||
|
: buildPageUrl(spaceSlug, page.slugId, page.title)
|
||||||
|
}
|
||||||
|
underline="never"
|
||||||
|
className={styles.pageMentionLink}
|
||||||
|
draggable={false}
|
||||||
|
>
|
||||||
|
{page?.icon ? (
|
||||||
|
<span style={{ marginRight: "4px" }}>{page.icon}</span>
|
||||||
|
) : (
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
color="gray"
|
||||||
|
component="span"
|
||||||
|
size={18}
|
||||||
|
style={{ verticalAlign: "text-bottom" }}
|
||||||
|
>
|
||||||
|
<IconFileDescription size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className={styles.pageMentionText}>
|
||||||
|
{page?.title || t("untitled")}
|
||||||
|
</span>
|
||||||
|
</Anchor>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
.container {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 4px;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
a {
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -38,6 +38,8 @@ import {
|
|||||||
Embed,
|
Embed,
|
||||||
SearchAndReplace,
|
SearchAndReplace,
|
||||||
Mention,
|
Mention,
|
||||||
|
Subpages,
|
||||||
|
TableDndExtension,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@ -57,6 +59,7 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
|
|||||||
import DrawioView from "../components/drawio/drawio-view";
|
import DrawioView from "../components/drawio/drawio-view";
|
||||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
|
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||||
import powershell from "highlight.js/lib/languages/powershell";
|
import powershell from "highlight.js/lib/languages/powershell";
|
||||||
import abap from "highlightjs-sap-abap";
|
import abap from "highlightjs-sap-abap";
|
||||||
@ -168,6 +171,7 @@ export const mainExtensions = [
|
|||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
|
TableDndExtension,
|
||||||
MathInline.configure({
|
MathInline.configure({
|
||||||
view: MathInlineView,
|
view: MathInlineView,
|
||||||
}),
|
}),
|
||||||
@ -212,6 +216,9 @@ export const mainExtensions = [
|
|||||||
Embed.configure({
|
Embed.configure({
|
||||||
view: EmbedView,
|
view: EmbedView,
|
||||||
}),
|
}),
|
||||||
|
Subpages.configure({
|
||||||
|
view: SubpagesView,
|
||||||
|
}),
|
||||||
MarkdownClipboard.configure({
|
MarkdownClipboard.configure({
|
||||||
transformPastedText: true,
|
transformPastedText: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
|||||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||||
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
||||||
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||||
|
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
|
||||||
import {
|
import {
|
||||||
handleFileDrop,
|
handleFileDrop,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
@ -49,6 +50,7 @@ import { extractPageSlugId } from "@/lib";
|
|||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
import { searchSpotlight } from '@/features/search/constants.ts';
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -221,6 +223,10 @@ export default function PageEditor({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
|
||||||
|
searchSpotlight.open();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
||||||
const slashCommand = document.querySelector("#slash-command");
|
const slashCommand = document.querySelector("#slash-command");
|
||||||
if (slashCommand) {
|
if (slashCommand) {
|
||||||
@ -375,7 +381,7 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "relative" }}>
|
<div className="editor-container" style={{ position: "relative" }}>
|
||||||
<div ref={menuContainerRef}>
|
<div ref={menuContainerRef}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
|
||||||
@ -391,6 +397,7 @@ export default function PageEditor({
|
|||||||
<ImageMenu editor={editor} />
|
<ImageMenu editor={editor} />
|
||||||
<VideoMenu editor={editor} />
|
<VideoMenu editor={editor} />
|
||||||
<CalloutMenu editor={editor} />
|
<CalloutMenu editor={editor} />
|
||||||
|
<SubpagesMenu editor={editor} />
|
||||||
<ExcalidrawMenu editor={editor} />
|
<ExcalidrawMenu editor={editor} />
|
||||||
<DrawioMenu editor={editor} />
|
<DrawioMenu editor={editor} />
|
||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
|
|||||||
@ -6,21 +6,19 @@ import { Document } from "@tiptap/extension-document";
|
|||||||
import { Heading } from "@tiptap/extension-heading";
|
import { Heading } from "@tiptap/extension-heading";
|
||||||
import { Text } from "@tiptap/extension-text";
|
import { Text } from "@tiptap/extension-text";
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||||
import { useAtom } from "jotai/index";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
pageEditorAtom,
|
|
||||||
readOnlyEditorAtom,
|
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
title: string;
|
title: string;
|
||||||
content: any;
|
content: any;
|
||||||
|
pageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReadonlyPageEditor({
|
export default function ReadonlyPageEditor({
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
|
pageId,
|
||||||
}: PageEditorProps) {
|
}: PageEditorProps) {
|
||||||
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
||||||
|
|
||||||
@ -56,6 +54,9 @@ export default function ReadonlyPageEditor({
|
|||||||
content={content}
|
content={content}
|
||||||
onCreate={({ editor }) => {
|
onCreate={({ editor }) => {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
|
if (pageId) {
|
||||||
|
editor.storage.pageId = pageId;
|
||||||
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
setReadOnlyEditor(editor);
|
setReadOnlyEditor(editor);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,6 +45,10 @@
|
|||||||
display: none;
|
display: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-direction='horizontal'] {
|
||||||
|
rotate: 90deg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .drag-handle {
|
.dark .drag-handle {
|
||||||
|
|||||||
@ -8,6 +8,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-dnd-preview {
|
||||||
|
padding: 0;
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-dnd-drop-indicator {
|
||||||
|
background-color: #adf;
|
||||||
|
}
|
||||||
|
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
table {
|
table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@ -118,3 +128,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
|
||||||
|
.prosemirror-dropcursor-block {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prosemirror-dropcursor-inline {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,7 @@ import { UpdateEvent } from "@/features/websocket/types";
|
|||||||
import localEmitter from "@/lib/local-emitter.ts";
|
import localEmitter from "@/lib/local-emitter.ts";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
|
|
||||||
export interface TitleEditorProps {
|
export interface TitleEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -86,6 +87,20 @@ export function TitleEditor({
|
|||||||
content: title,
|
content: title,
|
||||||
immediatelyRender: true,
|
immediatelyRender: true,
|
||||||
shouldRerenderOnTransaction: false,
|
shouldRerenderOnTransaction: false,
|
||||||
|
editorProps: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
keydown: (_view, event) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
|
||||||
|
searchSpotlight.open();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -193,7 +208,7 @@ export function TitleEditor({
|
|||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
// First handle the search hotkey
|
// First handle the search hotkey
|
||||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||||
|
|
||||||
// Then handle other key events
|
// Then handle other key events
|
||||||
handleTitleKeyDown(event);
|
handleTitleKeyDown(event);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -252,6 +252,7 @@ export function useGetSidebarPagesQuery(
|
|||||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: ["sidebar-pages", data],
|
||||||
|
enabled: !!data?.pageId || !!data?.spaceId,
|
||||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
getPreviousPageParam: (firstPage) =>
|
getPreviousPageParam: (firstPage) =>
|
||||||
|
|||||||
@ -60,7 +60,7 @@ export interface ICopyPageToSpace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarPagesParams {
|
export interface SidebarPagesParams {
|
||||||
spaceId: string;
|
spaceId?: string;
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
page?: number; // pagination
|
page?: number; // pagination
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,124 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Group, Center, Text, Badge, ActionIcon } from "@mantine/core";
|
||||||
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { IconFile, IconDownload } from "@tabler/icons-react";
|
||||||
|
import { buildPageUrl } from "@/features/page/page.utils";
|
||||||
|
import { getPageIcon } from "@/lib";
|
||||||
|
import {
|
||||||
|
IAttachmentSearch,
|
||||||
|
IPageSearch,
|
||||||
|
} from "@/features/search/types/search.types";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
|
interface SearchResultItemProps {
|
||||||
|
result: IPageSearch | IAttachmentSearch;
|
||||||
|
isAttachmentResult: boolean;
|
||||||
|
showSpace?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchResultItem({
|
||||||
|
result,
|
||||||
|
isAttachmentResult,
|
||||||
|
showSpace,
|
||||||
|
}: SearchResultItemProps) {
|
||||||
|
if (isAttachmentResult) {
|
||||||
|
const attachmentResult = result as IAttachmentSearch;
|
||||||
|
|
||||||
|
const handleDownload = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const downloadUrl = `/api/files/${attachmentResult.id}/${attachmentResult.fileName}`;
|
||||||
|
window.open(downloadUrl, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Spotlight.Action
|
||||||
|
component={Link}
|
||||||
|
//@ts-ignore
|
||||||
|
to={buildPageUrl(
|
||||||
|
attachmentResult.space.slug,
|
||||||
|
attachmentResult.page.slugId,
|
||||||
|
attachmentResult.page.title,
|
||||||
|
)}
|
||||||
|
style={{ userSelect: "none" }}
|
||||||
|
>
|
||||||
|
<Group wrap="nowrap" w="100%">
|
||||||
|
<Center>
|
||||||
|
<IconFile size={20} />
|
||||||
|
</Center>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Text>{attachmentResult.fileName}</Text>
|
||||||
|
<Text size="xs" opacity={0.6}>
|
||||||
|
{attachmentResult.space.name} • {attachmentResult.page.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{attachmentResult?.highlight && (
|
||||||
|
<Text
|
||||||
|
opacity={0.6}
|
||||||
|
size="xs"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(attachmentResult.highlight, {
|
||||||
|
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={handleDownload}
|
||||||
|
title="Download attachment"
|
||||||
|
>
|
||||||
|
<IconDownload size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Spotlight.Action>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const pageResult = result as IPageSearch;
|
||||||
|
return (
|
||||||
|
<Spotlight.Action
|
||||||
|
component={Link}
|
||||||
|
//@ts-ignore
|
||||||
|
to={buildPageUrl(
|
||||||
|
pageResult.space.slug,
|
||||||
|
pageResult.slugId,
|
||||||
|
pageResult.title,
|
||||||
|
)}
|
||||||
|
style={{ userSelect: "none" }}
|
||||||
|
>
|
||||||
|
<Group wrap="nowrap" w="100%">
|
||||||
|
<Center>{getPageIcon(pageResult?.icon)}</Center>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Text>{pageResult.title}</Text>
|
||||||
|
|
||||||
|
{showSpace && pageResult.space && (
|
||||||
|
<Badge variant="light" size="xs" color="gray">
|
||||||
|
{pageResult.space.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pageResult?.highlight && (
|
||||||
|
<Text
|
||||||
|
opacity={0.6}
|
||||||
|
size="xs"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(pageResult.highlight, {
|
||||||
|
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Spotlight.Action>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
.filtersContainer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterButton {
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6));
|
||||||
|
&:hover {
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,252 @@
|
|||||||
|
import React, { useState, useMemo, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Menu,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Divider,
|
||||||
|
Badge,
|
||||||
|
ScrollArea,
|
||||||
|
Avatar,
|
||||||
|
Group,
|
||||||
|
getDefaultZIndex,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconChevronDown,
|
||||||
|
IconBuilding,
|
||||||
|
IconFileDescription,
|
||||||
|
IconSearch,
|
||||||
|
IconCheck,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||||
|
import { useLicense } from "@/ee/hooks/use-license";
|
||||||
|
import classes from "./search-spotlight-filters.module.css";
|
||||||
|
|
||||||
|
interface SearchSpotlightFiltersProps {
|
||||||
|
onFiltersChange?: (filters: any) => void;
|
||||||
|
spaceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchSpotlightFilters({
|
||||||
|
onFiltersChange,
|
||||||
|
spaceId,
|
||||||
|
}: SearchSpotlightFiltersProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { hasLicenseKey } = useLicense();
|
||||||
|
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
|
||||||
|
spaceId || null,
|
||||||
|
);
|
||||||
|
const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
|
||||||
|
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
|
||||||
|
const [contentType, setContentType] = useState<string | null>("page");
|
||||||
|
|
||||||
|
const { data: spacesData } = useGetSpacesQuery({
|
||||||
|
page: 1,
|
||||||
|
limit: 100,
|
||||||
|
query: debouncedSpaceQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedSpaceData = useMemo(() => {
|
||||||
|
if (!spacesData?.items || !selectedSpaceId) return null;
|
||||||
|
return spacesData.items.find((space) => space.id === selectedSpaceId);
|
||||||
|
}, [spacesData?.items, selectedSpaceId]);
|
||||||
|
|
||||||
|
const availableSpaces = useMemo(() => {
|
||||||
|
const spaces = spacesData?.items || [];
|
||||||
|
if (!selectedSpaceId) return spaces;
|
||||||
|
|
||||||
|
// Sort to put selected space first
|
||||||
|
return [...spaces].sort((a, b) => {
|
||||||
|
if (a.id === selectedSpaceId) return -1;
|
||||||
|
if (b.id === selectedSpaceId) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}, [spacesData?.items, selectedSpaceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onFiltersChange) {
|
||||||
|
onFiltersChange({
|
||||||
|
spaceId: selectedSpaceId,
|
||||||
|
contentType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contentTypeOptions = [
|
||||||
|
{ value: "page", label: "Pages" },
|
||||||
|
{
|
||||||
|
value: "attachment",
|
||||||
|
label: "Attachments",
|
||||||
|
disabled: !hasLicenseKey,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSpaceSelect = (spaceId: string | null) => {
|
||||||
|
setSelectedSpaceId(spaceId);
|
||||||
|
|
||||||
|
if (onFiltersChange) {
|
||||||
|
onFiltersChange({
|
||||||
|
spaceId: spaceId,
|
||||||
|
contentType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterChange = (filterType: string, value: any) => {
|
||||||
|
let newSelectedSpaceId = selectedSpaceId;
|
||||||
|
let newContentType = contentType;
|
||||||
|
|
||||||
|
switch (filterType) {
|
||||||
|
case "spaceId":
|
||||||
|
newSelectedSpaceId = value;
|
||||||
|
setSelectedSpaceId(value);
|
||||||
|
break;
|
||||||
|
case "contentType":
|
||||||
|
newContentType = value;
|
||||||
|
setContentType(value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onFiltersChange) {
|
||||||
|
onFiltersChange({
|
||||||
|
spaceId: newSelectedSpaceId,
|
||||||
|
contentType: newContentType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.filtersContainer}>
|
||||||
|
<Menu
|
||||||
|
shadow="md"
|
||||||
|
width={250}
|
||||||
|
position="bottom-start"
|
||||||
|
zIndex={getDefaultZIndex("max")}
|
||||||
|
>
|
||||||
|
<Menu.Target>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
rightSection={<IconChevronDown size={14} />}
|
||||||
|
leftSection={<IconBuilding size={16} />}
|
||||||
|
className={classes.filterButton}
|
||||||
|
fw={500}
|
||||||
|
>
|
||||||
|
{selectedSpaceId
|
||||||
|
? `Space: ${selectedSpaceData?.name || "Unknown"}`
|
||||||
|
: "Space: All spaces"}
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Find a space"
|
||||||
|
data-autofocus
|
||||||
|
autoFocus
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
value={spaceSearchQuery}
|
||||||
|
onChange={(e) => setSpaceSearchQuery(e.target.value)}
|
||||||
|
size="sm"
|
||||||
|
variant="filled"
|
||||||
|
radius="sm"
|
||||||
|
styles={{ input: { marginBottom: 8 } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollArea.Autosize mah={280}>
|
||||||
|
<Menu.Item onClick={() => handleSpaceSelect(null)}>
|
||||||
|
<Group flex="1" gap="xs">
|
||||||
|
<Avatar
|
||||||
|
color="initials"
|
||||||
|
variant="filled"
|
||||||
|
name="All spaces"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
All spaces
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Search in all your spaces
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{!selectedSpaceId && <IconCheck size={20} />}
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Divider my="xs" />
|
||||||
|
|
||||||
|
{availableSpaces.map((space) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={space.id}
|
||||||
|
onClick={() => handleSpaceSelect(space.id)}
|
||||||
|
>
|
||||||
|
<Group flex="1" gap="xs">
|
||||||
|
<Avatar
|
||||||
|
color="initials"
|
||||||
|
variant="filled"
|
||||||
|
name={space.name}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
|
||||||
|
{space.name}
|
||||||
|
</Text>
|
||||||
|
{selectedSpaceId === space.id && <IconCheck size={20} />}
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
shadow="md"
|
||||||
|
width={220}
|
||||||
|
position="bottom-start"
|
||||||
|
zIndex={getDefaultZIndex("max")}
|
||||||
|
>
|
||||||
|
<Menu.Target>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
rightSection={<IconChevronDown size={14} />}
|
||||||
|
leftSection={<IconFileDescription size={16} />}
|
||||||
|
className={classes.filterButton}
|
||||||
|
fw={500}
|
||||||
|
>
|
||||||
|
{contentType
|
||||||
|
? `Type: ${contentTypeOptions.find((opt) => opt.value === contentType)?.label || contentType}`
|
||||||
|
: "Type"}
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{contentTypeOptions.map((option) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={option.value}
|
||||||
|
onClick={() =>
|
||||||
|
!option.disabled &&
|
||||||
|
contentType !== option.value &&
|
||||||
|
handleFilterChange("contentType", option.value)
|
||||||
|
}
|
||||||
|
disabled={option.disabled}
|
||||||
|
>
|
||||||
|
<Group flex="1" gap="xs">
|
||||||
|
<div>
|
||||||
|
<Text size="sm">{option.label}</Text>
|
||||||
|
{option.disabled && (
|
||||||
|
<Badge size="xs" mt={4}>
|
||||||
|
Enterprise
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{contentType === option.value && <IconCheck size={20} />}
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
apps/client/src/features/search/components/search-spotlight.tsx
Normal file
104
apps/client/src/features/search/components/search-spotlight.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { searchSpotlightStore } from "../constants.ts";
|
||||||
|
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
|
||||||
|
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
|
||||||
|
import { SearchResultItem } from "./search-result-item.tsx";
|
||||||
|
import { useLicense } from "@/ee/hooks/use-license.tsx";
|
||||||
|
|
||||||
|
interface SearchSpotlightProps {
|
||||||
|
spaceId?: string;
|
||||||
|
}
|
||||||
|
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { hasLicenseKey } = useLicense();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
||||||
|
const [filters, setFilters] = useState<{
|
||||||
|
spaceId?: string | null;
|
||||||
|
contentType?: string;
|
||||||
|
}>({
|
||||||
|
contentType: "page",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build unified search params
|
||||||
|
const searchParams = useMemo(() => {
|
||||||
|
const params: any = {
|
||||||
|
query: debouncedSearchQuery,
|
||||||
|
contentType: filters.contentType || "page", // Only used for frontend routing
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle space filtering - only pass spaceId if a specific space is selected
|
||||||
|
if (filters.spaceId) {
|
||||||
|
params.spaceId = filters.spaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}, [debouncedSearchQuery, filters]);
|
||||||
|
|
||||||
|
const { data: searchResults, isLoading } = useUnifiedSearch(searchParams);
|
||||||
|
|
||||||
|
// Determine result type for rendering
|
||||||
|
const isAttachmentSearch =
|
||||||
|
filters.contentType === "attachment" && hasLicenseKey;
|
||||||
|
|
||||||
|
const resultItems = (searchResults || []).map((result) => (
|
||||||
|
<SearchResultItem
|
||||||
|
key={result.id}
|
||||||
|
result={result}
|
||||||
|
isAttachmentResult={isAttachmentSearch}
|
||||||
|
showSpace={!filters.spaceId}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
const handleFiltersChange = (newFilters: any) => {
|
||||||
|
setFilters(newFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Spotlight.Root
|
||||||
|
size="xl"
|
||||||
|
maxHeight={600}
|
||||||
|
store={searchSpotlightStore}
|
||||||
|
query={query}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
scrollable
|
||||||
|
overlayProps={{
|
||||||
|
backgroundOpacity: 0.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spotlight.Search
|
||||||
|
placeholder={t("Search...")}
|
||||||
|
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "4px 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SearchSpotlightFilters
|
||||||
|
onFiltersChange={handleFiltersChange}
|
||||||
|
spaceId={spaceId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spotlight.ActionsList>
|
||||||
|
{query.length === 0 && resultItems.length === 0 && (
|
||||||
|
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{query.length > 0 && !isLoading && resultItems.length === 0 && (
|
||||||
|
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resultItems.length > 0 && <>{resultItems}</>}
|
||||||
|
</Spotlight.ActionsList>
|
||||||
|
</Spotlight.Root>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
|||||||
import { getPageIcon } from "@/lib";
|
import { getPageIcon } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { shareSearchSpotlightStore } from "@/features/search/constants.ts";
|
import { shareSearchSpotlightStore } from "@/features/search/constants.ts";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
interface ShareSearchSpotlightProps {
|
interface ShareSearchSpotlightProps {
|
||||||
shareId?: string;
|
shareId?: string;
|
||||||
@ -47,7 +48,12 @@ export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) {
|
|||||||
<Text
|
<Text
|
||||||
opacity={0.6}
|
opacity={0.6}
|
||||||
size="xs"
|
size="xs"
|
||||||
dangerouslySetInnerHTML={{ __html: page.highlight }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(page.highlight, {
|
||||||
|
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
42
apps/client/src/features/search/hooks/use-unified-search.ts
Normal file
42
apps/client/src/features/search/hooks/use-unified-search.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
searchPage,
|
||||||
|
searchAttachments,
|
||||||
|
} from "@/features/search/services/search-service";
|
||||||
|
import {
|
||||||
|
IAttachmentSearch,
|
||||||
|
IPageSearch,
|
||||||
|
IPageSearchParams,
|
||||||
|
} from "@/features/search/types/search.types";
|
||||||
|
import { useLicense } from "@/ee/hooks/use-license";
|
||||||
|
|
||||||
|
export type UnifiedSearchResult = IPageSearch | IAttachmentSearch;
|
||||||
|
|
||||||
|
export interface UseUnifiedSearchParams extends IPageSearchParams {
|
||||||
|
contentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnifiedSearch(
|
||||||
|
params: UseUnifiedSearchParams,
|
||||||
|
): UseQueryResult<UnifiedSearchResult[], Error> {
|
||||||
|
const { hasLicenseKey } = useLicense();
|
||||||
|
|
||||||
|
const isAttachmentSearch =
|
||||||
|
params.contentType === "attachment" && hasLicenseKey;
|
||||||
|
const searchType = isAttachmentSearch ? "attachment" : "page";
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["unified-search", searchType, params],
|
||||||
|
queryFn: async () => {
|
||||||
|
// Remove contentType from backend params since it's only used for frontend routing
|
||||||
|
const { contentType, ...backendParams } = params;
|
||||||
|
|
||||||
|
if (isAttachmentSearch) {
|
||||||
|
return await searchAttachments(backendParams);
|
||||||
|
} else {
|
||||||
|
return await searchPage(backendParams);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!params.query,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,15 +1,17 @@
|
|||||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
searchAttachments,
|
||||||
searchPage,
|
searchPage,
|
||||||
searchShare,
|
searchShare,
|
||||||
searchSuggestions,
|
searchSuggestions,
|
||||||
} from "@/features/search/services/search-service";
|
} from '@/features/search/services/search-service';
|
||||||
import {
|
import {
|
||||||
|
IAttachmentSearch,
|
||||||
IPageSearch,
|
IPageSearch,
|
||||||
IPageSearchParams,
|
IPageSearchParams,
|
||||||
ISuggestionResult,
|
ISuggestionResult,
|
||||||
SearchSuggestionParams,
|
SearchSuggestionParams,
|
||||||
} from "@/features/search/types/search.types";
|
} from '@/features/search/types/search.types';
|
||||||
|
|
||||||
export function usePageSearchQuery(
|
export function usePageSearchQuery(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
@ -41,3 +43,13 @@ export function useShareSearchQuery(
|
|||||||
enabled: !!params.query,
|
enabled: !!params.query,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useAttachmentSearchQuery(
|
||||||
|
params: IPageSearchParams,
|
||||||
|
): UseQueryResult<IAttachmentSearch[], Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["attachment-search", params],
|
||||||
|
queryFn: () => searchAttachments(params),
|
||||||
|
enabled: !!params.query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
import { Group, Center, Text } from "@mantine/core";
|
|
||||||
import { Spotlight } from "@mantine/spotlight";
|
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
|
||||||
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
||||||
import { getPageIcon } from "@/lib";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { searchSpotlightStore } from "./constants";
|
|
||||||
|
|
||||||
interface SearchSpotlightProps {
|
|
||||||
spaceId?: string;
|
|
||||||
}
|
|
||||||
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
|
||||||
|
|
||||||
const { data: searchResults } = usePageSearchQuery({
|
|
||||||
query: debouncedSearchQuery,
|
|
||||||
spaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pages = (
|
|
||||||
searchResults && searchResults.length > 0 ? searchResults : []
|
|
||||||
).map((page) => (
|
|
||||||
<Spotlight.Action
|
|
||||||
key={page.id}
|
|
||||||
component={Link}
|
|
||||||
//@ts-ignore
|
|
||||||
to={buildPageUrl(page.space.slug, page.slugId, page.title)}
|
|
||||||
style={{ userSelect: "none" }}
|
|
||||||
>
|
|
||||||
<Group wrap="nowrap" w="100%">
|
|
||||||
<Center>{getPageIcon(page?.icon)}</Center>
|
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Text>{page.title}</Text>
|
|
||||||
|
|
||||||
{page?.highlight && (
|
|
||||||
<Text
|
|
||||||
opacity={0.6}
|
|
||||||
size="xs"
|
|
||||||
dangerouslySetInnerHTML={{ __html: page.highlight }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Spotlight.Action>
|
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Spotlight.Root
|
|
||||||
store={searchSpotlightStore}
|
|
||||||
query={query}
|
|
||||||
onQueryChange={setQuery}
|
|
||||||
scrollable
|
|
||||||
overlayProps={{
|
|
||||||
backgroundOpacity: 0.55,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Spotlight.Search
|
|
||||||
placeholder={t("Search...")}
|
|
||||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
|
||||||
/>
|
|
||||||
<Spotlight.ActionsList>
|
|
||||||
{query.length === 0 && pages.length === 0 && (
|
|
||||||
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{query.length > 0 && pages.length === 0 && (
|
|
||||||
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pages.length > 0 && pages}
|
|
||||||
</Spotlight.ActionsList>
|
|
||||||
</Spotlight.Root>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
import {
|
import {
|
||||||
|
IAttachmentSearch,
|
||||||
IPageSearch,
|
IPageSearch,
|
||||||
IPageSearchParams,
|
IPageSearchParams,
|
||||||
ISuggestionResult,
|
ISuggestionResult,
|
||||||
SearchSuggestionParams,
|
SearchSuggestionParams,
|
||||||
} from "@/features/search/types/search.types";
|
} from '@/features/search/types/search.types';
|
||||||
|
|
||||||
export async function searchPage(
|
export async function searchPage(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
@ -26,3 +27,10 @@ export async function searchShare(
|
|||||||
const req = await api.post<IPageSearch[]>("/search/share-search", params);
|
const req = await api.post<IPageSearch[]>("/search/share-search", params);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function searchAttachments(
|
||||||
|
params: IPageSearchParams,
|
||||||
|
): Promise<IAttachmentSearch[]> {
|
||||||
|
const req = await api.post<IAttachmentSearch[]>("/search-attachments", params);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|||||||
@ -37,3 +37,25 @@ export interface IPageSearchParams {
|
|||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
shareId?: string;
|
shareId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IAttachmentSearch {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
pageId: string;
|
||||||
|
creatorId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
rank: string;
|
||||||
|
highlight: string;
|
||||||
|
space: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
page: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slugId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
6
apps/client/src/features/share/atoms/shared-page-atom.ts
Normal file
6
apps/client/src/features/share/atoms/shared-page-atom.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
import { ISharedPageTree } from "@/features/share/types/share.types";
|
||||||
|
import { SharedPageTreeNode } from "@/features/share/utils";
|
||||||
|
|
||||||
|
export const sharedPageTreeAtom = atom<ISharedPageTree | null>(null);
|
||||||
|
export const sharedTreeDataAtom = atom<SharedPageTreeNode[] | null>(null);
|
||||||
@ -1,9 +1,7 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Affix,
|
|
||||||
AppShell,
|
AppShell,
|
||||||
Button,
|
|
||||||
Group,
|
Group,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@ -14,8 +12,10 @@ import SharedTree from "@/features/share/components/shared-tree.tsx";
|
|||||||
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
|
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
|
||||||
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle.tsx";
|
import { ThemeToggle } from "@/components/theme-toggle.tsx";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
import { sharedPageTreeAtom, sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
|
||||||
|
import { buildSharedPageTree } from "@/features/share/utils";
|
||||||
import {
|
import {
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
@ -34,7 +34,7 @@ import {
|
|||||||
SearchControl,
|
SearchControl,
|
||||||
SearchMobileControl,
|
SearchMobileControl,
|
||||||
} from "@/features/search/components/search-control.tsx";
|
} from "@/features/search/components/search-control.tsx";
|
||||||
import { ShareSearchSpotlight } from "@/features/search/share-search-spotlight";
|
import { ShareSearchSpotlight } from "@/features/search/components/share-search-spotlight.tsx";
|
||||||
import { shareSearchSpotlight } from "@/features/search/constants";
|
import { shareSearchSpotlight } from "@/features/search/constants";
|
||||||
import ShareBranding from '@/features/share/components/share-branding.tsx';
|
import ShareBranding from '@/features/share/components/share-branding.tsx';
|
||||||
|
|
||||||
@ -60,6 +60,22 @@ export default function ShareShell({
|
|||||||
const { data } = useGetSharedPageTreeQuery(shareId);
|
const { data } = useGetSharedPageTreeQuery(shareId);
|
||||||
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
|
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const setSharedPageTree = useSetAtom(sharedPageTreeAtom);
|
||||||
|
// @ts-ignore
|
||||||
|
const setSharedTreeData = useSetAtom(sharedTreeDataAtom);
|
||||||
|
|
||||||
|
// Build and set the tree data when it changes
|
||||||
|
const treeData = useMemo(() => {
|
||||||
|
if (!data?.pageTree) return null;
|
||||||
|
return buildSharedPageTree(data.pageTree);
|
||||||
|
}, [data?.pageTree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSharedPageTree(data || null);
|
||||||
|
setSharedTreeData(treeData);
|
||||||
|
}, [data, treeData, setSharedPageTree, setSharedTreeData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 50 }}
|
header={{ height: 50 }}
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
|
||||||
|
import { SharedPageTreeNode } from "@/features/share/utils";
|
||||||
|
|
||||||
|
export function useSharedPageSubpages(pageId: string | undefined) {
|
||||||
|
const treeData = useAtomValue(sharedTreeDataAtom);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!treeData || !pageId) return [];
|
||||||
|
|
||||||
|
function findSubpages(nodes: SharedPageTreeNode[]): SharedPageTreeNode[] {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.value === pageId || node.slugId === pageId) {
|
||||||
|
return node.children || [];
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
const subpages = findSubpages(node.children);
|
||||||
|
if (subpages.length > 0) {
|
||||||
|
return subpages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return findSubpages(treeData);
|
||||||
|
}, [treeData, pageId]);
|
||||||
|
}
|
||||||
@ -19,7 +19,6 @@ import {
|
|||||||
import classes from "./space-sidebar.module.css";
|
import classes from "./space-sidebar.module.css";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
|
|
||||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
import { Link, useLocation, useParams } from "react-router-dom";
|
import { Link, useLocation, useParams } from "react-router-dom";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@ -195,8 +194,6 @@ export function SpaceSidebar() {
|
|||||||
onClose={closeSettings}
|
onClose={closeSettings}
|
||||||
spaceId={space?.slug}
|
spaceId={space?.slug}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SearchSpotlight spaceId={space.id} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,41 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
import { atomWithStorage } from "jotai/utils";
|
import { atomWithStorage } from "jotai/utils";
|
||||||
|
import { ICurrentUser, IUser } from "@/features/user/types/user.types";
|
||||||
import { ICurrentUser } from "@/features/user/types/user.types";
|
import { IWorkspace } from "@/features/workspace/types/workspace.types";
|
||||||
import { focusAtom } from "jotai-optics";
|
|
||||||
|
|
||||||
export const currentUserAtom = atomWithStorage<ICurrentUser | null>(
|
export const currentUserAtom = atomWithStorage<ICurrentUser | null>(
|
||||||
"currentUser",
|
"currentUser",
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const userAtom = focusAtom(currentUserAtom, (optic) =>
|
export const userAtom = atom(
|
||||||
optic.prop("user"),
|
(get) => {
|
||||||
|
const currentUser = get(currentUserAtom);
|
||||||
|
return currentUser?.user ?? null;
|
||||||
|
},
|
||||||
|
(get, set, newUser: IUser) => {
|
||||||
|
const currentUser = get(currentUserAtom);
|
||||||
|
if (currentUser) {
|
||||||
|
set(currentUserAtom, {
|
||||||
|
...currentUser,
|
||||||
|
user: newUser,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
export const workspaceAtom = focusAtom(currentUserAtom, (optic) =>
|
|
||||||
optic.prop("workspace"),
|
export const workspaceAtom = atom(
|
||||||
|
(get) => {
|
||||||
|
const currentUser = get(currentUserAtom);
|
||||||
|
return currentUser?.workspace ?? null;
|
||||||
|
},
|
||||||
|
(get, set, newWorkspace: IWorkspace) => {
|
||||||
|
const currentUser = get(currentUserAtom);
|
||||||
|
if (currentUser) {
|
||||||
|
set(currentUserAtom, {
|
||||||
|
...currentUser,
|
||||||
|
workspace: newWorkspace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -25,7 +25,7 @@ function LanguageSwitcher() {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [user, setUser] = useAtom(userAtom);
|
const [user, setUser] = useAtom(userAtom);
|
||||||
const [language, setLanguage] = useState(
|
const [language, setLanguage] = useState(
|
||||||
user?.locale === "en" ? "en-US" : user.locale,
|
user?.locale === "en" ? "en-US" : user?.locale,
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = async (value: string) => {
|
const handleChange = async (value: string) => {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export interface IUser {
|
|||||||
deletedAt: Date;
|
deletedAt: Date;
|
||||||
fullPageWidth: boolean; // used for update
|
fullPageWidth: boolean; // used for update
|
||||||
pageEditMode: string; // used for update
|
pageEditMode: string; // used for update
|
||||||
|
hasGeneratedPassword?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICurrentUser {
|
export interface ICurrentUser {
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import { useState } from "react";
|
|||||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||||
import { TextInput, Button } from "@mantine/core";
|
import { TextInput, Button } from "@mantine/core";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
|
import { zodResolver } from "mantine-form-zod-resolver";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
const APP_ROUTE = {
|
const APP_ROUTE = {
|
||||||
HOME: "/home",
|
HOME: "/home",
|
||||||
SPACES: "/spaces",
|
SPACES: "/spaces",
|
||||||
|
SEARCH: "/search",
|
||||||
AUTH: {
|
AUTH: {
|
||||||
LOGIN: "/login",
|
LOGIN: "/login",
|
||||||
SIGNUP: "/signup",
|
SIGNUP: "/signup",
|
||||||
|
|||||||
@ -52,6 +52,7 @@ export default function SharedPage() {
|
|||||||
key={data.page.id}
|
key={data.page.id}
|
||||||
title={data.page.title}
|
title={data.page.title}
|
||||||
content={data.page.content}
|
content={data.page.content}
|
||||||
|
pageId={data.page.id}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
|||||||
@ -67,6 +67,8 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"kysely": "^0.28.2",
|
"kysely": "^0.28.2",
|
||||||
"kysely-migration-cli": "^0.4.2",
|
"kysely-migration-cli": "^0.4.2",
|
||||||
|
"ldapts": "^7.4.0",
|
||||||
|
"mammoth": "^1.10.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"nestjs-kysely": "^1.2.0",
|
"nestjs-kysely": "^1.2.0",
|
||||||
@ -77,6 +79,7 @@
|
|||||||
"p-limit": "^6.2.0",
|
"p-limit": "^6.2.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pdfjs-dist": "^5.4.54",
|
||||||
"pg": "^8.16.0",
|
"pg": "^8.16.0",
|
||||||
"pg-tsquery": "^8.4.2",
|
"pg-tsquery": "^8.4.2",
|
||||||
"pgvector": "^0.2.1",
|
"pgvector": "^0.2.1",
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import {
|
|||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
Mention,
|
Mention,
|
||||||
|
Subpages,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import { generateHTML } from '../common/helpers/prosemirror/html';
|
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||||
@ -79,6 +80,7 @@ export const tiptapExtensions = [
|
|||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
Mention,
|
Mention,
|
||||||
|
Subpages,
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
export function jsonToHtml(tiptapJson: any) {
|
export function jsonToHtml(tiptapJson: any) {
|
||||||
|
|||||||
@ -87,3 +87,12 @@ export function extractBearerTokenFromHeader(
|
|||||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
return type === 'Bearer' ? token : undefined;
|
return type === 'Bearer' ? token : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasLicenseOrEE(opts: {
|
||||||
|
licenseKey: string;
|
||||||
|
plan: string;
|
||||||
|
isCloud: boolean;
|
||||||
|
}): boolean {
|
||||||
|
const { licenseKey, plan, isCloud } = opts;
|
||||||
|
return Boolean(licenseKey) || (isCloud && plan === 'business');
|
||||||
|
}
|
||||||
|
|||||||
@ -3,12 +3,15 @@ import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
|||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
import { AttachmentService } from '../services/attachment.service';
|
import { AttachmentService } from '../services/attachment.service';
|
||||||
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||||
import { Space } from '@docmost/db/types/entity.types';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
|
||||||
@Processor(QueueName.ATTACHMENT_QUEUE)
|
@Processor(QueueName.ATTACHMENT_QUEUE)
|
||||||
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
||||||
private readonly logger = new Logger(AttachmentProcessor.name);
|
private readonly logger = new Logger(AttachmentProcessor.name);
|
||||||
constructor(private readonly attachmentService: AttachmentService) {
|
constructor(
|
||||||
|
private readonly attachmentService: AttachmentService,
|
||||||
|
private moduleRef: ModuleRef,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,6 +28,33 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
job.data.pageId,
|
job.data.pageId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
job.name === QueueJob.ATTACHMENT_INDEX_CONTENT ||
|
||||||
|
job.name === QueueJob.ATTACHMENT_INDEXING
|
||||||
|
) {
|
||||||
|
let AttachmentEeModule: any;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
AttachmentEeModule = require('./../../../ee/attachments-ee/attachment-ee.service');
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
'Attachment enterprise module requested but EE module not bundled in this build',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attachmentEeService = this.moduleRef.get(
|
||||||
|
AttachmentEeModule.AttachmentEeService,
|
||||||
|
{ strict: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (job.name === QueueJob.ATTACHMENT_INDEX_CONTENT) {
|
||||||
|
await attachmentEeService.indexAttachment(job.data.attachmentId);
|
||||||
|
} else if (job.name === QueueJob.ATTACHMENT_INDEXING) {
|
||||||
|
await attachmentEeService.indexAttachments(
|
||||||
|
job.data.workspaceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,9 @@ import { executeTx } from '@docmost/db/utils';
|
|||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AttachmentService {
|
export class AttachmentService {
|
||||||
@ -33,6 +36,7 @@ export class AttachmentService {
|
|||||||
private readonly workspaceRepo: WorkspaceRepo,
|
private readonly workspaceRepo: WorkspaceRepo,
|
||||||
private readonly spaceRepo: SpaceRepo,
|
private readonly spaceRepo: SpaceRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async uploadFile(opts: {
|
async uploadFile(opts: {
|
||||||
@ -99,6 +103,23 @@ export class AttachmentService {
|
|||||||
pageId,
|
pageId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only index PDFs and DOCX files
|
||||||
|
if (['.pdf', '.docx'].includes(attachment.fileExt.toLowerCase())) {
|
||||||
|
await this.attachmentQueue.add(
|
||||||
|
QueueJob.ATTACHMENT_INDEX_CONTENT,
|
||||||
|
{
|
||||||
|
attachmentId: attachmentId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attempts: 2,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 10000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// delete uploaded file on error
|
// delete uploaded file on error
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
@ -367,4 +388,5 @@ export class AttachmentService {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,6 +106,7 @@ export class AuthService {
|
|||||||
await this.userRepo.updateUser(
|
await this.userRepo.updateUser(
|
||||||
{
|
{
|
||||||
password: newPasswordHash,
|
password: newPasswordHash,
|
||||||
|
hasGeneratedPassword: false,
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@ -186,6 +187,7 @@ export class AuthService {
|
|||||||
await this.userRepo.updateUser(
|
await this.userRepo.updateUser(
|
||||||
{
|
{
|
||||||
password: newPasswordHash,
|
password: newPasswordHash,
|
||||||
|
hasGeneratedPassword: false,
|
||||||
},
|
},
|
||||||
user.id,
|
user.id,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
import { SpaceIdDto } from './page.dto';
|
import { SpaceIdDto } from './page.dto';
|
||||||
|
|
||||||
export class SidebarPageDto extends SpaceIdDto {
|
export class SidebarPageDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
spaceId: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|||||||
@ -254,21 +254,28 @@ export class PageController {
|
|||||||
@Body() pagination: PaginationOptions,
|
@Body() pagination: PaginationOptions,
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
) {
|
) {
|
||||||
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
|
if (!dto.spaceId && !dto.pageId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Either spaceId or pageId must be provided',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let spaceId = dto.spaceId;
|
||||||
|
|
||||||
|
if (dto.pageId) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceId = page.spaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
let pageId = null;
|
return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId);
|
||||||
if (dto.pageId) {
|
|
||||||
const page = await this.pageRepo.findById(dto.pageId);
|
|
||||||
if (page.spaceId !== dto.spaceId) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
pageId = page.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.pageService.getSidebarPages(dto.spaceId, pagination, pageId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@ -5,15 +5,13 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { PartialType } from '@nestjs/mapped-types';
|
|
||||||
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
|
|
||||||
|
|
||||||
export class SearchDTO {
|
export class SearchDTO {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
query: string;
|
query: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import { Public } from '../../common/decorators/public.decorator';
|
|||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
|
import { hasLicenseOrEE } from '../../common/helpers';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('shares')
|
@Controller('shares')
|
||||||
@ -65,9 +66,11 @@ export class ShareController {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...(await this.shareService.getSharedPage(dto, workspace.id)),
|
...(await this.shareService.getSharedPage(dto, workspace.id)),
|
||||||
hasLicenseKey:
|
hasLicenseKey: hasLicenseOrEE({
|
||||||
Boolean(workspace.licenseKey) ||
|
licenseKey: workspace.licenseKey,
|
||||||
(this.environmentService.isCloud() && workspace.plan === 'business'),
|
isCloud: this.environmentService.isCloud(),
|
||||||
|
plan: workspace.plan,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,9 +178,11 @@ export class ShareController {
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
|
...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
|
||||||
hasLicenseKey:
|
hasLicenseKey: hasLicenseOrEE({
|
||||||
Boolean(workspace.licenseKey) ||
|
licenseKey: workspace.licenseKey,
|
||||||
(this.environmentService.isCloud() && workspace.plan === 'business'),
|
isCloud: this.environmentService.isCloud(),
|
||||||
|
plan: workspace.plan,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { type Kysely } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('auth_providers')
|
||||||
|
.addColumn('group_sync', 'boolean', (col) => col.defaultTo(false).notNull())
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('auth_providers')
|
||||||
|
.dropColumn('group_sync')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
// switch type to text column since you can't add value to PG types in a transaction
|
||||||
|
await db.schema
|
||||||
|
.alterTable('auth_providers')
|
||||||
|
.alterColumn('type', (col) => col.setDataType('text'))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema.dropType('auth_provider_type').ifExists().execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.alterTable('users')
|
||||||
|
.addColumn('has_generated_password', 'boolean', (col) =>
|
||||||
|
col.notNull().defaultTo(false).ifNotExists(),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.alterTable('auth_providers')
|
||||||
|
.addColumn('ldap_url', 'varchar', (col) => col)
|
||||||
|
.addColumn('ldap_bind_dn', 'varchar', (col) => col)
|
||||||
|
.addColumn('ldap_bind_password', 'varchar', (col) => col)
|
||||||
|
.addColumn('ldap_base_dn', 'varchar', (col) => col)
|
||||||
|
.addColumn('ldap_user_search_filter', 'varchar', (col) => col)
|
||||||
|
.addColumn('ldap_user_attributes', 'jsonb', (col) =>
|
||||||
|
col.defaultTo(sql`'{}'::jsonb`),
|
||||||
|
)
|
||||||
|
.addColumn('ldap_tls_enabled', 'boolean', (col) => col.defaultTo(false))
|
||||||
|
.addColumn('ldap_tls_ca_cert', 'text', (col) => col)
|
||||||
|
.addColumn('ldap_config', 'jsonb', (col) => col.defaultTo(sql`'{}'::jsonb`))
|
||||||
|
.addColumn('settings', 'jsonb', (col) => col.defaultTo(sql`'{}'::jsonb`))
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('users')
|
||||||
|
.dropColumn('has_generated_password')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.alterTable('auth_providers')
|
||||||
|
.dropColumn('ldap_url')
|
||||||
|
.dropColumn('ldap_bind_dn')
|
||||||
|
.dropColumn('ldap_bind_password')
|
||||||
|
.dropColumn('ldap_base_dn')
|
||||||
|
.dropColumn('ldap_user_search_filter')
|
||||||
|
.dropColumn('ldap_user_attributes')
|
||||||
|
.dropColumn('ldap_tls_enabled')
|
||||||
|
.dropColumn('ldap_tls_ca_cert')
|
||||||
|
.dropColumn('ldap_config')
|
||||||
|
.dropColumn('settings')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createType('auth_provider_type')
|
||||||
|
.asEnum(['saml', 'oidc', 'google'])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.deleteFrom('auth_providers').where('type', '=', 'ldap').execute();
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
ALTER TABLE auth_providers
|
||||||
|
ALTER COLUMN type TYPE auth_provider_type
|
||||||
|
USING type::auth_provider_type
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('attachments')
|
||||||
|
.addColumn('text_content', 'text', (col) => col)
|
||||||
|
.addColumn('tsv', sql`tsvector`, (col) => col)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('attachments_tsv_idx')
|
||||||
|
.on('attachments')
|
||||||
|
.using('GIN')
|
||||||
|
.column('tsv')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('attachments')
|
||||||
|
.dropIndex('attachments_tsv_idx')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.alterTable('attachments')
|
||||||
|
.dropColumn('text_content')
|
||||||
|
.dropColumn('tsv')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
@ -12,6 +12,23 @@ import {
|
|||||||
export class AttachmentRepo {
|
export class AttachmentRepo {
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
private baseFields: Array<keyof Attachment> = [
|
||||||
|
'id',
|
||||||
|
'fileName',
|
||||||
|
'filePath',
|
||||||
|
'fileSize',
|
||||||
|
'fileExt',
|
||||||
|
'mimeType',
|
||||||
|
'type',
|
||||||
|
'creatorId',
|
||||||
|
'pageId',
|
||||||
|
'spaceId',
|
||||||
|
'workspaceId',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'deletedAt',
|
||||||
|
];
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
attachmentId: string,
|
attachmentId: string,
|
||||||
opts?: {
|
opts?: {
|
||||||
@ -22,7 +39,7 @@ export class AttachmentRepo {
|
|||||||
|
|
||||||
return db
|
return db
|
||||||
.selectFrom('attachments')
|
.selectFrom('attachments')
|
||||||
.selectAll()
|
.select(this.baseFields)
|
||||||
.where('id', '=', attachmentId)
|
.where('id', '=', attachmentId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
@ -36,7 +53,7 @@ export class AttachmentRepo {
|
|||||||
return db
|
return db
|
||||||
.insertInto('attachments')
|
.insertInto('attachments')
|
||||||
.values(insertableAttachment)
|
.values(insertableAttachment)
|
||||||
.returningAll()
|
.returning(this.baseFields)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,7 +67,7 @@ export class AttachmentRepo {
|
|||||||
|
|
||||||
return db
|
return db
|
||||||
.selectFrom('attachments')
|
.selectFrom('attachments')
|
||||||
.selectAll()
|
.select(this.baseFields)
|
||||||
.where('spaceId', '=', spaceId)
|
.where('spaceId', '=', spaceId)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
@ -64,6 +81,7 @@ export class AttachmentRepo {
|
|||||||
.updateTable('attachments')
|
.updateTable('attachments')
|
||||||
.set(updatableAttachment)
|
.set(updatableAttachment)
|
||||||
.where('pageId', 'in', pageIds)
|
.where('pageId', 'in', pageIds)
|
||||||
|
.returning(this.baseFields)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,7 +93,7 @@ export class AttachmentRepo {
|
|||||||
.updateTable('attachments')
|
.updateTable('attachments')
|
||||||
.set(updatableAttachment)
|
.set(updatableAttachment)
|
||||||
.where('id', '=', attachmentId)
|
.where('id', '=', attachmentId)
|
||||||
.returningAll()
|
.returning(this.baseFields)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -401,6 +401,7 @@ export class PageRepo {
|
|||||||
])
|
])
|
||||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||||
.where('id', '=', parentPageId)
|
.where('id', '=', parentPageId)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
.unionAll((exp) =>
|
.unionAll((exp) =>
|
||||||
exp
|
exp
|
||||||
.selectFrom('pages as p')
|
.selectFrom('pages as p')
|
||||||
@ -415,7 +416,8 @@ export class PageRepo {
|
|||||||
'p.workspaceId',
|
'p.workspaceId',
|
||||||
])
|
])
|
||||||
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
||||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
|
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||||
|
.where('p.deletedAt', 'is', null),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.selectFrom('page_hierarchy')
|
.selectFrom('page_hierarchy')
|
||||||
|
|||||||
@ -34,6 +34,7 @@ export class UserRepo {
|
|||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'deletedAt',
|
'deletedAt',
|
||||||
|
'hasGeneratedPassword',
|
||||||
];
|
];
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
|
|||||||
18
apps/server/src/database/types/db.d.ts
vendored
18
apps/server/src/database/types/db.d.ts
vendored
@ -5,8 +5,6 @@
|
|||||||
|
|
||||||
import type { ColumnType } from "kysely";
|
import type { ColumnType } from "kysely";
|
||||||
|
|
||||||
export type AuthProviderType = "google" | "oidc" | "saml";
|
|
||||||
|
|
||||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||||
? ColumnType<S, I | undefined, U>
|
? ColumnType<S, I | undefined, U>
|
||||||
: ColumnType<T, T | undefined, T>;
|
: ColumnType<T, T | undefined, T>;
|
||||||
@ -39,6 +37,8 @@ export interface Attachments {
|
|||||||
mimeType: string | null;
|
mimeType: string | null;
|
||||||
pageId: string | null;
|
pageId: string | null;
|
||||||
spaceId: string | null;
|
spaceId: string | null;
|
||||||
|
textContent: string | null;
|
||||||
|
tsv: string | null;
|
||||||
type: string | null;
|
type: string | null;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@ -62,13 +62,24 @@ export interface AuthProviders {
|
|||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
isEnabled: Generated<boolean>;
|
isEnabled: Generated<boolean>;
|
||||||
|
groupSync: Generated<boolean>;
|
||||||
|
ldapBaseDn: string | null;
|
||||||
|
ldapBindDn: string | null;
|
||||||
|
ldapBindPassword: string | null;
|
||||||
|
ldapTlsCaCert: string | null;
|
||||||
|
ldapTlsEnabled: Generated<boolean | null>;
|
||||||
|
ldapUrl: string | null;
|
||||||
|
ldapUserAttributes: Json | null;
|
||||||
|
ldapUserSearchFilter: string | null;
|
||||||
|
ldapConfig: Json | null;
|
||||||
|
settings: Json | null;
|
||||||
name: string;
|
name: string;
|
||||||
oidcClientId: string | null;
|
oidcClientId: string | null;
|
||||||
oidcClientSecret: string | null;
|
oidcClientSecret: string | null;
|
||||||
oidcIssuer: string | null;
|
oidcIssuer: string | null;
|
||||||
samlCertificate: string | null;
|
samlCertificate: string | null;
|
||||||
samlUrl: string | null;
|
samlUrl: string | null;
|
||||||
type: AuthProviderType;
|
type: string;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
@ -275,6 +286,7 @@ export interface Users {
|
|||||||
lastActiveAt: Timestamp | null;
|
lastActiveAt: Timestamp | null;
|
||||||
lastLoginAt: Timestamp | null;
|
lastLoginAt: Timestamp | null;
|
||||||
locale: string | null;
|
locale: string | null;
|
||||||
|
hasGeneratedPassword: Generated<boolean | null>;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
password: string | null;
|
password: string | null;
|
||||||
role: string | null;
|
role: string | null;
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export class ExportController {
|
|||||||
includeContent: true,
|
includeContent: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!page) {
|
if (!page || page.deletedAt) {
|
||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,8 @@ export enum QueueName {
|
|||||||
export enum QueueJob {
|
export enum QueueJob {
|
||||||
SEND_EMAIL = 'send-email',
|
SEND_EMAIL = 'send-email',
|
||||||
DELETE_SPACE_ATTACHMENTS = 'delete-space-attachments',
|
DELETE_SPACE_ATTACHMENTS = 'delete-space-attachments',
|
||||||
|
ATTACHMENT_INDEX_CONTENT = 'attachment-index-content',
|
||||||
|
ATTACHMENT_INDEXING = 'attachment-indexing',
|
||||||
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
|
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
|
||||||
PAGE_CONTENT_UPDATE = 'page-content-update',
|
PAGE_CONTENT_UPDATE = 'page-content-update',
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/sanitize-url": "^7.1.0",
|
"@braintree/sanitize-url": "^7.1.0",
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
|
"@floating-ui/dom": "^1.7.3",
|
||||||
"@hocuspocus/extension-redis": "^2.15.2",
|
"@hocuspocus/extension-redis": "^2.15.2",
|
||||||
"@hocuspocus/provider": "^2.15.2",
|
"@hocuspocus/provider": "^2.15.2",
|
||||||
"@hocuspocus/server": "^2.15.2",
|
"@hocuspocus/server": "^2.15.2",
|
||||||
|
|||||||
@ -19,3 +19,4 @@ export * from "./lib/mention";
|
|||||||
export * from "./lib/markdown";
|
export * from "./lib/markdown";
|
||||||
export * from "./lib/search-and-replace";
|
export * from "./lib/search-and-replace";
|
||||||
export * from "./lib/embed-provider";
|
export * from "./lib/embed-provider";
|
||||||
|
export * from "./lib/subpages";
|
||||||
|
|||||||
@ -18,6 +18,10 @@ export interface CalloutAttributes {
|
|||||||
* The type of callout.
|
* The type of callout.
|
||||||
*/
|
*/
|
||||||
type: CalloutType;
|
type: CalloutType;
|
||||||
|
/**
|
||||||
|
* The custom icon name for the callout.
|
||||||
|
*/
|
||||||
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
@ -27,6 +31,7 @@ declare module "@tiptap/core" {
|
|||||||
unsetCallout: () => ReturnType;
|
unsetCallout: () => ReturnType;
|
||||||
toggleCallout: (attributes?: CalloutAttributes) => ReturnType;
|
toggleCallout: (attributes?: CalloutAttributes) => ReturnType;
|
||||||
updateCalloutType: (type: CalloutType) => ReturnType;
|
updateCalloutType: (type: CalloutType) => ReturnType;
|
||||||
|
updateCalloutIcon: (icon: string) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -58,6 +63,13 @@ export const Callout = Node.create<CalloutOptions>({
|
|||||||
"data-callout-type": attributes.type,
|
"data-callout-type": attributes.type,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
icon: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-callout-icon"),
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
"data-callout-icon": attributes.icon,
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -107,6 +119,13 @@ export const Callout = Node.create<CalloutOptions>({
|
|||||||
commands.updateAttributes("callout", {
|
commands.updateAttributes("callout", {
|
||||||
type: getValidCalloutType(type),
|
type: getValidCalloutType(type),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
updateCalloutIcon:
|
||||||
|
(icon: string) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("callout", {
|
||||||
|
icon: icon || null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
2
packages/editor-ext/src/lib/subpages/index.ts
Normal file
2
packages/editor-ext/src/lib/subpages/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Subpages } from "./subpages";
|
||||||
|
export type { SubpagesAttributes, SubpagesOptions } from "./subpages";
|
||||||
68
packages/editor-ext/src/lib/subpages/subpages.ts
Normal file
68
packages/editor-ext/src/lib/subpages/subpages.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { mergeAttributes, Node } from "@tiptap/core";
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
|
||||||
|
export interface SubpagesOptions {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
view: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubpagesAttributes {}
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
subpages: {
|
||||||
|
insertSubpages: (attributes?: SubpagesAttributes) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Subpages = Node.create<SubpagesOptions>({
|
||||||
|
name: "subpages",
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
view: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
group: "block",
|
||||||
|
atom: true,
|
||||||
|
draggable: false,
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `div[data-type="${this.name}"]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"div",
|
||||||
|
mergeAttributes(
|
||||||
|
{ "data-type": this.name },
|
||||||
|
this.options.HTMLAttributes,
|
||||||
|
HTMLAttributes,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertSubpages:
|
||||||
|
(attributes) =>
|
||||||
|
({ commands }) => {
|
||||||
|
return commands.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
attrs: attributes,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
import { DraggingDOMs } from "./utils";
|
||||||
|
|
||||||
|
const EDGE_THRESHOLD = 100;
|
||||||
|
const SCROLL_SPEED = 10;
|
||||||
|
|
||||||
|
export class AutoScrollController {
|
||||||
|
private _autoScrollInterval?: number;
|
||||||
|
|
||||||
|
checkYAutoScroll = (clientY: number) => {
|
||||||
|
const scrollContainer = document.documentElement;
|
||||||
|
|
||||||
|
if (clientY < 0 + EDGE_THRESHOLD) {
|
||||||
|
this._startYAutoScroll(scrollContainer!, -1 * SCROLL_SPEED);
|
||||||
|
} else if (clientY > window.innerHeight - EDGE_THRESHOLD) {
|
||||||
|
this._startYAutoScroll(scrollContainer!, SCROLL_SPEED);
|
||||||
|
} else {
|
||||||
|
this._stopYAutoScroll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkXAutoScroll = (clientX: number, draggingDOMs: DraggingDOMs) => {
|
||||||
|
const table = draggingDOMs?.table;
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
const scrollContainer = table.closest<HTMLElement>('.tableWrapper');
|
||||||
|
const editorRect = scrollContainer.getBoundingClientRect();
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
if (clientX < editorRect.left + EDGE_THRESHOLD) {
|
||||||
|
this._startXAutoScroll(scrollContainer!, -1 * SCROLL_SPEED);
|
||||||
|
} else if (clientX > editorRect.right - EDGE_THRESHOLD) {
|
||||||
|
this._startXAutoScroll(scrollContainer!, SCROLL_SPEED);
|
||||||
|
} else {
|
||||||
|
this._stopXAutoScroll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop = () => {
|
||||||
|
this._stopXAutoScroll();
|
||||||
|
this._stopYAutoScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _startXAutoScroll = (scrollContainer: HTMLElement, speed: number) => {
|
||||||
|
if (this._autoScrollInterval) {
|
||||||
|
clearInterval(this._autoScrollInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._autoScrollInterval = window.setInterval(() => {
|
||||||
|
scrollContainer.scrollLeft += speed;
|
||||||
|
}, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _stopXAutoScroll = () => {
|
||||||
|
if (this._autoScrollInterval) {
|
||||||
|
clearInterval(this._autoScrollInterval);
|
||||||
|
this._autoScrollInterval = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _startYAutoScroll = (scrollContainer: HTMLElement, speed: number) => {
|
||||||
|
if (this._autoScrollInterval) {
|
||||||
|
clearInterval(this._autoScrollInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._autoScrollInterval = window.setInterval(() => {
|
||||||
|
scrollContainer.scrollTop += speed;
|
||||||
|
}, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _stopYAutoScroll = () => {
|
||||||
|
if (this._autoScrollInterval) {
|
||||||
|
clearInterval(this._autoScrollInterval);
|
||||||
|
this._autoScrollInterval = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
packages/editor-ext/src/lib/table/dnd/calc-drag-over.ts
Normal file
44
packages/editor-ext/src/lib/table/dnd/calc-drag-over.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
function findDragOverElement(
|
||||||
|
elements: Element[],
|
||||||
|
pointer: number,
|
||||||
|
axis: 'x' | 'y',
|
||||||
|
): [Element, number] | undefined {
|
||||||
|
const startProp = axis === 'x' ? 'left' : 'top'
|
||||||
|
const endProp = axis === 'x' ? 'right' : 'bottom'
|
||||||
|
const lastIndex = elements.length - 1
|
||||||
|
|
||||||
|
const index = elements.findIndex((el, index) => {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const boundaryStart = rect[startProp]
|
||||||
|
const boundaryEnd = rect[endProp]
|
||||||
|
|
||||||
|
// The pointer is within the boundary of the current element.
|
||||||
|
if (boundaryStart <= pointer && pointer <= boundaryEnd) return true
|
||||||
|
// The pointer is beyond the last element.
|
||||||
|
if (index === lastIndex && pointer > boundaryEnd) return true
|
||||||
|
// The pointer is before the first element.
|
||||||
|
if (index === 0 && pointer < boundaryStart) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
return index >= 0 ? [elements[index], index] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDragOverColumn(
|
||||||
|
table: HTMLTableElement,
|
||||||
|
pointerX: number,
|
||||||
|
): [element: Element, index: number] | undefined {
|
||||||
|
const firstRow = table.querySelector('tr')
|
||||||
|
if (!firstRow) return
|
||||||
|
const cells = Array.from(firstRow.children)
|
||||||
|
return findDragOverElement(cells, pointerX, 'x')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDragOverRow(
|
||||||
|
table: HTMLTableElement,
|
||||||
|
pointerY: number,
|
||||||
|
): [element: Element, index: number] | undefined {
|
||||||
|
const rows = Array.from(table.querySelectorAll('tr'))
|
||||||
|
return findDragOverElement(rows, pointerY, 'y')
|
||||||
|
}
|
||||||
277
packages/editor-ext/src/lib/table/dnd/dnd-extension.ts
Normal file
277
packages/editor-ext/src/lib/table/dnd/dnd-extension.ts
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
import { Editor, Extension } from "@tiptap/core";
|
||||||
|
import { PluginKey, Plugin, PluginSpec } from "@tiptap/pm/state";
|
||||||
|
import { EditorProps, EditorView } from "@tiptap/pm/view";
|
||||||
|
import { DraggingDOMs, getDndRelatedDOMs, getHoveringCell, HoveringCellInfo } from "./utils";
|
||||||
|
import { getDragOverColumn, getDragOverRow } from "./calc-drag-over";
|
||||||
|
import { moveColumn, moveRow } from "../utils";
|
||||||
|
import { PreviewController } from "./preview/preview-controller";
|
||||||
|
import { DropIndicatorController } from "./preview/drop-indicator-controller";
|
||||||
|
import { DragHandleController } from "./handle/drag-handle-controller";
|
||||||
|
import { EmptyImageController } from "./handle/empty-image-controller";
|
||||||
|
import { AutoScrollController } from "./auto-scroll-controller";
|
||||||
|
|
||||||
|
export const TableDndKey = new PluginKey('table-drag-and-drop')
|
||||||
|
|
||||||
|
class TableDragHandlePluginSpec implements PluginSpec<void> {
|
||||||
|
key = TableDndKey
|
||||||
|
props: EditorProps<Plugin<void>>
|
||||||
|
|
||||||
|
private _colDragHandle: HTMLElement;
|
||||||
|
private _rowDragHandle: HTMLElement;
|
||||||
|
private _hoveringCell?: HoveringCellInfo;
|
||||||
|
private _disposables: (() => void)[] = [];
|
||||||
|
private _draggingCoords: { x: number; y: number } = { x: 0, y: 0 };
|
||||||
|
private _dragging = false;
|
||||||
|
private _draggingDirection: 'col' | 'row' = 'col';
|
||||||
|
private _draggingIndex = -1;
|
||||||
|
private _droppingIndex = -1;
|
||||||
|
private _draggingDOMs?: DraggingDOMs | undefined
|
||||||
|
private _startCoords: { x: number; y: number } = { x: 0, y: 0 };
|
||||||
|
private _previewController: PreviewController;
|
||||||
|
private _dropIndicatorController: DropIndicatorController;
|
||||||
|
private _dragHandleController: DragHandleController;
|
||||||
|
private _emptyImageController: EmptyImageController;
|
||||||
|
private _autoScrollController: AutoScrollController;
|
||||||
|
|
||||||
|
constructor(public editor: Editor) {
|
||||||
|
this.props = {
|
||||||
|
handleDOMEvents: {
|
||||||
|
pointerover: this._pointerOver,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._dragHandleController = new DragHandleController();
|
||||||
|
this._colDragHandle = this._dragHandleController.colDragHandle;
|
||||||
|
this._rowDragHandle = this._dragHandleController.rowDragHandle;
|
||||||
|
|
||||||
|
this._previewController = new PreviewController();
|
||||||
|
this._dropIndicatorController = new DropIndicatorController();
|
||||||
|
this._emptyImageController = new EmptyImageController();
|
||||||
|
|
||||||
|
this._autoScrollController = new AutoScrollController();
|
||||||
|
|
||||||
|
this._bindDragEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
view = () => {
|
||||||
|
const wrapper = this.editor.options.element;
|
||||||
|
wrapper.appendChild(this._colDragHandle)
|
||||||
|
wrapper.appendChild(this._rowDragHandle)
|
||||||
|
wrapper.appendChild(this._previewController.previewRoot)
|
||||||
|
wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot)
|
||||||
|
|
||||||
|
return {
|
||||||
|
update: this.update,
|
||||||
|
destroy: this.destroy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update = () => {}
|
||||||
|
|
||||||
|
destroy = () => {
|
||||||
|
if (!this.editor.isDestroyed) return;
|
||||||
|
this._dragHandleController.destroy();
|
||||||
|
this._emptyImageController.destroy();
|
||||||
|
this._previewController.destroy();
|
||||||
|
this._dropIndicatorController.destroy();
|
||||||
|
this._autoScrollController.stop();
|
||||||
|
|
||||||
|
this._disposables.forEach(disposable => disposable());
|
||||||
|
}
|
||||||
|
|
||||||
|
private _pointerOver = (view: EditorView, event: PointerEvent) => {
|
||||||
|
if (this._dragging) return;
|
||||||
|
|
||||||
|
// Don't show drag handles in readonly mode
|
||||||
|
if (!this.editor.isEditable) {
|
||||||
|
this._dragHandleController.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoveringCell = getHoveringCell(view, event)
|
||||||
|
this._hoveringCell = hoveringCell;
|
||||||
|
if (!hoveringCell) {
|
||||||
|
this._dragHandleController.hide();
|
||||||
|
} else {
|
||||||
|
this._dragHandleController.show(this.editor, hoveringCell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDragColStart = (event: DragEvent) => {
|
||||||
|
this._onDragStart(event, 'col');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDraggingCol = (event: DragEvent) => {
|
||||||
|
const draggingDOMs = this._draggingDOMs;
|
||||||
|
if (!draggingDOMs) return;
|
||||||
|
|
||||||
|
this._draggingCoords = { x: event.clientX, y: event.clientY };
|
||||||
|
this._previewController.onDragging(draggingDOMs, this._draggingCoords.x, this._draggingCoords.y, 'col');
|
||||||
|
|
||||||
|
this._autoScrollController.checkXAutoScroll(event.clientX, draggingDOMs);
|
||||||
|
|
||||||
|
const direction = this._startCoords.x > this._draggingCoords.x ? 'left' : 'right';
|
||||||
|
const dragOverColumn = getDragOverColumn(draggingDOMs.table, this._draggingCoords.x);
|
||||||
|
if (!dragOverColumn) return;
|
||||||
|
|
||||||
|
const [col, index] = dragOverColumn;
|
||||||
|
this._droppingIndex = index;
|
||||||
|
this._dropIndicatorController.onDragging(col, direction, 'col');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDragRowStart = (event: DragEvent) => {
|
||||||
|
this._onDragStart(event, 'row');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDraggingRow = (event: DragEvent) => {
|
||||||
|
const draggingDOMs = this._draggingDOMs;
|
||||||
|
if (!draggingDOMs) return;
|
||||||
|
|
||||||
|
this._draggingCoords = { x: event.clientX, y: event.clientY };
|
||||||
|
this._previewController.onDragging(draggingDOMs, this._draggingCoords.x, this._draggingCoords.y, 'row');
|
||||||
|
|
||||||
|
this._autoScrollController.checkYAutoScroll(event.clientY);
|
||||||
|
|
||||||
|
const direction = this._startCoords.y > this._draggingCoords.y ? 'up' : 'down';
|
||||||
|
const dragOverRow = getDragOverRow(draggingDOMs.table, this._draggingCoords.y);
|
||||||
|
if (!dragOverRow) return;
|
||||||
|
|
||||||
|
const [row, index] = dragOverRow;
|
||||||
|
this._droppingIndex = index;
|
||||||
|
this._dropIndicatorController.onDragging(row, direction, 'row');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDragEnd = () => {
|
||||||
|
this._dragging = false;
|
||||||
|
this._draggingIndex = -1;
|
||||||
|
this._droppingIndex = -1;
|
||||||
|
this._startCoords = { x: 0, y: 0 };
|
||||||
|
this._autoScrollController.stop();
|
||||||
|
this._dropIndicatorController.onDragEnd();
|
||||||
|
this._previewController.onDragEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _bindDragEvents = () => {
|
||||||
|
this._colDragHandle.addEventListener('dragstart', this._onDragColStart);
|
||||||
|
this._disposables.push(() => {
|
||||||
|
this._colDragHandle.removeEventListener('dragstart', this._onDragColStart);
|
||||||
|
})
|
||||||
|
|
||||||
|
this._colDragHandle.addEventListener('dragend', this._onDragEnd);
|
||||||
|
this._disposables.push(() => {
|
||||||
|
this._colDragHandle.removeEventListener('dragend', this._onDragEnd);
|
||||||
|
})
|
||||||
|
|
||||||
|
this._rowDragHandle.addEventListener('dragstart', this._onDragRowStart);
|
||||||
|
this._disposables.push(() => {
|
||||||
|
this._rowDragHandle.removeEventListener('dragstart', this._onDragRowStart);
|
||||||
|
})
|
||||||
|
|
||||||
|
this._rowDragHandle.addEventListener('dragend', this._onDragEnd);
|
||||||
|
this._disposables.push(() => {
|
||||||
|
this._rowDragHandle.removeEventListener('dragend', this._onDragEnd);
|
||||||
|
})
|
||||||
|
|
||||||
|
const ownerDocument = this.editor.view.dom?.ownerDocument
|
||||||
|
if (ownerDocument) {
|
||||||
|
// To make `drop` event work, we need to prevent the default behavior of the
|
||||||
|
// `dragover` event for drop zone. Here we set the whole document as the
|
||||||
|
// drop zone so that even the mouse moves outside the editor, the `drop`
|
||||||
|
// event will still be triggered.
|
||||||
|
ownerDocument.addEventListener('drop', this._onDrop);
|
||||||
|
ownerDocument.addEventListener('dragover', this._onDrag);
|
||||||
|
this._disposables.push(() => {
|
||||||
|
ownerDocument.removeEventListener('drop', this._onDrop);
|
||||||
|
ownerDocument.removeEventListener('dragover', this._onDrag);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDragStart = (event: DragEvent, type: 'col' | 'row') => {
|
||||||
|
const dataTransfer = event.dataTransfer;
|
||||||
|
if (dataTransfer) {
|
||||||
|
dataTransfer.effectAllowed = 'move';
|
||||||
|
this._emptyImageController.hideDragImage(dataTransfer);
|
||||||
|
}
|
||||||
|
this._dragging = true;
|
||||||
|
this._draggingDirection = type;
|
||||||
|
this._startCoords = { x: event.clientX, y: event.clientY };
|
||||||
|
const draggingIndex = (type === 'col' ? this._hoveringCell?.colIndex : this._hoveringCell?.rowIndex) ?? 0;
|
||||||
|
|
||||||
|
this._draggingIndex = draggingIndex;
|
||||||
|
|
||||||
|
const relatedDoms = getDndRelatedDOMs(
|
||||||
|
this.editor.view,
|
||||||
|
this._hoveringCell?.cellPos,
|
||||||
|
draggingIndex,
|
||||||
|
type
|
||||||
|
)
|
||||||
|
this._draggingDOMs = relatedDoms;
|
||||||
|
|
||||||
|
const index = type === 'col' ? this._hoveringCell?.colIndex : this._hoveringCell?.rowIndex;
|
||||||
|
|
||||||
|
this._previewController.onDragStart(relatedDoms, index, type);
|
||||||
|
this._dropIndicatorController.onDragStart(relatedDoms, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDrag = (event: DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!this._dragging) return;
|
||||||
|
if (this._draggingDirection === 'col') {
|
||||||
|
this._onDraggingCol(event);
|
||||||
|
} else {
|
||||||
|
this._onDraggingRow(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDrop = () => {
|
||||||
|
if (!this._dragging) return;
|
||||||
|
const direction = this._draggingDirection;
|
||||||
|
const from = this._draggingIndex;
|
||||||
|
const to = this._droppingIndex;
|
||||||
|
const tr = this.editor.state.tr;
|
||||||
|
const pos = this.editor.state.selection.from;
|
||||||
|
|
||||||
|
if (direction === 'col') {
|
||||||
|
const canMove = moveColumn({
|
||||||
|
tr,
|
||||||
|
originIndex: from,
|
||||||
|
targetIndex: to,
|
||||||
|
select: true,
|
||||||
|
pos,
|
||||||
|
})
|
||||||
|
if (canMove) {
|
||||||
|
this.editor.view.dispatch(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === 'row') {
|
||||||
|
const canMove = moveRow({
|
||||||
|
tr,
|
||||||
|
originIndex: from,
|
||||||
|
targetIndex: to,
|
||||||
|
select: true,
|
||||||
|
pos,
|
||||||
|
})
|
||||||
|
if (canMove) {
|
||||||
|
this.editor.view.dispatch(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TableDndExtension = Extension.create({
|
||||||
|
name: 'table-drag-and-drop',
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const editor = this.editor
|
||||||
|
|
||||||
|
const dragHandlePluginSpec = new TableDragHandlePluginSpec(editor)
|
||||||
|
const dragHandlePlugin = new Plugin(dragHandlePluginSpec)
|
||||||
|
|
||||||
|
return [dragHandlePlugin]
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { HoveringCellInfo } from "../utils";
|
||||||
|
import { computePosition, offset } from "@floating-ui/dom";
|
||||||
|
|
||||||
|
export class DragHandleController {
|
||||||
|
private _colDragHandle: HTMLElement;
|
||||||
|
private _rowDragHandle: HTMLElement;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._colDragHandle = this._createDragHandleDom('col');
|
||||||
|
this._rowDragHandle = this._createDragHandleDom('row');
|
||||||
|
}
|
||||||
|
|
||||||
|
get colDragHandle() {
|
||||||
|
return this._colDragHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rowDragHandle() {
|
||||||
|
return this._rowDragHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
show = (editor: Editor, hoveringCell: HoveringCellInfo) => {
|
||||||
|
this._showColDragHandle(editor, hoveringCell);
|
||||||
|
this._showRowDragHandle(editor, hoveringCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
hide = () => {
|
||||||
|
Object.assign(this._colDragHandle.style, {
|
||||||
|
display: 'none',
|
||||||
|
left: '-999px',
|
||||||
|
top: '-999px',
|
||||||
|
});
|
||||||
|
Object.assign(this._rowDragHandle.style, {
|
||||||
|
display: 'none',
|
||||||
|
left: '-999px',
|
||||||
|
top: '-999px',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy = () => {
|
||||||
|
this._colDragHandle.remove()
|
||||||
|
this._rowDragHandle.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
private _createDragHandleDom = (type: 'col' | 'row') => {
|
||||||
|
const dragHandle = document.createElement('div')
|
||||||
|
dragHandle.classList.add('drag-handle')
|
||||||
|
dragHandle.setAttribute('draggable', 'true')
|
||||||
|
dragHandle.setAttribute('data-direction', type === 'col' ? 'horizontal' : 'vertical')
|
||||||
|
dragHandle.setAttribute('data-drag-handle', '')
|
||||||
|
Object.assign(dragHandle.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-999px',
|
||||||
|
left: '-999px',
|
||||||
|
display: 'none',
|
||||||
|
})
|
||||||
|
return dragHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _showColDragHandle(editor: Editor, hoveringCell: HoveringCellInfo) {
|
||||||
|
const referenceCell = editor.view.nodeDOM(hoveringCell.colFirstCellPos);
|
||||||
|
if (!referenceCell) return;
|
||||||
|
|
||||||
|
const yOffset = -1 * parseInt(getComputedStyle(this._colDragHandle).height) / 2;
|
||||||
|
|
||||||
|
computePosition(
|
||||||
|
referenceCell as HTMLElement,
|
||||||
|
this._colDragHandle,
|
||||||
|
{
|
||||||
|
placement: 'top',
|
||||||
|
middleware: [offset(yOffset)]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(({ x, y }) => {
|
||||||
|
Object.assign(this._colDragHandle.style, {
|
||||||
|
display: 'block',
|
||||||
|
top: `${y}px`,
|
||||||
|
left: `${x}px`,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private _showRowDragHandle(editor: Editor, hoveringCell: HoveringCellInfo) {
|
||||||
|
const referenceCell = editor.view.nodeDOM(hoveringCell.rowFirstCellPos);
|
||||||
|
if (!referenceCell) return;
|
||||||
|
|
||||||
|
const xOffset = -1 * parseInt(getComputedStyle(this._rowDragHandle).width) / 2;
|
||||||
|
|
||||||
|
computePosition(
|
||||||
|
referenceCell as HTMLElement,
|
||||||
|
this._rowDragHandle,
|
||||||
|
{
|
||||||
|
middleware: [offset(xOffset)],
|
||||||
|
placement: 'left'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(({ x, y}) => {
|
||||||
|
Object.assign(this._rowDragHandle.style, {
|
||||||
|
display: 'block',
|
||||||
|
top: `${y}px`,
|
||||||
|
left: `${x}px`,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
export class EmptyImageController {
|
||||||
|
private _emptyImage: HTMLImageElement;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._emptyImage = new Image(1, 1);
|
||||||
|
this._emptyImage.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
|
}
|
||||||
|
|
||||||
|
get emptyImage() {
|
||||||
|
return this._emptyImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideDragImage = (dataTransfer: DataTransfer) => {
|
||||||
|
dataTransfer.effectAllowed = 'move';
|
||||||
|
dataTransfer.setDragImage(this._emptyImage, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy = () => {
|
||||||
|
this._emptyImage.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/editor-ext/src/lib/table/dnd/index.ts
Normal file
1
packages/editor-ext/src/lib/table/dnd/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './dnd-extension'
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
import { computePosition, offset } from "@floating-ui/dom";
|
||||||
|
import { DraggingDOMs } from "../utils";
|
||||||
|
|
||||||
|
const DROP_INDICATOR_WIDTH = 2;
|
||||||
|
|
||||||
|
export class DropIndicatorController {
|
||||||
|
private _dropIndicator: HTMLElement;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._dropIndicator = document.createElement('div');
|
||||||
|
this._dropIndicator.classList.add('table-dnd-drop-indicator');
|
||||||
|
Object.assign(this._dropIndicator.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get dropIndicatorRoot() {
|
||||||
|
return this._dropIndicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragStart = (relatedDoms: DraggingDOMs, type: 'col' | 'row') => {
|
||||||
|
this._initDropIndicatorStyle(relatedDoms.table, type);
|
||||||
|
this._initDropIndicatorPosition(relatedDoms.cell, type);
|
||||||
|
this._dropIndicator.dataset.dragging = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragEnd = () => {
|
||||||
|
Object.assign(this._dropIndicator.style, { display: 'none' });
|
||||||
|
this._dropIndicator.dataset.dragging = 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragging = (target: Element, direction: 'left' | 'right' | 'up' | 'down', type: 'col' | 'row') => {
|
||||||
|
if (type === 'col') {
|
||||||
|
void computePosition(target, this._dropIndicator, {
|
||||||
|
placement: direction === 'left' ? 'left' : 'right',
|
||||||
|
middleware: [offset((direction === 'left' ? -1 * DROP_INDICATOR_WIDTH : 0))],
|
||||||
|
}).then(({ x }) => {
|
||||||
|
Object.assign(this._dropIndicator.style, { left: `${x}px` });
|
||||||
|
})
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'row') {
|
||||||
|
void computePosition(target, this._dropIndicator, {
|
||||||
|
placement: direction === 'up' ? 'top' : 'bottom',
|
||||||
|
middleware: [offset((direction === 'up' ? -1 * DROP_INDICATOR_WIDTH : 0))],
|
||||||
|
}).then(({ y }) => {
|
||||||
|
Object.assign(this._dropIndicator.style, { top: `${y}px` });
|
||||||
|
})
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy = () => {
|
||||||
|
this._dropIndicator.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _initDropIndicatorStyle = (table: HTMLElement, type: 'col' | 'row') => {
|
||||||
|
const tableRect = table.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (type === 'col') {
|
||||||
|
Object.assign(this._dropIndicator.style, {
|
||||||
|
display: 'block',
|
||||||
|
width: `${DROP_INDICATOR_WIDTH}px`,
|
||||||
|
height: `${tableRect.height}px`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'row') {
|
||||||
|
Object.assign(this._dropIndicator.style, {
|
||||||
|
display: 'block',
|
||||||
|
width: `${tableRect.width}px`,
|
||||||
|
height: `${DROP_INDICATOR_WIDTH}px`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private _initDropIndicatorPosition = (cell: HTMLElement, type: 'col' | 'row') => {
|
||||||
|
void computePosition(cell, this._dropIndicator, {
|
||||||
|
placement: type === 'row' ? 'right' : 'bottom',
|
||||||
|
middleware: [
|
||||||
|
offset(({ rects }) => {
|
||||||
|
if (type === 'col') {
|
||||||
|
return -rects.reference.height
|
||||||
|
}
|
||||||
|
return -rects.reference.width
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}).then(({ x, y }) => {
|
||||||
|
Object.assign(this._dropIndicator.style, {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
import { computePosition, offset, ReferenceElement } from "@floating-ui/dom";
|
||||||
|
import { DraggingDOMs } from "../utils";
|
||||||
|
import { clearPreviewDOM, createPreviewDOM } from "./render-preview";
|
||||||
|
|
||||||
|
export class PreviewController {
|
||||||
|
private _preview: HTMLElement;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._preview = document.createElement('div');
|
||||||
|
this._preview.classList.add('table-dnd-preview');
|
||||||
|
this._preview.classList.add('ProseMirror');
|
||||||
|
Object.assign(this._preview.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
display: 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get previewRoot(): HTMLElement {
|
||||||
|
return this._preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragStart = (relatedDoms: DraggingDOMs, index: number | undefined, type: 'col' | 'row') => {
|
||||||
|
this._initPreviewStyle(relatedDoms.table, relatedDoms.cell, type);
|
||||||
|
createPreviewDOM(relatedDoms.table, this._preview, index, type)
|
||||||
|
this._initPreviewPosition(relatedDoms.cell, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragEnd = () => {
|
||||||
|
clearPreviewDOM(this._preview);
|
||||||
|
Object.assign(this._preview.style, { display: 'none' });
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragging = (relatedDoms: DraggingDOMs, x: number, y: number, type: 'col' | 'row') => {
|
||||||
|
this._updatePreviewPosition(x, y, relatedDoms.cell, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy = () => {
|
||||||
|
this._preview.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _initPreviewStyle(table: HTMLTableElement, cell: HTMLTableCellElement, type: 'col' | 'row') {
|
||||||
|
const tableRect = table.getBoundingClientRect();
|
||||||
|
const cellRect = cell.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (type === 'col') {
|
||||||
|
Object.assign(this._preview.style, {
|
||||||
|
display: 'block',
|
||||||
|
width: `${cellRect.width}px`,
|
||||||
|
height: `${tableRect.height}px`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'row') {
|
||||||
|
Object.assign(this._preview.style, {
|
||||||
|
display: 'block',
|
||||||
|
width: `${tableRect.width}px`,
|
||||||
|
height: `${cellRect.height}px`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _initPreviewPosition(cell: HTMLElement, type: 'col' | 'row') {
|
||||||
|
void computePosition(cell, this._preview, {
|
||||||
|
placement: type === 'row' ? 'right' : 'bottom',
|
||||||
|
middleware: [
|
||||||
|
offset(({ rects }) => {
|
||||||
|
if (type === 'col') {
|
||||||
|
return -rects.reference.height
|
||||||
|
}
|
||||||
|
return -rects.reference.width
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}).then(({ x, y }) => {
|
||||||
|
Object.assign(this._preview.style, {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _updatePreviewPosition(x: number, y: number, cell: HTMLElement, type: 'col' | 'row') {
|
||||||
|
computePosition(
|
||||||
|
getVirtualElement(cell, x, y),
|
||||||
|
this._preview,
|
||||||
|
{ placement: type === 'row' ? 'right' : 'bottom' },
|
||||||
|
).then(({ x, y }) => {
|
||||||
|
if (type === 'row') {
|
||||||
|
Object.assign(this._preview.style, {
|
||||||
|
top: `${y}px`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'col') {
|
||||||
|
Object.assign(this._preview.style, {
|
||||||
|
left: `${x}px`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVirtualElement(cell: HTMLElement, x: number, y: number): ReferenceElement {
|
||||||
|
return {
|
||||||
|
contextElement: cell,
|
||||||
|
getBoundingClientRect: () => {
|
||||||
|
const rect = cell.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
right: x + rect.width / 2,
|
||||||
|
bottom: y + rect.height / 2,
|
||||||
|
top: y - rect.height / 2,
|
||||||
|
left: x - rect.width / 2,
|
||||||
|
x: x - rect.width / 2,
|
||||||
|
y: y - rect.height / 2,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
export function clearPreviewDOM(previewRoot: HTMLElement): void {
|
||||||
|
while (previewRoot.firstChild) {
|
||||||
|
previewRoot.removeChild(previewRoot.firstChild)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPreviewDOM(
|
||||||
|
table: HTMLTableElement,
|
||||||
|
previewRoot: HTMLElement,
|
||||||
|
index: number,
|
||||||
|
direction: 'row' | 'col',
|
||||||
|
): void {
|
||||||
|
clearPreviewDOM(previewRoot)
|
||||||
|
|
||||||
|
const previewTable = document.createElement('table')
|
||||||
|
const previewTableBody = document.createElement('tbody')
|
||||||
|
previewTable.appendChild(previewTableBody)
|
||||||
|
previewRoot.appendChild(previewTable)
|
||||||
|
|
||||||
|
const rows = table.querySelectorAll('tr')
|
||||||
|
|
||||||
|
if (direction === 'row') {
|
||||||
|
const row = rows[index]
|
||||||
|
const rowDOM = row.cloneNode(true)
|
||||||
|
previewTableBody.appendChild(rowDOM)
|
||||||
|
} else {
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const rowDOM = row.cloneNode(false)
|
||||||
|
const cells = row.querySelectorAll('th,td')
|
||||||
|
if (cells[index]) {
|
||||||
|
const cellDOM = cells[index].cloneNode(true)
|
||||||
|
rowDOM.appendChild(cellDOM)
|
||||||
|
previewTableBody.appendChild(rowDOM)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
107
packages/editor-ext/src/lib/table/dnd/utils.ts
Normal file
107
packages/editor-ext/src/lib/table/dnd/utils.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { cellAround, TableMap } from "@tiptap/pm/tables"
|
||||||
|
import { EditorView } from "@tiptap/pm/view"
|
||||||
|
|
||||||
|
export function getHoveringCell(
|
||||||
|
view: EditorView,
|
||||||
|
event: MouseEvent,
|
||||||
|
): HoveringCellInfo | undefined {
|
||||||
|
const domCell = domCellAround(event.target as HTMLElement | null)
|
||||||
|
if (!domCell) return
|
||||||
|
|
||||||
|
const { left, top, width, height } = domCell.getBoundingClientRect()
|
||||||
|
const eventPos = view.posAtCoords({
|
||||||
|
// Use the center coordinates of the cell to ensure we're within the
|
||||||
|
// selected cell. This prevents potential issues when the mouse is on the
|
||||||
|
// border of two cells.
|
||||||
|
left: left + width / 2,
|
||||||
|
top: top + height / 2,
|
||||||
|
})
|
||||||
|
if (!eventPos) return
|
||||||
|
|
||||||
|
const $cellPos = cellAround(view.state.doc.resolve(eventPos.pos))
|
||||||
|
if (!$cellPos) return
|
||||||
|
|
||||||
|
const map = TableMap.get($cellPos.node(-1))
|
||||||
|
const tableStart = $cellPos.start(-1)
|
||||||
|
const cellRect = map.findCell($cellPos.pos - tableStart)
|
||||||
|
const rowIndex = cellRect.top
|
||||||
|
const colIndex = cellRect.left
|
||||||
|
|
||||||
|
return {
|
||||||
|
rowIndex,
|
||||||
|
colIndex,
|
||||||
|
cellPos: $cellPos.pos,
|
||||||
|
rowFirstCellPos: getCellPos(map, tableStart, rowIndex, 0),
|
||||||
|
colFirstCellPos: getCellPos(map, tableStart, 0, colIndex),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function domCellAround(target: HTMLElement | null): HTMLElement | null {
|
||||||
|
while (target && target.nodeName != 'TD' && target.nodeName != 'TH') {
|
||||||
|
target = target.classList?.contains('ProseMirror')
|
||||||
|
? null
|
||||||
|
: (target.parentNode as HTMLElement | null)
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HoveringCellInfo {
|
||||||
|
rowIndex: number
|
||||||
|
colIndex: number
|
||||||
|
cellPos: number
|
||||||
|
rowFirstCellPos: number
|
||||||
|
colFirstCellPos: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellPos(
|
||||||
|
map: TableMap,
|
||||||
|
tableStart: number,
|
||||||
|
rowIndex: number,
|
||||||
|
colIndex: number,
|
||||||
|
) {
|
||||||
|
const cellIndex = getCellIndex(map, rowIndex, colIndex)
|
||||||
|
const posInTable = map.map[cellIndex]
|
||||||
|
return tableStart + posInTable
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellIndex(
|
||||||
|
map: TableMap,
|
||||||
|
rowIndex: number,
|
||||||
|
colIndex: number,
|
||||||
|
): number {
|
||||||
|
return map.width * rowIndex + colIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTableDOMByPos(view: EditorView, pos: number): HTMLTableElement | undefined {
|
||||||
|
const dom = view.domAtPos(pos).node
|
||||||
|
if (!dom) return
|
||||||
|
const element = dom instanceof HTMLElement ? dom : dom.parentElement
|
||||||
|
const table = element?.closest('table')
|
||||||
|
return table ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTargetFirstCellDOM(table: HTMLTableElement, index: number, direction: 'row' | 'col'): HTMLTableCellElement | undefined {
|
||||||
|
if (direction === 'row') {
|
||||||
|
const row = table.querySelectorAll('tr')[index]
|
||||||
|
const cell = row?.querySelector<HTMLTableCellElement>('th,td')
|
||||||
|
return cell ?? undefined
|
||||||
|
} else {
|
||||||
|
const row = table.querySelector('tr')
|
||||||
|
const cell = row?.querySelectorAll<HTMLTableCellElement>('th,td')[index]
|
||||||
|
return cell ?? undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DraggingDOMs = {
|
||||||
|
table: HTMLTableElement
|
||||||
|
cell: HTMLTableCellElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDndRelatedDOMs(view: EditorView, cellPos: number | undefined, draggingIndex: number, direction: 'row' | 'col'): DraggingDOMs | undefined {
|
||||||
|
if (cellPos == null) return
|
||||||
|
const table = getTableDOMByPos(view, cellPos)
|
||||||
|
if (!table) return
|
||||||
|
const cell = getTargetFirstCellDOM(table, draggingIndex, direction)
|
||||||
|
if (!cell) return
|
||||||
|
return { table, cell }
|
||||||
|
}
|
||||||
@ -2,3 +2,4 @@ export * from "./row";
|
|||||||
export * from "./cell";
|
export * from "./cell";
|
||||||
export * from "./header";
|
export * from "./header";
|
||||||
export * from "./table";
|
export * from "./table";
|
||||||
|
export * from "./dnd";
|
||||||
@ -1,29 +1,34 @@
|
|||||||
import Table from "@tiptap/extension-table";
|
import Table from "@tiptap/extension-table";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { DOMOutputSpec } from "@tiptap/pm/model";
|
||||||
|
|
||||||
const LIST_TYPES = ["bulletList", "orderedList", "taskList"];
|
const LIST_TYPES = ["bulletList", "orderedList", "taskList"];
|
||||||
|
|
||||||
function isInList(editor: Editor): boolean {
|
function isInList(editor: Editor): boolean {
|
||||||
const { $from } = editor.state.selection;
|
const { $from } = editor.state.selection;
|
||||||
|
|
||||||
for (let depth = $from.depth; depth > 0; depth--) {
|
for (let depth = $from.depth; depth > 0; depth--) {
|
||||||
const node = $from.node(depth);
|
const node = $from.node(depth);
|
||||||
if (LIST_TYPES.includes(node.type.name)) {
|
if (LIST_TYPES.includes(node.type.name)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleListIndent(editor: Editor): boolean {
|
function handleListIndent(editor: Editor): boolean {
|
||||||
return editor.commands.sinkListItem("listItem") ||
|
return (
|
||||||
editor.commands.sinkListItem("taskItem");
|
editor.commands.sinkListItem("listItem") ||
|
||||||
|
editor.commands.sinkListItem("taskItem")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleListOutdent(editor: Editor): boolean {
|
function handleListOutdent(editor: Editor): boolean {
|
||||||
return editor.commands.liftListItem("listItem") ||
|
return (
|
||||||
editor.commands.liftListItem("taskItem");
|
editor.commands.liftListItem("listItem") ||
|
||||||
|
editor.commands.liftListItem("taskItem")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomTable = Table.extend({
|
export const CustomTable = Table.extend({
|
||||||
@ -62,4 +67,15 @@ export const CustomTable = Table.extend({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
|
||||||
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
|
// https://github.com/ueberdosis/tiptap/issues/4872#issuecomment-2717554498
|
||||||
|
const originalRender = this.parent?.({ node, HTMLAttributes });
|
||||||
|
const wrapper: DOMOutputSpec = [
|
||||||
|
"div",
|
||||||
|
{ class: "tableWrapper" },
|
||||||
|
originalRender,
|
||||||
|
];
|
||||||
|
return wrapper;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
import type { Node } from '@tiptap/pm/model'
|
||||||
|
import { TableMap } from '@tiptap/pm/tables'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an array of rows to a table node.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function convertArrayOfRowsToTableNode(
|
||||||
|
tableNode: Node,
|
||||||
|
arrayOfNodes: (Node | null)[][],
|
||||||
|
): Node {
|
||||||
|
const rowsPM = []
|
||||||
|
const map = TableMap.get(tableNode)
|
||||||
|
for (let rowIndex = 0; rowIndex < map.height; rowIndex++) {
|
||||||
|
const row = tableNode.child(rowIndex)
|
||||||
|
const rowCells = []
|
||||||
|
|
||||||
|
for (let colIndex = 0; colIndex < map.width; colIndex++) {
|
||||||
|
if (!arrayOfNodes[rowIndex][colIndex]) continue
|
||||||
|
|
||||||
|
const cellPos = map.map[rowIndex * map.width + colIndex]
|
||||||
|
|
||||||
|
const cell = arrayOfNodes[rowIndex][colIndex]!
|
||||||
|
const oldCell = tableNode.nodeAt(cellPos)!
|
||||||
|
const newCell = oldCell.type.createChecked(
|
||||||
|
Object.assign({}, cell.attrs),
|
||||||
|
cell.content,
|
||||||
|
cell.marks,
|
||||||
|
)
|
||||||
|
rowCells.push(newCell)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsPM.push(row.type.createChecked(row.attrs, rowCells, row.marks))
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTable = tableNode.type.createChecked(
|
||||||
|
tableNode.attrs,
|
||||||
|
rowsPM,
|
||||||
|
tableNode.marks,
|
||||||
|
)
|
||||||
|
|
||||||
|
return newTable
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import type { Node } from '@tiptap/pm/model'
|
||||||
|
import { TableMap } from '@tiptap/pm/tables'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function will transform the table node into a matrix of rows and columns
|
||||||
|
* respecting merged cells, for example this table:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* ┌──────┬──────┬─────────────┐
|
||||||
|
* │ A1 │ B1 │ C1 │
|
||||||
|
* ├──────┼──────┴──────┬──────┤
|
||||||
|
* │ A2 │ B2 │ │
|
||||||
|
* ├──────┼─────────────┤ D1 │
|
||||||
|
* │ A3 │ B3 │ C3 │ │
|
||||||
|
* └──────┴──────┴──────┴──────┘
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* will be converted to the below:
|
||||||
|
*
|
||||||
|
* ```javascript
|
||||||
|
* [
|
||||||
|
* [A1, B1, C1, null],
|
||||||
|
* [A2, B2, null, D1],
|
||||||
|
* [A3, B3, C3, null],
|
||||||
|
* ]
|
||||||
|
* ```
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function convertTableNodeToArrayOfRows(tableNode: Node): (Node | null)[][] {
|
||||||
|
const map = TableMap.get(tableNode)
|
||||||
|
const rows: (Node | null)[][] = []
|
||||||
|
const rowCount = map.height
|
||||||
|
const colCount = map.width
|
||||||
|
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
||||||
|
const row: (Node | null)[] = []
|
||||||
|
for (let colIndex = 0; colIndex < colCount; colIndex++) {
|
||||||
|
let cellIndex = rowIndex * colCount + colIndex
|
||||||
|
let cellPos = map.map[cellIndex]
|
||||||
|
if (rowIndex > 0) {
|
||||||
|
const topCellIndex = cellIndex - colCount
|
||||||
|
const topCellPos = map.map[topCellIndex]
|
||||||
|
if (cellPos === topCellPos) {
|
||||||
|
row.push(null)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colIndex > 0) {
|
||||||
|
const leftCellIndex = cellIndex - 1
|
||||||
|
const leftCellPos = map.map[leftCellIndex]
|
||||||
|
if (cellPos === leftCellPos) {
|
||||||
|
row.push(null)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.push(tableNode.nodeAt(cellPos))
|
||||||
|
}
|
||||||
|
rows.push(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import type { Selection } from '@tiptap/pm/state'
|
||||||
|
import { TableMap } from '@tiptap/pm/tables'
|
||||||
|
|
||||||
|
import { findTable } from './query'
|
||||||
|
import type { CellPos } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an array of cells in a column(s), where `columnIndex` could be a column index or an array of column indexes.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function getCellsInColumn(columnIndexes: number | number[], selection: Selection): CellPos[] | undefined {
|
||||||
|
const table = findTable(selection.$from)
|
||||||
|
if (!table) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = TableMap.get(table.node)
|
||||||
|
const indexes = Array.isArray(columnIndexes) ? columnIndexes : [columnIndexes]
|
||||||
|
|
||||||
|
return indexes
|
||||||
|
.filter((index) => index >= 0 && index <= map.width - 1)
|
||||||
|
.flatMap((index) => {
|
||||||
|
const cells = map.cellsInRect({
|
||||||
|
left: index,
|
||||||
|
right: index + 1,
|
||||||
|
top: 0,
|
||||||
|
bottom: map.height,
|
||||||
|
})
|
||||||
|
return cells.map((nodePos) => {
|
||||||
|
const node = table.node.nodeAt(nodePos)!
|
||||||
|
const pos = nodePos + table.start
|
||||||
|
return { pos, start: pos + 1, node, depth: table.depth + 2 }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
36
packages/editor-ext/src/lib/table/utils/get-cells-in-row.ts
Normal file
36
packages/editor-ext/src/lib/table/utils/get-cells-in-row.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import type { Selection } from '@tiptap/pm/state'
|
||||||
|
import { TableMap } from '@tiptap/pm/tables'
|
||||||
|
|
||||||
|
import { findTable } from './query'
|
||||||
|
import type { CellPos } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an array of cells in a row(s), where `rowIndex` could be a row index or an array of row indexes.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function getCellsInRow(rowIndex: number | number[], selection: Selection): CellPos[] | undefined {
|
||||||
|
const table = findTable(selection.$from)
|
||||||
|
if (!table) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = TableMap.get(table.node)
|
||||||
|
const indexes = Array.isArray(rowIndex) ? rowIndex : [rowIndex]
|
||||||
|
|
||||||
|
return indexes
|
||||||
|
.filter((index) => index >= 0 && index <= map.height - 1)
|
||||||
|
.flatMap((index) => {
|
||||||
|
const cells = map.cellsInRect({
|
||||||
|
left: 0,
|
||||||
|
right: map.width,
|
||||||
|
top: index,
|
||||||
|
bottom: index + 1,
|
||||||
|
})
|
||||||
|
return cells.map((nodePos) => {
|
||||||
|
const node = table.node.nodeAt(nodePos)!
|
||||||
|
const pos = nodePos + table.start
|
||||||
|
return { pos, start: pos + 1, node, depth: table.depth + 2 }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
import type { Transaction } from '@tiptap/pm/state'
|
||||||
|
|
||||||
|
import { getCellsInColumn } from './get-cells-in-column'
|
||||||
|
import { getCellsInRow } from './get-cells-in-row'
|
||||||
|
import type { CellSelectionRange } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a range of rectangular selection spanning all merged cells around a
|
||||||
|
* column at index `columnIndex`.
|
||||||
|
*
|
||||||
|
* Original implementation from Atlassian (Apache License 2.0)
|
||||||
|
*
|
||||||
|
* https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/5f91cb871e8248bc3bae5ddc30bb9fd9200fadbb/editor/editor-tables/src/utils/get-selection-range-in-column.ts#editor/editor-tables/src/utils/get-selection-range-in-column.ts
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function getSelectionRangeInColumn(tr: Transaction, startColIndex: number, endColIndex: number = startColIndex): CellSelectionRange | undefined {
|
||||||
|
let startIndex = startColIndex
|
||||||
|
let endIndex = endColIndex
|
||||||
|
|
||||||
|
// looking for selection start column (startIndex)
|
||||||
|
for (let i = startColIndex; i >= 0; i--) {
|
||||||
|
const cells = getCellsInColumn(i, tr.selection)
|
||||||
|
if (cells) {
|
||||||
|
cells.forEach((cell) => {
|
||||||
|
const maybeEndIndex = cell.node.attrs.colspan + i - 1
|
||||||
|
if (maybeEndIndex >= startIndex) {
|
||||||
|
startIndex = i
|
||||||
|
}
|
||||||
|
if (maybeEndIndex > endIndex) {
|
||||||
|
endIndex = maybeEndIndex
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// looking for selection end column (endIndex)
|
||||||
|
for (let i = startColIndex; i <= endIndex; i++) {
|
||||||
|
const cells = getCellsInColumn(i, tr.selection)
|
||||||
|
if (cells) {
|
||||||
|
cells.forEach((cell) => {
|
||||||
|
const maybeEndIndex = cell.node.attrs.colspan + i - 1
|
||||||
|
if (cell.node.attrs.colspan > 1 && maybeEndIndex > endIndex) {
|
||||||
|
endIndex = maybeEndIndex
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter out columns without cells (where all rows have colspan > 1 in the same column)
|
||||||
|
const indexes = []
|
||||||
|
for (let i = startIndex; i <= endIndex; i++) {
|
||||||
|
const maybeCells = getCellsInColumn(i, tr.selection)
|
||||||
|
if (maybeCells && maybeCells.length > 0) {
|
||||||
|
indexes.push(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startIndex = indexes[0]
|
||||||
|
endIndex = indexes[indexes.length - 1]
|
||||||
|
|
||||||
|
const firstSelectedColumnCells = getCellsInColumn(startIndex, tr.selection)
|
||||||
|
const firstRowCells = getCellsInRow(0, tr.selection)
|
||||||
|
if (!firstSelectedColumnCells || !firstRowCells) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const $anchor = tr.doc.resolve(
|
||||||
|
firstSelectedColumnCells[firstSelectedColumnCells.length - 1].pos,
|
||||||
|
)
|
||||||
|
|
||||||
|
let headCell
|
||||||
|
for (let i = endIndex; i >= startIndex; i--) {
|
||||||
|
const columnCells = getCellsInColumn(i, tr.selection)
|
||||||
|
if (columnCells && columnCells.length > 0) {
|
||||||
|
for (let j = firstRowCells.length - 1; j >= 0; j--) {
|
||||||
|
if (firstRowCells[j].pos === columnCells[0].pos) {
|
||||||
|
headCell = columnCells[0]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (headCell) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!headCell) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const $head = tr.doc.resolve(headCell.pos)
|
||||||
|
return { $anchor, $head, indexes }
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
import type { Transaction } from '@tiptap/pm/state'
|
||||||
|
|
||||||
|
import { getCellsInColumn } from './get-cells-in-column'
|
||||||
|
import { getCellsInRow } from './get-cells-in-row'
|
||||||
|
import type { CellSelectionRange } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a range of rectangular selection spanning all merged cells around a
|
||||||
|
* row at index `rowIndex`.
|
||||||
|
*
|
||||||
|
* Original implementation from Atlassian (Apache License 2.0)
|
||||||
|
*
|
||||||
|
* https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/5f91cb871e8248bc3bae5ddc30bb9fd9200fadbb/editor/editor-tables/src/utils/get-selection-range-in-row.ts#editor/editor-tables/src/utils/get-selection-range-in-row.ts
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function getSelectionRangeInRow(tr: Transaction, startRowIndex: number, endRowIndex: number = startRowIndex): CellSelectionRange | undefined {
|
||||||
|
let startIndex = startRowIndex
|
||||||
|
let endIndex = endRowIndex
|
||||||
|
|
||||||
|
// looking for selection start row (startIndex)
|
||||||
|
for (let i = startRowIndex; i >= 0; i--) {
|
||||||
|
const cells = getCellsInRow(i, tr.selection)
|
||||||
|
if (cells) {
|
||||||
|
cells.forEach((cell) => {
|
||||||
|
const maybeEndIndex = cell.node.attrs.rowspan + i - 1
|
||||||
|
if (maybeEndIndex >= startIndex) {
|
||||||
|
startIndex = i
|
||||||
|
}
|
||||||
|
if (maybeEndIndex > endIndex) {
|
||||||
|
endIndex = maybeEndIndex
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// looking for selection end row (endIndex)
|
||||||
|
for (let i = startRowIndex; i <= endIndex; i++) {
|
||||||
|
const cells = getCellsInRow(i, tr.selection)
|
||||||
|
if (cells) {
|
||||||
|
cells.forEach((cell) => {
|
||||||
|
const maybeEndIndex = cell.node.attrs.rowspan + i - 1
|
||||||
|
if (cell.node.attrs.rowspan > 1 && maybeEndIndex > endIndex) {
|
||||||
|
endIndex = maybeEndIndex
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter out rows without cells (where all columns have rowspan > 1 in the same row)
|
||||||
|
const indexes = []
|
||||||
|
for (let i = startIndex; i <= endIndex; i++) {
|
||||||
|
const maybeCells = getCellsInRow(i, tr.selection)
|
||||||
|
if (maybeCells && maybeCells.length > 0) {
|
||||||
|
indexes.push(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startIndex = indexes[0]
|
||||||
|
endIndex = indexes[indexes.length - 1]
|
||||||
|
|
||||||
|
const firstSelectedRowCells = getCellsInRow(startIndex, tr.selection)
|
||||||
|
const firstColumnCells = getCellsInColumn(0, tr.selection)
|
||||||
|
if (!firstSelectedRowCells || !firstColumnCells) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const $anchor = tr.doc.resolve(firstSelectedRowCells[firstSelectedRowCells.length - 1].pos)
|
||||||
|
|
||||||
|
let headCell
|
||||||
|
for (let i = endIndex; i >= startIndex; i--) {
|
||||||
|
const rowCells = getCellsInRow(i, tr.selection)
|
||||||
|
if (rowCells && rowCells.length > 0) {
|
||||||
|
for (let j = firstColumnCells.length - 1; j >= 0; j--) {
|
||||||
|
if (firstColumnCells[j].pos === rowCells[0].pos) {
|
||||||
|
headCell = rowCells[0]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (headCell) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!headCell) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const $head = tr.doc.resolve(headCell.pos)
|
||||||
|
return { $anchor, $head, indexes }
|
||||||
|
}
|
||||||
3
packages/editor-ext/src/lib/table/utils/index.ts
Normal file
3
packages/editor-ext/src/lib/table/utils/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './move-column'
|
||||||
|
export * from './move-row'
|
||||||
|
export * from './query'
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user