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",
|
||||
"Deleted by": "Deleted by",
|
||||
"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 useTrial from "@/ee/hooks/use-trial.tsx";
|
||||
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" }];
|
||||
|
||||
@ -79,6 +87,15 @@ export function AppHeader() {
|
||||
</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">
|
||||
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
||||
<Badge
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
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 { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
||||
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() {
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
|
||||
return (
|
||||
<UserProvider>
|
||||
<GlobalAppShell>
|
||||
<Outlet />
|
||||
</GlobalAppShell>
|
||||
{isCloud() && <PosthogUser />}
|
||||
<SearchSpotlight spaceId={space?.id} />
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,6 +15,11 @@ export interface EmojiPickerInterface {
|
||||
icon: ReactNode;
|
||||
removeEmojiAction: () => void;
|
||||
readOnly: boolean;
|
||||
actionIconProps?: {
|
||||
size?: string;
|
||||
variant?: string;
|
||||
c?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function EmojiPicker({
|
||||
@ -22,6 +27,7 @@ function EmojiPicker({
|
||||
icon,
|
||||
removeEmojiAction,
|
||||
readOnly,
|
||||
actionIconProps,
|
||||
}: EmojiPickerInterface) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, handlers] = useDisclosure(false);
|
||||
@ -64,7 +70,12 @@ function EmojiPicker({
|
||||
closeOnEscape={true}
|
||||
>
|
||||
<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}
|
||||
</ActionIcon>
|
||||
</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 { 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 { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
|
||||
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
||||
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
|
||||
|
||||
export default function SsoLogin() {
|
||||
const { data, isLoading } = useWorkspacePublicDataQuery();
|
||||
const [ldapModalOpened, setLdapModalOpened] = useState(false);
|
||||
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
|
||||
|
||||
if (!data?.authProviders || data?.authProviders?.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSsoLogin = (provider: IAuthProvider) => {
|
||||
if (provider.type === SSO_PROVIDER.LDAP) {
|
||||
// Open modal for LDAP instead of redirecting
|
||||
setSelectedLdapProvider(provider);
|
||||
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 (
|
||||
<>
|
||||
{selectedLdapProvider && (
|
||||
<LdapLoginModal
|
||||
opened={ldapModalOpened}
|
||||
onClose={() => {
|
||||
setLdapModalOpened(false);
|
||||
setSelectedLdapProvider(null);
|
||||
}}
|
||||
provider={selectedLdapProvider}
|
||||
workspaceId={data.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isCloud() || data.hasLicenseKey) && (
|
||||
<>
|
||||
<Stack align="stretch" justify="center" gap="sm">
|
||||
@ -31,13 +64,7 @@ export default function SsoLogin() {
|
||||
<div key={provider.id}>
|
||||
<Button
|
||||
onClick={() => handleSsoLogin(provider)}
|
||||
leftSection={
|
||||
provider.type === SSO_PROVIDER.GOOGLE ? (
|
||||
<GoogleIcon size={16} />
|
||||
) : (
|
||||
<IconLock size={16} />
|
||||
)
|
||||
}
|
||||
leftSection={getProviderIcon(provider)}
|
||||
variant="default"
|
||||
fullWidth
|
||||
>
|
||||
|
||||
@ -45,6 +45,7 @@ export function MfaBackupCodeInput({
|
||||
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
|
||||
error={error}
|
||||
autoFocus
|
||||
data-autofocus
|
||||
maxLength={8}
|
||||
styles={{
|
||||
input: {
|
||||
|
||||
@ -25,23 +25,30 @@ import { regenerateBackupCodes } from "@/ee/mfa";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod";
|
||||
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
||||
|
||||
interface MfaBackupCodesModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
||||
});
|
||||
|
||||
export function MfaBackupCodesModal({
|
||||
opened,
|
||||
onClose,
|
||||
}: MfaBackupCodesModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
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({
|
||||
validate: zodResolver(formSchema),
|
||||
@ -51,7 +58,7 @@ export function MfaBackupCodesModal({
|
||||
});
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: (data: { confirmPassword: string }) =>
|
||||
mutationFn: (data: { confirmPassword?: string }) =>
|
||||
regenerateBackupCodes(data),
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backupCodes);
|
||||
@ -73,8 +80,12 @@ export function MfaBackupCodesModal({
|
||||
},
|
||||
});
|
||||
|
||||
const handleRegenerate = (values: { confirmPassword: string }) => {
|
||||
regenerateMutation.mutate(values);
|
||||
const handleRegenerate = (values: { confirmPassword?: string }) => {
|
||||
// Only send confirmPassword if it's required (non-SSO users)
|
||||
const payload = requiresPassword
|
||||
? { confirmPassword: values.confirmPassword }
|
||||
: {};
|
||||
regenerateMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@ -114,12 +125,16 @@ export function MfaBackupCodesModal({
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{requiresPassword && (
|
||||
<PasswordInput
|
||||
label={t("Confirm password")}
|
||||
placeholder={t("Enter your password")}
|
||||
variant="filled"
|
||||
{...form.getInputProps("confirmPassword")}
|
||||
autoFocus
|
||||
data-autofocus
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@ -97,6 +97,7 @@ export function MfaChallenge() {
|
||||
length={6}
|
||||
type="number"
|
||||
autoFocus
|
||||
data-autofocus
|
||||
oneTimeCode
|
||||
{...form.getInputProps("code")}
|
||||
error={!!form.errors.code}
|
||||
|
||||
@ -15,6 +15,7 @@ import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { disableMfa } from "@/ee/mfa";
|
||||
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
||||
|
||||
interface MfaDisableModalProps {
|
||||
opened: boolean;
|
||||
@ -22,16 +23,22 @@ interface MfaDisableModalProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
||||
});
|
||||
|
||||
export function MfaDisableModal({
|
||||
opened,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: MfaDisableModalProps) {
|
||||
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({
|
||||
validate: zodResolver(formSchema),
|
||||
@ -54,8 +61,12 @@ export function MfaDisableModal({
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: { confirmPassword: string }) => {
|
||||
await disableMutation.mutateAsync(values);
|
||||
const handleSubmit = async (values: { confirmPassword?: string }) => {
|
||||
// Only send confirmPassword if it's required (non-SSO users)
|
||||
const payload = requiresPassword
|
||||
? { confirmPassword: values.confirmPassword }
|
||||
: {};
|
||||
await disableMutation.mutateAsync(payload);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@ -85,6 +96,8 @@ export function MfaDisableModal({
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{requiresPassword && (
|
||||
<>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Please enter your password to disable two-factor authentication:",
|
||||
@ -96,7 +109,10 @@ export function MfaDisableModal({
|
||||
placeholder={t("Enter your password")}
|
||||
{...form.getInputProps("confirmPassword")}
|
||||
autoFocus
|
||||
data-autofocus
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
|
||||
@ -235,6 +235,7 @@ export function MfaSetupModal({
|
||||
length={6}
|
||||
type="number"
|
||||
autoFocus
|
||||
data-autofocus
|
||||
oneTimeCode
|
||||
{...form.getInputProps("verificationCode")}
|
||||
styles={{
|
||||
|
||||
@ -37,7 +37,7 @@ export async function disableMfa(
|
||||
}
|
||||
|
||||
export async function regenerateBackupCodes(data: {
|
||||
confirmPassword: string;
|
||||
confirmPassword?: string;
|
||||
}): Promise<MfaBackupCodesResponse> {
|
||||
const req = await api.post<MfaBackupCodesResponse>(
|
||||
"/mfa/generate-backup-codes",
|
||||
|
||||
@ -46,7 +46,7 @@ export interface MfaEnableResponse {
|
||||
}
|
||||
|
||||
export interface MfaDisableRequest {
|
||||
confirmPassword: string;
|
||||
confirmPassword?: string;
|
||||
}
|
||||
|
||||
export interface MfaBackupCodesResponse {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useAtom } from "jotai";
|
||||
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 React, { useState } from "react";
|
||||
import { Button, Text, TagsInput } from "@mantine/core";
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
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 { SSO_PROVIDER } from "@/ee/security/contants.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 (
|
||||
<>
|
||||
<SsoProviderModal opened={opened} onClose={close} provider={provider} />
|
||||
@ -71,6 +84,13 @@ export default function CreateSsoProvider() {
|
||||
>
|
||||
OpenID (OIDC)
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
onClick={handleCreateLDAP}
|
||||
leftSection={<IconServer size={16} />}
|
||||
>
|
||||
LDAP / Active Directory
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</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"),
|
||||
isEnabled: z.boolean(),
|
||||
allowSignup: z.boolean(),
|
||||
groupSync: z.boolean(),
|
||||
});
|
||||
|
||||
type SSOFormValues = z.infer<typeof ssoSchema>;
|
||||
@ -36,6 +37,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
||||
oidcClientSecret: provider.oidcClientSecret || "",
|
||||
isEnabled: provider.isEnabled,
|
||||
allowSignup: provider.allowSignup,
|
||||
groupSync: provider.groupSync || false,
|
||||
},
|
||||
validate: zodResolver(ssoSchema),
|
||||
});
|
||||
@ -67,6 +69,9 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
||||
if (form.isDirty("allowSignup")) {
|
||||
ssoData.allowSignup = values.allowSignup;
|
||||
}
|
||||
if (form.isDirty("groupSync")) {
|
||||
ssoData.groupSync = values.groupSync;
|
||||
}
|
||||
|
||||
await updateSsoProviderMutation.mutateAsync(ssoData);
|
||||
form.resetDirty();
|
||||
@ -110,6 +115,15 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
||||
{...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">
|
||||
<div>{t("Allow signup")}</div>
|
||||
<Switch
|
||||
|
||||
@ -5,6 +5,7 @@ import { SsoSamlForm } from "@/ee/security/components/sso-saml-form.tsx";
|
||||
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
||||
import { SsoOIDCForm } from "@/ee/security/components/sso-oidc-form.tsx";
|
||||
import { SsoGoogleForm } from "@/ee/security/components/sso-google-form.tsx";
|
||||
import { SsoLDAPForm } from "@/ee/security/components/sso-ldap-form.tsx";
|
||||
|
||||
interface SsoModalProps {
|
||||
opened: boolean;
|
||||
@ -38,6 +39,10 @@ export default function SsoProviderModal({
|
||||
{provider.type === SSO_PROVIDER.GOOGLE && (
|
||||
<SsoGoogleForm provider={provider} onClose={onClose} />
|
||||
)}
|
||||
|
||||
{provider.type === SSO_PROVIDER.LDAP && (
|
||||
<SsoLDAPForm provider={provider} onClose={onClose} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ const ssoSchema = z.object({
|
||||
samlCertificate: z.string().min(1, "SAML Idp Certificate is required"),
|
||||
isEnabled: z.boolean(),
|
||||
allowSignup: z.boolean(),
|
||||
groupSync: z.boolean(),
|
||||
});
|
||||
|
||||
type SSOFormValues = z.infer<typeof ssoSchema>;
|
||||
@ -45,6 +46,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
||||
samlCertificate: provider.samlCertificate || "",
|
||||
isEnabled: provider.isEnabled,
|
||||
allowSignup: provider.allowSignup,
|
||||
groupSync: provider.groupSync || false,
|
||||
},
|
||||
validate: zodResolver(ssoSchema),
|
||||
});
|
||||
@ -75,6 +77,9 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
||||
if (form.isDirty("allowSignup")) {
|
||||
ssoData.allowSignup = values.allowSignup;
|
||||
}
|
||||
if (form.isDirty("groupSync")) {
|
||||
ssoData.groupSync = values.groupSync;
|
||||
}
|
||||
|
||||
await updateSsoProviderMutation.mutateAsync(ssoData);
|
||||
form.resetDirty();
|
||||
@ -123,6 +128,15 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
||||
{...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">
|
||||
<div>{t("Allow signup")}</div>
|
||||
<Switch
|
||||
|
||||
@ -2,4 +2,5 @@ export enum SSO_PROVIDER {
|
||||
OIDC = 'oidc',
|
||||
SAML = 'saml',
|
||||
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;
|
||||
oidcClientId: string;
|
||||
oidcClientSecret: string;
|
||||
ldapUrl: string;
|
||||
ldapBindDn: string;
|
||||
ldapBindPassword: string;
|
||||
ldapBaseDn: string;
|
||||
ldapUserSearchFilter: string;
|
||||
ldapUserAttributes: any;
|
||||
ldapTlsEnabled: boolean;
|
||||
ldapTlsCaCert: string;
|
||||
allowSignup: boolean;
|
||||
isEnabled: boolean;
|
||||
groupSync: boolean;
|
||||
creatorId: string;
|
||||
workspaceId: string;
|
||||
createdAt: Date;
|
||||
|
||||
@ -9,18 +9,21 @@ import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Tooltip, Divider } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangleFilled,
|
||||
IconCircleCheckFilled,
|
||||
IconCircleXFilled,
|
||||
IconInfoCircleFilled,
|
||||
IconMoodSmile,
|
||||
} from "@tabler/icons-react";
|
||||
import { CalloutType } from "@docmost/editor-ext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||
|
||||
export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
@ -56,6 +59,36 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
[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 (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
@ -130,6 +163,20 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
<IconCircleXFilled size={18} />
|
||||
</ActionIcon>
|
||||
</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>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
|
||||
@ -11,7 +11,7 @@ import { CalloutType } from "@docmost/editor-ext";
|
||||
|
||||
export default function CalloutView(props: NodeViewProps) {
|
||||
const { node } = props;
|
||||
const { type } = node.attrs;
|
||||
const { type, icon } = node.attrs;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
@ -19,7 +19,7 @@ export default function CalloutView(props: NodeViewProps) {
|
||||
variant="light"
|
||||
title=""
|
||||
color={getCalloutColor(type)}
|
||||
icon={getCalloutIcon(type)}
|
||||
icon={getCalloutIcon(type, icon)}
|
||||
p="xs"
|
||||
classNames={{
|
||||
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) {
|
||||
case "info":
|
||||
return <IconInfoCircleFilled />;
|
||||
|
||||
@ -4,11 +4,7 @@ import mermaid from "mermaid";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import classes from "./code-block.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
import { useComputedColorScheme } from "@mantine/core";
|
||||
|
||||
interface MermaidViewProps {
|
||||
props: NodeViewProps;
|
||||
@ -16,12 +12,22 @@ interface MermaidViewProps {
|
||||
|
||||
export default function MermaidView({ props }: MermaidViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
const { node } = props;
|
||||
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(() => {
|
||||
const id = `mermaid-${uuidv4()}`;
|
||||
|
||||
if (node.textContent.length > 0) {
|
||||
mermaid
|
||||
.render(id, node.textContent)
|
||||
@ -40,7 +46,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [node.textContent]);
|
||||
}, [node.textContent, computedColorScheme]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@ -17,8 +17,10 @@ import {
|
||||
IconTable,
|
||||
IconTypography,
|
||||
IconMenu4,
|
||||
IconCalendar, IconAppWindow,
|
||||
} from '@tabler/icons-react';
|
||||
IconCalendar,
|
||||
IconAppWindow,
|
||||
IconSitemap,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
CommandProps,
|
||||
SlashMenuGroupedItemsType,
|
||||
@ -357,6 +359,15 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.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",
|
||||
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,
|
||||
SearchAndReplace,
|
||||
Mention,
|
||||
Subpages,
|
||||
TableDndExtension,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@ -57,6 +59,7 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
|
||||
import DrawioView from "../components/drawio/drawio-view";
|
||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-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 powershell from "highlight.js/lib/languages/powershell";
|
||||
import abap from "highlightjs-sap-abap";
|
||||
@ -168,6 +171,7 @@ export const mainExtensions = [
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableDndExtension,
|
||||
MathInline.configure({
|
||||
view: MathInlineView,
|
||||
}),
|
||||
@ -212,6 +216,9 @@ export const mainExtensions = [
|
||||
Embed.configure({
|
||||
view: EmbedView,
|
||||
}),
|
||||
Subpages.configure({
|
||||
view: SubpagesView,
|
||||
}),
|
||||
MarkdownClipboard.configure({
|
||||
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 CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
||||
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
|
||||
import {
|
||||
handleFileDrop,
|
||||
handlePaste,
|
||||
@ -49,6 +50,7 @@ import { extractPageSlugId } from "@/lib";
|
||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from '@/features/search/constants.ts';
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@ -221,6 +223,10 @@ export default function PageEditor({
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
|
||||
searchSpotlight.open();
|
||||
return true;
|
||||
}
|
||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
||||
const slashCommand = document.querySelector("#slash-command");
|
||||
if (slashCommand) {
|
||||
@ -375,7 +381,7 @@ export default function PageEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<div className="editor-container" style={{ position: "relative" }}>
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
@ -391,6 +397,7 @@ export default function PageEditor({
|
||||
<ImageMenu editor={editor} />
|
||||
<VideoMenu editor={editor} />
|
||||
<CalloutMenu editor={editor} />
|
||||
<SubpagesMenu editor={editor} />
|
||||
<ExcalidrawMenu editor={editor} />
|
||||
<DrawioMenu editor={editor} />
|
||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||
|
||||
@ -6,21 +6,19 @@ import { Document } from "@tiptap/extension-document";
|
||||
import { Heading } from "@tiptap/extension-heading";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { useAtom } from "jotai/index";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
readOnlyEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
|
||||
interface PageEditorProps {
|
||||
title: string;
|
||||
content: any;
|
||||
pageId?: string;
|
||||
}
|
||||
|
||||
export default function ReadonlyPageEditor({
|
||||
title,
|
||||
content,
|
||||
pageId,
|
||||
}: PageEditorProps) {
|
||||
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
||||
|
||||
@ -56,6 +54,9 @@ export default function ReadonlyPageEditor({
|
||||
content={content}
|
||||
onCreate={({ editor }) => {
|
||||
if (editor) {
|
||||
if (pageId) {
|
||||
editor.storage.pageId = pageId;
|
||||
}
|
||||
// @ts-ignore
|
||||
setReadOnlyEditor(editor);
|
||||
}
|
||||
|
||||
@ -45,6 +45,10 @@
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&[data-direction='horizontal'] {
|
||||
rotate: 90deg;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
table {
|
||||
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 { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
@ -86,6 +87,20 @@ export function TitleEditor({
|
||||
content: title,
|
||||
immediatelyRender: true,
|
||||
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(() => {
|
||||
|
||||
@ -252,6 +252,7 @@ export function useGetSidebarPagesQuery(
|
||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["sidebar-pages", data],
|
||||
enabled: !!data?.pageId || !!data?.spaceId,
|
||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
||||
initialPageParam: 1,
|
||||
getPreviousPageParam: (firstPage) =>
|
||||
|
||||
@ -60,7 +60,7 @@ export interface ICopyPageToSpace {
|
||||
}
|
||||
|
||||
export interface SidebarPagesParams {
|
||||
spaceId: string;
|
||||
spaceId?: string;
|
||||
pageId?: string;
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import { shareSearchSpotlightStore } from "@/features/search/constants.ts";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
interface ShareSearchSpotlightProps {
|
||||
shareId?: string;
|
||||
@ -47,7 +48,12 @@ export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) {
|
||||
<Text
|
||||
opacity={0.6}
|
||||
size="xs"
|
||||
dangerouslySetInnerHTML={{ __html: page.highlight }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(page.highlight, {
|
||||
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
|
||||
ALLOWED_ATTR: []
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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 {
|
||||
searchAttachments,
|
||||
searchPage,
|
||||
searchShare,
|
||||
searchSuggestions,
|
||||
} from "@/features/search/services/search-service";
|
||||
} from '@/features/search/services/search-service';
|
||||
import {
|
||||
IAttachmentSearch,
|
||||
IPageSearch,
|
||||
IPageSearchParams,
|
||||
ISuggestionResult,
|
||||
SearchSuggestionParams,
|
||||
} from "@/features/search/types/search.types";
|
||||
} from '@/features/search/types/search.types';
|
||||
|
||||
export function usePageSearchQuery(
|
||||
params: IPageSearchParams,
|
||||
@ -41,3 +43,13 @@ export function useShareSearchQuery(
|
||||
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 {
|
||||
IAttachmentSearch,
|
||||
IPageSearch,
|
||||
IPageSearchParams,
|
||||
ISuggestionResult,
|
||||
SearchSuggestionParams,
|
||||
} from "@/features/search/types/search.types";
|
||||
} from '@/features/search/types/search.types';
|
||||
|
||||
export async function searchPage(
|
||||
params: IPageSearchParams,
|
||||
@ -26,3 +27,10 @@ export async function searchShare(
|
||||
const req = await api.post<IPageSearch[]>("/search/share-search", params);
|
||||
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;
|
||||
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 {
|
||||
ActionIcon,
|
||||
Affix,
|
||||
AppShell,
|
||||
Button,
|
||||
Group,
|
||||
ScrollArea,
|
||||
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 { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { ThemeToggle } from "@/components/theme-toggle.tsx";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import { sharedPageTreeAtom, sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
|
||||
import { buildSharedPageTree } from "@/features/share/utils";
|
||||
import {
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
@ -34,7 +34,7 @@ import {
|
||||
SearchControl,
|
||||
SearchMobileControl,
|
||||
} 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 ShareBranding from '@/features/share/components/share-branding.tsx';
|
||||
|
||||
@ -60,6 +60,22 @@ export default function ShareShell({
|
||||
const { data } = useGetSharedPageTreeQuery(shareId);
|
||||
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 (
|
||||
<AppShell
|
||||
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 React from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import { Link, useLocation, useParams } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
@ -195,8 +194,6 @@ export function SpaceSidebar() {
|
||||
onClose={closeSettings}
|
||||
spaceId={space?.slug}
|
||||
/>
|
||||
|
||||
<SearchSpotlight spaceId={space.id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,16 +1,41 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
|
||||
import { ICurrentUser } from "@/features/user/types/user.types";
|
||||
import { focusAtom } from "jotai-optics";
|
||||
import { ICurrentUser, IUser } from "@/features/user/types/user.types";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types";
|
||||
|
||||
export const currentUserAtom = atomWithStorage<ICurrentUser | null>(
|
||||
"currentUser",
|
||||
null,
|
||||
);
|
||||
|
||||
export const userAtom = focusAtom(currentUserAtom, (optic) =>
|
||||
optic.prop("user"),
|
||||
export const userAtom = atom(
|
||||
(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 [user, setUser] = useAtom(userAtom);
|
||||
const [language, setLanguage] = useState(
|
||||
user?.locale === "en" ? "en-US" : user.locale,
|
||||
user?.locale === "en" ? "en-US" : user?.locale,
|
||||
);
|
||||
|
||||
const handleChange = async (value: string) => {
|
||||
|
||||
@ -20,6 +20,7 @@ export interface IUser {
|
||||
deletedAt: Date;
|
||||
fullPageWidth: boolean; // used for update
|
||||
pageEditMode: string; // used for update
|
||||
hasGeneratedPassword?: boolean;
|
||||
}
|
||||
|
||||
export interface ICurrentUser {
|
||||
|
||||
@ -5,7 +5,8 @@ import { useState } from "react";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||
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 useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const APP_ROUTE = {
|
||||
HOME: "/home",
|
||||
SPACES: "/spaces",
|
||||
SEARCH: "/search",
|
||||
AUTH: {
|
||||
LOGIN: "/login",
|
||||
SIGNUP: "/signup",
|
||||
|
||||
@ -52,6 +52,7 @@ export default function SharedPage() {
|
||||
key={data.page.id}
|
||||
title={data.page.title}
|
||||
content={data.page.content}
|
||||
pageId={data.page.id}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
|
||||
@ -67,6 +67,8 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"kysely": "^0.28.2",
|
||||
"kysely-migration-cli": "^0.4.2",
|
||||
"ldapts": "^7.4.0",
|
||||
"mammoth": "^1.10.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "3.3.11",
|
||||
"nestjs-kysely": "^1.2.0",
|
||||
@ -77,6 +79,7 @@
|
||||
"p-limit": "^6.2.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfjs-dist": "^5.4.54",
|
||||
"pg": "^8.16.0",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"pgvector": "^0.2.1",
|
||||
|
||||
@ -32,6 +32,7 @@ import {
|
||||
Excalidraw,
|
||||
Embed,
|
||||
Mention,
|
||||
Subpages,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||
@ -79,6 +80,7 @@ export const tiptapExtensions = [
|
||||
Excalidraw,
|
||||
Embed,
|
||||
Mention,
|
||||
Subpages,
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
|
||||
@ -87,3 +87,12 @@ export function extractBearerTokenFromHeader(
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
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 { AttachmentService } from '../services/attachment.service';
|
||||
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)
|
||||
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(AttachmentProcessor.name);
|
||||
constructor(private readonly attachmentService: AttachmentService) {
|
||||
constructor(
|
||||
private readonly attachmentService: AttachmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@ -25,6 +28,33 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
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) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@ -22,6 +22,9 @@ import { executeTx } from '@docmost/db/utils';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.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()
|
||||
export class AttachmentService {
|
||||
@ -33,6 +36,7 @@ export class AttachmentService {
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
) {}
|
||||
|
||||
async uploadFile(opts: {
|
||||
@ -99,6 +103,23 @@ export class AttachmentService {
|
||||
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) {
|
||||
// delete uploaded file on error
|
||||
this.logger.error(err);
|
||||
@ -367,4 +388,5 @@ export class AttachmentService {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -106,6 +106,7 @@ export class AuthService {
|
||||
await this.userRepo.updateUser(
|
||||
{
|
||||
password: newPasswordHash,
|
||||
hasGeneratedPassword: false,
|
||||
},
|
||||
userId,
|
||||
workspaceId,
|
||||
@ -186,6 +187,7 @@ export class AuthService {
|
||||
await this.userRepo.updateUser(
|
||||
{
|
||||
password: newPasswordHash,
|
||||
hasGeneratedPassword: false,
|
||||
},
|
||||
user.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';
|
||||
|
||||
export class SidebarPageDto extends SpaceIdDto {
|
||||
export class SidebarPageDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pageId: string;
|
||||
|
||||
@ -254,21 +254,28 @@ export class PageController {
|
||||
@Body() pagination: PaginationOptions,
|
||||
@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)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
let pageId = null;
|
||||
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);
|
||||
return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@ -5,15 +5,13 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
|
||||
|
||||
export class SearchDTO {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
query: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ import { Public } from '../../common/decorators/public.decorator';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { hasLicenseOrEE } from '../../common/helpers';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('shares')
|
||||
@ -65,9 +66,11 @@ export class ShareController {
|
||||
|
||||
return {
|
||||
...(await this.shareService.getSharedPage(dto, workspace.id)),
|
||||
hasLicenseKey:
|
||||
Boolean(workspace.licenseKey) ||
|
||||
(this.environmentService.isCloud() && workspace.plan === 'business'),
|
||||
hasLicenseKey: hasLicenseOrEE({
|
||||
licenseKey: workspace.licenseKey,
|
||||
isCloud: this.environmentService.isCloud(),
|
||||
plan: workspace.plan,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -175,9 +178,11 @@ export class ShareController {
|
||||
) {
|
||||
return {
|
||||
...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
|
||||
hasLicenseKey:
|
||||
Boolean(workspace.licenseKey) ||
|
||||
(this.environmentService.isCloud() && workspace.plan === 'business'),
|
||||
hasLicenseKey: hasLicenseOrEE({
|
||||
licenseKey: workspace.licenseKey,
|
||||
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 {
|
||||
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(
|
||||
attachmentId: string,
|
||||
opts?: {
|
||||
@ -22,7 +39,7 @@ export class AttachmentRepo {
|
||||
|
||||
return db
|
||||
.selectFrom('attachments')
|
||||
.selectAll()
|
||||
.select(this.baseFields)
|
||||
.where('id', '=', attachmentId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
@ -36,7 +53,7 @@ export class AttachmentRepo {
|
||||
return db
|
||||
.insertInto('attachments')
|
||||
.values(insertableAttachment)
|
||||
.returningAll()
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@ -50,7 +67,7 @@ export class AttachmentRepo {
|
||||
|
||||
return db
|
||||
.selectFrom('attachments')
|
||||
.selectAll()
|
||||
.select(this.baseFields)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.execute();
|
||||
}
|
||||
@ -64,6 +81,7 @@ export class AttachmentRepo {
|
||||
.updateTable('attachments')
|
||||
.set(updatableAttachment)
|
||||
.where('pageId', 'in', pageIds)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@ -75,7 +93,7 @@ export class AttachmentRepo {
|
||||
.updateTable('attachments')
|
||||
.set(updatableAttachment)
|
||||
.where('id', '=', attachmentId)
|
||||
.returningAll()
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
|
||||
@ -401,6 +401,7 @@ export class PageRepo {
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.where('id', '=', parentPageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
@ -415,7 +416,8 @@ export class PageRepo {
|
||||
'p.workspaceId',
|
||||
])
|
||||
.$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')
|
||||
|
||||
@ -34,6 +34,7 @@ export class UserRepo {
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
'hasGeneratedPassword',
|
||||
];
|
||||
|
||||
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";
|
||||
|
||||
export type AuthProviderType = "google" | "oidc" | "saml";
|
||||
|
||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
@ -39,6 +37,8 @@ export interface Attachments {
|
||||
mimeType: string | null;
|
||||
pageId: string | null;
|
||||
spaceId: string | null;
|
||||
textContent: string | null;
|
||||
tsv: string | null;
|
||||
type: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
@ -62,13 +62,24 @@ export interface AuthProviders {
|
||||
deletedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
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;
|
||||
oidcClientId: string | null;
|
||||
oidcClientSecret: string | null;
|
||||
oidcIssuer: string | null;
|
||||
samlCertificate: string | null;
|
||||
samlUrl: string | null;
|
||||
type: AuthProviderType;
|
||||
type: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
@ -275,6 +286,7 @@ export interface Users {
|
||||
lastActiveAt: Timestamp | null;
|
||||
lastLoginAt: Timestamp | null;
|
||||
locale: string | null;
|
||||
hasGeneratedPassword: Generated<boolean | null>;
|
||||
name: string | null;
|
||||
password: string | null;
|
||||
role: string | null;
|
||||
|
||||
@ -46,7 +46,7 @@ export class ExportController {
|
||||
includeContent: true,
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,8 @@ export enum QueueName {
|
||||
export enum QueueJob {
|
||||
SEND_EMAIL = 'send-email',
|
||||
DELETE_SPACE_ATTACHMENTS = 'delete-space-attachments',
|
||||
ATTACHMENT_INDEX_CONTENT = 'attachment-index-content',
|
||||
ATTACHMENT_INDEXING = 'attachment-indexing',
|
||||
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
|
||||
PAGE_CONTENT_UPDATE = 'page-content-update',
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.0",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@floating-ui/dom": "^1.7.3",
|
||||
"@hocuspocus/extension-redis": "^2.15.2",
|
||||
"@hocuspocus/provider": "^2.15.2",
|
||||
"@hocuspocus/server": "^2.15.2",
|
||||
|
||||
@ -19,3 +19,4 @@ export * from "./lib/mention";
|
||||
export * from "./lib/markdown";
|
||||
export * from "./lib/search-and-replace";
|
||||
export * from "./lib/embed-provider";
|
||||
export * from "./lib/subpages";
|
||||
|
||||
@ -18,6 +18,10 @@ export interface CalloutAttributes {
|
||||
* The type of callout.
|
||||
*/
|
||||
type: CalloutType;
|
||||
/**
|
||||
* The custom icon name for the callout.
|
||||
*/
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
@ -27,6 +31,7 @@ declare module "@tiptap/core" {
|
||||
unsetCallout: () => ReturnType;
|
||||
toggleCallout: (attributes?: CalloutAttributes) => ReturnType;
|
||||
updateCalloutType: (type: CalloutType) => ReturnType;
|
||||
updateCalloutIcon: (icon: string) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -58,6 +63,13 @@ export const Callout = Node.create<CalloutOptions>({
|
||||
"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", {
|
||||
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 "./header";
|
||||
export * from "./table";
|
||||
export * from "./dnd";
|
||||
@ -1,5 +1,6 @@
|
||||
import Table from "@tiptap/extension-table";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { DOMOutputSpec } from "@tiptap/pm/model";
|
||||
|
||||
const LIST_TYPES = ["bulletList", "orderedList", "taskList"];
|
||||
|
||||
@ -17,13 +18,17 @@ function isInList(editor: Editor): boolean {
|
||||
}
|
||||
|
||||
function handleListIndent(editor: Editor): boolean {
|
||||
return editor.commands.sinkListItem("listItem") ||
|
||||
editor.commands.sinkListItem("taskItem");
|
||||
return (
|
||||
editor.commands.sinkListItem("listItem") ||
|
||||
editor.commands.sinkListItem("taskItem")
|
||||
);
|
||||
}
|
||||
|
||||
function handleListOutdent(editor: Editor): boolean {
|
||||
return editor.commands.liftListItem("listItem") ||
|
||||
editor.commands.liftListItem("taskItem");
|
||||
return (
|
||||
editor.commands.liftListItem("listItem") ||
|
||||
editor.commands.liftListItem("taskItem")
|
||||
);
|
||||
}
|
||||
|
||||
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