Merge branch 'main' into ai-vector

This commit is contained in:
Philipinho
2025-09-01 22:02:52 -07:00
107 changed files with 3900 additions and 228 deletions

View File

@ -495,5 +495,10 @@
"Page restored successfully": "Page restored successfully", "Page restored successfully": "Page restored successfully",
"Deleted by": "Deleted by", "Deleted by": "Deleted by",
"Deleted at": "Deleted at", "Deleted at": "Deleted at",
"Preview": "Preview" "Preview": "Preview",
"Subpages": "Subpages",
"Failed to load subpages": "Failed to load subpages",
"No subpages": "No subpages",
"Subpages (Child pages)": "Subpages (Child pages)",
"List all subpages of the current page": "List all subpages of the current page"
} }

View File

@ -14,6 +14,14 @@ import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useTrial from "@/ee/hooks/use-trial.tsx"; import useTrial from "@/ee/hooks/use-trial.tsx";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import {
SearchControl,
SearchMobileControl,
} from "@/features/search/components/search-control.tsx";
import {
searchSpotlight,
shareSearchSpotlight,
} from "@/features/search/constants.ts";
const links = [{ link: APP_ROUTE.HOME, label: "Home" }]; const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
@ -79,6 +87,15 @@ export function AppHeader() {
</Group> </Group>
</Group> </Group>
<div>
<Group visibleFrom="sm">
<SearchControl onClick={searchSpotlight.open} />
</Group>
<Group hiddenFrom="sm">
<SearchMobileControl onSearch={searchSpotlight.open} />
</Group>
</div>
<Group px={"xl"} wrap="nowrap"> <Group px={"xl"} wrap="nowrap">
{isCloud() && isTrial && trialDaysLeft !== 0 && ( {isCloud() && isTrial && trialDaysLeft !== 0 && (
<Badge <Badge

View File

@ -1,16 +1,23 @@
import { UserProvider } from "@/features/user/user-provider.tsx"; import { UserProvider } from "@/features/user/user-provider.tsx";
import { Outlet } from "react-router-dom"; import { Outlet, useParams } from "react-router-dom";
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx"; import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
import { PosthogUser } from "@/ee/components/posthog-user.tsx"; import { PosthogUser } from "@/ee/components/posthog-user.tsx";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
import React from "react";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
export default function Layout() { export default function Layout() {
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
return ( return (
<UserProvider> <UserProvider>
<GlobalAppShell> <GlobalAppShell>
<Outlet /> <Outlet />
</GlobalAppShell> </GlobalAppShell>
{isCloud() && <PosthogUser />} {isCloud() && <PosthogUser />}
<SearchSpotlight spaceId={space?.id} />
</UserProvider> </UserProvider>
); );
} }

View File

@ -15,6 +15,11 @@ export interface EmojiPickerInterface {
icon: ReactNode; icon: ReactNode;
removeEmojiAction: () => void; removeEmojiAction: () => void;
readOnly: boolean; readOnly: boolean;
actionIconProps?: {
size?: string;
variant?: string;
c?: string;
};
} }
function EmojiPicker({ function EmojiPicker({
@ -22,6 +27,7 @@ function EmojiPicker({
icon, icon,
removeEmojiAction, removeEmojiAction,
readOnly, readOnly,
actionIconProps,
}: EmojiPickerInterface) { }: EmojiPickerInterface) {
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, handlers] = useDisclosure(false); const [opened, handlers] = useDisclosure(false);
@ -64,7 +70,12 @@ function EmojiPicker({
closeOnEscape={true} closeOnEscape={true}
> >
<Popover.Target ref={setTarget}> <Popover.Target ref={setTarget}>
<ActionIcon c="gray" variant="transparent" onClick={handlers.toggle}> <ActionIcon
c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"}
size={actionIconProps?.size}
onClick={handlers.toggle}
>
{icon} {icon}
</ActionIcon> </ActionIcon>
</Popover.Target> </Popover.Target>

View 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>
);
}

View File

@ -1,29 +1,62 @@
import { useState } from "react";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts"; import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core"; import { Button, Divider, Stack } from "@mantine/core";
import { IconLock } from "@tabler/icons-react"; import { IconLock, IconServer } from "@tabler/icons-react";
import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts"; import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts"; import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx"; import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
export default function SsoLogin() { export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery(); const { data, isLoading } = useWorkspacePublicDataQuery();
const [ldapModalOpened, setLdapModalOpened] = useState(false);
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
if (!data?.authProviders || data?.authProviders?.length === 0) { if (!data?.authProviders || data?.authProviders?.length === 0) {
return null; return null;
} }
const handleSsoLogin = (provider: IAuthProvider) => { const handleSsoLogin = (provider: IAuthProvider) => {
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({ window.location.href = buildSsoLoginUrl({
providerId: provider.id, providerId: provider.id,
type: provider.type, type: provider.type,
workspaceId: data.id, workspaceId: data.id,
}); });
}
};
const getProviderIcon = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.GOOGLE) {
return <GoogleIcon size={16} />;
} else if (provider.type === SSO_PROVIDER.LDAP) {
return <IconServer size={16} />;
} else {
return <IconLock size={16} />;
}
}; };
return ( return (
<> <>
{selectedLdapProvider && (
<LdapLoginModal
opened={ldapModalOpened}
onClose={() => {
setLdapModalOpened(false);
setSelectedLdapProvider(null);
}}
provider={selectedLdapProvider}
workspaceId={data.id}
/>
)}
{(isCloud() || data.hasLicenseKey) && ( {(isCloud() || data.hasLicenseKey) && (
<> <>
<Stack align="stretch" justify="center" gap="sm"> <Stack align="stretch" justify="center" gap="sm">
@ -31,13 +64,7 @@ export default function SsoLogin() {
<div key={provider.id}> <div key={provider.id}>
<Button <Button
onClick={() => handleSsoLogin(provider)} onClick={() => handleSsoLogin(provider)}
leftSection={ leftSection={getProviderIcon(provider)}
provider.type === SSO_PROVIDER.GOOGLE ? (
<GoogleIcon size={16} />
) : (
<IconLock size={16} />
)
}
variant="default" variant="default"
fullWidth fullWidth
> >

View File

@ -45,6 +45,7 @@ export function MfaBackupCodeInput({
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())} onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
error={error} error={error}
autoFocus autoFocus
data-autofocus
maxLength={8} maxLength={8}
styles={{ styles={{
input: { input: {

View File

@ -25,23 +25,30 @@ import { regenerateBackupCodes } from "@/ee/mfa";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver"; import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod"; import { z } from "zod";
import useCurrentUser from "@/features/user/hooks/use-current-user";
interface MfaBackupCodesModalProps { interface MfaBackupCodesModalProps {
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
} }
const formSchema = z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
});
export function MfaBackupCodesModal({ export function MfaBackupCodesModal({
opened, opened,
onClose, onClose,
}: MfaBackupCodesModalProps) { }: MfaBackupCodesModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: currentUser } = useCurrentUser();
const [backupCodes, setBackupCodes] = useState<string[]>([]); const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [showNewCodes, setShowNewCodes] = useState(false); const [showNewCodes, setShowNewCodes] = useState(false);
const requiresPassword = !currentUser?.user?.hasGeneratedPassword;
const formSchema = requiresPassword
? z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
})
: z.object({
confirmPassword: z.string().optional(),
});
const form = useForm({ const form = useForm({
validate: zodResolver(formSchema), validate: zodResolver(formSchema),
@ -51,7 +58,7 @@ export function MfaBackupCodesModal({
}); });
const regenerateMutation = useMutation({ const regenerateMutation = useMutation({
mutationFn: (data: { confirmPassword: string }) => mutationFn: (data: { confirmPassword?: string }) =>
regenerateBackupCodes(data), regenerateBackupCodes(data),
onSuccess: (data) => { onSuccess: (data) => {
setBackupCodes(data.backupCodes); setBackupCodes(data.backupCodes);
@ -73,8 +80,12 @@ export function MfaBackupCodesModal({
}, },
}); });
const handleRegenerate = (values: { confirmPassword: string }) => { const handleRegenerate = (values: { confirmPassword?: string }) => {
regenerateMutation.mutate(values); // Only send confirmPassword if it's required (non-SSO users)
const payload = requiresPassword
? { confirmPassword: values.confirmPassword }
: {};
regenerateMutation.mutate(payload);
}; };
const handleClose = () => { const handleClose = () => {
@ -114,12 +125,16 @@ export function MfaBackupCodesModal({
)} )}
</Text> </Text>
{requiresPassword && (
<PasswordInput <PasswordInput
label={t("Confirm password")} label={t("Confirm password")}
placeholder={t("Enter your password")} placeholder={t("Enter your password")}
variant="filled" variant="filled"
{...form.getInputProps("confirmPassword")} {...form.getInputProps("confirmPassword")}
autoFocus
data-autofocus
/> />
)}
<Button <Button
type="submit" type="submit"

View File

@ -97,6 +97,7 @@ export function MfaChallenge() {
length={6} length={6}
type="number" type="number"
autoFocus autoFocus
data-autofocus
oneTimeCode oneTimeCode
{...form.getInputProps("code")} {...form.getInputProps("code")}
error={!!form.errors.code} error={!!form.errors.code}

View File

@ -15,6 +15,7 @@ import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { disableMfa } from "@/ee/mfa"; import { disableMfa } from "@/ee/mfa";
import useCurrentUser from "@/features/user/hooks/use-current-user";
interface MfaDisableModalProps { interface MfaDisableModalProps {
opened: boolean; opened: boolean;
@ -22,16 +23,22 @@ interface MfaDisableModalProps {
onComplete: () => void; onComplete: () => void;
} }
const formSchema = z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
});
export function MfaDisableModal({ export function MfaDisableModal({
opened, opened,
onClose, onClose,
onComplete, onComplete,
}: MfaDisableModalProps) { }: MfaDisableModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: currentUser } = useCurrentUser();
const requiresPassword = !currentUser?.user?.hasGeneratedPassword;
const formSchema = requiresPassword
? z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
})
: z.object({
confirmPassword: z.string().optional(),
});
const form = useForm({ const form = useForm({
validate: zodResolver(formSchema), validate: zodResolver(formSchema),
@ -54,8 +61,12 @@ export function MfaDisableModal({
}, },
}); });
const handleSubmit = async (values: { confirmPassword: string }) => { const handleSubmit = async (values: { confirmPassword?: string }) => {
await disableMutation.mutateAsync(values); // Only send confirmPassword if it's required (non-SSO users)
const payload = requiresPassword
? { confirmPassword: values.confirmPassword }
: {};
await disableMutation.mutateAsync(payload);
}; };
const handleClose = () => { const handleClose = () => {
@ -85,6 +96,8 @@ export function MfaDisableModal({
</Text> </Text>
</Alert> </Alert>
{requiresPassword && (
<>
<Text size="sm"> <Text size="sm">
{t( {t(
"Please enter your password to disable two-factor authentication:", "Please enter your password to disable two-factor authentication:",
@ -96,7 +109,10 @@ export function MfaDisableModal({
placeholder={t("Enter your password")} placeholder={t("Enter your password")}
{...form.getInputProps("confirmPassword")} {...form.getInputProps("confirmPassword")}
autoFocus autoFocus
data-autofocus
/> />
</>
)}
<Stack gap="sm"> <Stack gap="sm">
<Button <Button

View File

@ -235,6 +235,7 @@ export function MfaSetupModal({
length={6} length={6}
type="number" type="number"
autoFocus autoFocus
data-autofocus
oneTimeCode oneTimeCode
{...form.getInputProps("verificationCode")} {...form.getInputProps("verificationCode")}
styles={{ styles={{

View File

@ -37,7 +37,7 @@ export async function disableMfa(
} }
export async function regenerateBackupCodes(data: { export async function regenerateBackupCodes(data: {
confirmPassword: string; confirmPassword?: string;
}): Promise<MfaBackupCodesResponse> { }): Promise<MfaBackupCodesResponse> {
const req = await api.post<MfaBackupCodesResponse>( const req = await api.post<MfaBackupCodesResponse>(
"/mfa/generate-backup-codes", "/mfa/generate-backup-codes",

View File

@ -46,7 +46,7 @@ export interface MfaEnableResponse {
} }
export interface MfaDisableRequest { export interface MfaDisableRequest {
confirmPassword: string; confirmPassword?: string;
} }
export interface MfaBackupCodesResponse { export interface MfaBackupCodesResponse {

View File

@ -1,6 +1,7 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import * as z from "zod"; import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form"; import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Text, TagsInput } from "@mantine/core"; import { Button, Text, TagsInput } from "@mantine/core";

View File

@ -1,7 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { Button, Menu, Group } from "@mantine/core"; import { Button, Menu, Group } from "@mantine/core";
import { IconChevronDown, IconLock } from "@tabler/icons-react"; import { IconChevronDown, IconLock, IconServer } from "@tabler/icons-react";
import { useCreateSsoProviderMutation } from "@/ee/security/queries/security-query.ts"; import { useCreateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts"; import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { IAuthProvider } from "@/ee/security/types/security.types.ts";
@ -40,6 +40,19 @@ export default function CreateSsoProvider() {
} }
}; };
const handleCreateLDAP = async () => {
try {
const newProvider = await createSsoProviderMutation.mutateAsync({
type: SSO_PROVIDER.LDAP,
name: "LDAP",
});
setProvider(newProvider);
open();
} catch (error) {
console.error("Failed to create LDAP provider", error);
}
};
return ( return (
<> <>
<SsoProviderModal opened={opened} onClose={close} provider={provider} /> <SsoProviderModal opened={opened} onClose={close} provider={provider} />
@ -71,6 +84,13 @@ export default function CreateSsoProvider() {
> >
OpenID (OIDC) OpenID (OIDC)
</Menu.Item> </Menu.Item>
<Menu.Item
onClick={handleCreateLDAP}
leftSection={<IconServer size={16} />}
>
LDAP / Active Directory
</Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Group> </Group>

View 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>
);
}

View File

@ -16,6 +16,7 @@ const ssoSchema = z.object({
oidcClientSecret: z.string().min(1, "Client secret is required"), oidcClientSecret: z.string().min(1, "Client secret is required"),
isEnabled: z.boolean(), isEnabled: z.boolean(),
allowSignup: z.boolean(), allowSignup: z.boolean(),
groupSync: z.boolean(),
}); });
type SSOFormValues = z.infer<typeof ssoSchema>; type SSOFormValues = z.infer<typeof ssoSchema>;
@ -36,6 +37,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
oidcClientSecret: provider.oidcClientSecret || "", oidcClientSecret: provider.oidcClientSecret || "",
isEnabled: provider.isEnabled, isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup, allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
}, },
validate: zodResolver(ssoSchema), validate: zodResolver(ssoSchema),
}); });
@ -67,6 +69,9 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
if (form.isDirty("allowSignup")) { if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup; ssoData.allowSignup = values.allowSignup;
} }
if (form.isDirty("groupSync")) {
ssoData.groupSync = values.groupSync;
}
await updateSsoProviderMutation.mutateAsync(ssoData); await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty(); form.resetDirty();
@ -110,6 +115,15 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
{...form.getInputProps("oidcClientSecret")} {...form.getInputProps("oidcClientSecret")}
/> />
<Group justify="space-between">
<div>{t("Group sync")}</div>
<Switch
className={classes.switch}
checked={form.values.groupSync}
{...form.getInputProps("groupSync")}
/>
</Group>
<Group justify="space-between"> <Group justify="space-between">
<div>{t("Allow signup")}</div> <div>{t("Allow signup")}</div>
<Switch <Switch

View File

@ -5,6 +5,7 @@ import { SsoSamlForm } from "@/ee/security/components/sso-saml-form.tsx";
import { SSO_PROVIDER } from "@/ee/security/contants.ts"; import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { SsoOIDCForm } from "@/ee/security/components/sso-oidc-form.tsx"; import { SsoOIDCForm } from "@/ee/security/components/sso-oidc-form.tsx";
import { SsoGoogleForm } from "@/ee/security/components/sso-google-form.tsx"; import { SsoGoogleForm } from "@/ee/security/components/sso-google-form.tsx";
import { SsoLDAPForm } from "@/ee/security/components/sso-ldap-form.tsx";
interface SsoModalProps { interface SsoModalProps {
opened: boolean; opened: boolean;
@ -38,6 +39,10 @@ export default function SsoProviderModal({
{provider.type === SSO_PROVIDER.GOOGLE && ( {provider.type === SSO_PROVIDER.GOOGLE && (
<SsoGoogleForm provider={provider} onClose={onClose} /> <SsoGoogleForm provider={provider} onClose={onClose} />
)} )}
{provider.type === SSO_PROVIDER.LDAP && (
<SsoLDAPForm provider={provider} onClose={onClose} />
)}
</Modal> </Modal>
); );
} }

View File

@ -26,6 +26,7 @@ const ssoSchema = z.object({
samlCertificate: z.string().min(1, "SAML Idp Certificate is required"), samlCertificate: z.string().min(1, "SAML Idp Certificate is required"),
isEnabled: z.boolean(), isEnabled: z.boolean(),
allowSignup: z.boolean(), allowSignup: z.boolean(),
groupSync: z.boolean(),
}); });
type SSOFormValues = z.infer<typeof ssoSchema>; type SSOFormValues = z.infer<typeof ssoSchema>;
@ -45,6 +46,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
samlCertificate: provider.samlCertificate || "", samlCertificate: provider.samlCertificate || "",
isEnabled: provider.isEnabled, isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup, allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
}, },
validate: zodResolver(ssoSchema), validate: zodResolver(ssoSchema),
}); });
@ -75,6 +77,9 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
if (form.isDirty("allowSignup")) { if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup; ssoData.allowSignup = values.allowSignup;
} }
if (form.isDirty("groupSync")) {
ssoData.groupSync = values.groupSync;
}
await updateSsoProviderMutation.mutateAsync(ssoData); await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty(); form.resetDirty();
@ -123,6 +128,15 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
{...form.getInputProps("samlCertificate")} {...form.getInputProps("samlCertificate")}
/> />
<Group justify="space-between">
<div>{t("Group sync")}</div>
<Switch
className={classes.switch}
checked={form.values.groupSync}
{...form.getInputProps("groupSync")}
/>
</Group>
<Group justify="space-between"> <Group justify="space-between">
<div>{t("Allow signup")}</div> <div>{t("Allow signup")}</div>
<Switch <Switch

View File

@ -2,4 +2,5 @@ export enum SSO_PROVIDER {
OIDC = 'oidc', OIDC = 'oidc',
SAML = 'saml', SAML = 'saml',
GOOGLE = 'google', GOOGLE = 'google',
LDAP = 'ldap',
} }

View 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;
}

View File

@ -9,8 +9,17 @@ export interface IAuthProvider {
oidcIssuer: string; oidcIssuer: string;
oidcClientId: string; oidcClientId: string;
oidcClientSecret: string; oidcClientSecret: string;
ldapUrl: string;
ldapBindDn: string;
ldapBindPassword: string;
ldapBaseDn: string;
ldapUserSearchFilter: string;
ldapUserAttributes: any;
ldapTlsEnabled: boolean;
ldapTlsCaCert: string;
allowSignup: boolean; allowSignup: boolean;
isEnabled: boolean; isEnabled: boolean;
groupSync: boolean;
creatorId: string; creatorId: string;
workspaceId: string; workspaceId: string;
createdAt: Date; createdAt: Date;

View File

@ -9,18 +9,21 @@ import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip, Divider } from "@mantine/core";
import { import {
IconAlertTriangleFilled, IconAlertTriangleFilled,
IconCircleCheckFilled, IconCircleCheckFilled,
IconCircleXFilled, IconCircleXFilled,
IconInfoCircleFilled, IconInfoCircleFilled,
IconMoodSmile,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { CalloutType } from "@docmost/editor-ext"; import { CalloutType } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
export function CalloutMenu({ editor }: EditorMenuProps) { export function CalloutMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const shouldShow = useCallback( const shouldShow = useCallback(
({ state }: ShouldShowProps) => { ({ state }: ShouldShowProps) => {
if (!state) { if (!state) {
@ -56,6 +59,36 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
[editor], [editor],
); );
const setCalloutIcon = useCallback(
(emoji: any) => {
const emojiChar = emoji?.native || emoji?.emoji || emoji;
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.updateCalloutIcon(emojiChar)
.run();
},
[editor],
);
const removeCalloutIcon = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.updateCalloutIcon("")
.run();
}, [editor]);
const getCurrentIcon = () => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection);
const icon = parent?.node.attrs.icon;
return icon || null;
};
const currentIcon = getCurrentIcon();
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
@ -130,6 +163,20 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
<IconCircleXFilled size={18} /> <IconCircleXFilled size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Custom emoji")}>
<EmojiPicker
onEmojiSelect={setCalloutIcon}
removeEmojiAction={removeCalloutIcon}
readOnly={false}
icon={currentIcon || <IconMoodSmile size={18} />}
actionIconProps={{
size: "lg",
variant: "default",
c: undefined
}}
/>
</Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
</BaseBubbleMenu> </BaseBubbleMenu>
); );

View File

@ -11,7 +11,7 @@ import { CalloutType } from "@docmost/editor-ext";
export default function CalloutView(props: NodeViewProps) { export default function CalloutView(props: NodeViewProps) {
const { node } = props; const { node } = props;
const { type } = node.attrs; const { type, icon } = node.attrs;
return ( return (
<NodeViewWrapper> <NodeViewWrapper>
@ -19,7 +19,7 @@ export default function CalloutView(props: NodeViewProps) {
variant="light" variant="light"
title="" title=""
color={getCalloutColor(type)} color={getCalloutColor(type)}
icon={getCalloutIcon(type)} icon={getCalloutIcon(type, icon)}
p="xs" p="xs"
classNames={{ classNames={{
message: classes.message, message: classes.message,
@ -32,7 +32,11 @@ export default function CalloutView(props: NodeViewProps) {
); );
} }
function getCalloutIcon(type: CalloutType) { function getCalloutIcon(type: CalloutType, customIcon?: string) {
if (customIcon && customIcon.trim() !== "") {
return <span style={{ fontSize: '18px' }}>{customIcon}</span>;
}
switch (type) { switch (type) {
case "info": case "info":
return <IconInfoCircleFilled />; return <IconInfoCircleFilled />;

View File

@ -4,11 +4,7 @@ import mermaid from "mermaid";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import classes from "./code-block.module.css"; import classes from "./code-block.module.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useComputedColorScheme } from "@mantine/core";
mermaid.initialize({
startOnLoad: false,
suppressErrorRendering: true,
});
interface MermaidViewProps { interface MermaidViewProps {
props: NodeViewProps; props: NodeViewProps;
@ -16,12 +12,22 @@ interface MermaidViewProps {
export default function MermaidView({ props }: MermaidViewProps) { export default function MermaidView({ props }: MermaidViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const computedColorScheme = useComputedColorScheme();
const { node } = props; const { node } = props;
const [preview, setPreview] = useState<string>(""); const [preview, setPreview] = useState<string>("");
// Update Mermaid config when theme changes.
useEffect(() => {
mermaid.initialize({
startOnLoad: false,
suppressErrorRendering: true,
theme: computedColorScheme === "light" ? "default" : "dark",
});
}, [computedColorScheme]);
// Re-render the diagram whenever the node content or theme changes.
useEffect(() => { useEffect(() => {
const id = `mermaid-${uuidv4()}`; const id = `mermaid-${uuidv4()}`;
if (node.textContent.length > 0) { if (node.textContent.length > 0) {
mermaid mermaid
.render(id, node.textContent) .render(id, node.textContent)
@ -40,7 +46,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
} }
}); });
} }
}, [node.textContent]); }, [node.textContent, computedColorScheme]);
return ( return (
<div <div

View File

@ -17,8 +17,10 @@ import {
IconTable, IconTable,
IconTypography, IconTypography,
IconMenu4, IconMenu4,
IconCalendar, IconAppWindow, IconCalendar,
} from '@tabler/icons-react'; IconAppWindow,
IconSitemap,
} from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
SlashMenuGroupedItemsType, SlashMenuGroupedItemsType,
@ -357,6 +359,15 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run(); .run();
}, },
}, },
{
title: "Subpages (Child pages)",
description: "List all subpages of the current page",
searchTerms: ["subpages", "child", "children", "nested", "hierarchy"],
icon: IconSitemap,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertSubpages().run();
},
},
{ {
title: "Iframe embed", title: "Iframe embed",
description: "Embed any Iframe", description: "Embed any Iframe",

View File

@ -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;

View File

@ -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>
);
}

View File

@ -0,0 +1,9 @@
.container {
margin: 0;
padding-left: 4px;
user-select: none;
a {
border: none !important;
}
}

View File

@ -38,6 +38,8 @@ import {
Embed, Embed,
SearchAndReplace, SearchAndReplace,
Mention, Mention,
Subpages,
TableDndExtension,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@ -57,6 +59,7 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
import DrawioView from "../components/drawio/drawio-view"; import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx"; import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import plaintext from "highlight.js/lib/languages/plaintext"; import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell"; import powershell from "highlight.js/lib/languages/powershell";
import abap from "highlightjs-sap-abap"; import abap from "highlightjs-sap-abap";
@ -168,6 +171,7 @@ export const mainExtensions = [
TableRow, TableRow,
TableCell, TableCell,
TableHeader, TableHeader,
TableDndExtension,
MathInline.configure({ MathInline.configure({
view: MathInlineView, view: MathInlineView,
}), }),
@ -212,6 +216,9 @@ export const mainExtensions = [
Embed.configure({ Embed.configure({
view: EmbedView, view: EmbedView,
}), }),
Subpages.configure({
view: SubpagesView,
}),
MarkdownClipboard.configure({ MarkdownClipboard.configure({
transformPastedText: true, transformPastedText: true,
}), }),

View File

@ -31,6 +31,7 @@ import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx"; import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx"; import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx"; import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import { import {
handleFileDrop, handleFileDrop,
handlePaste, handlePaste,
@ -49,6 +50,7 @@ import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts"; import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from '@/features/search/constants.ts';
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@ -221,6 +223,10 @@ export default function PageEditor({
event.preventDefault(); event.preventDefault();
return true; return true;
} }
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
searchSpotlight.open();
return true;
}
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command"); const slashCommand = document.querySelector("#slash-command");
if (slashCommand) { if (slashCommand) {
@ -375,7 +381,7 @@ export default function PageEditor({
} }
return ( return (
<div style={{ position: "relative" }}> <div className="editor-container" style={{ position: "relative" }}>
<div ref={menuContainerRef}> <div ref={menuContainerRef}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
@ -391,6 +397,7 @@ export default function PageEditor({
<ImageMenu editor={editor} /> <ImageMenu editor={editor} />
<VideoMenu editor={editor} /> <VideoMenu editor={editor} />
<CalloutMenu editor={editor} /> <CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} /> <ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} /> <DrawioMenu editor={editor} />
<LinkMenu editor={editor} appendTo={menuContainerRef} /> <LinkMenu editor={editor} appendTo={menuContainerRef} />

View File

@ -6,21 +6,19 @@ import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading"; import { Heading } from "@tiptap/extension-heading";
import { Text } from "@tiptap/extension-text"; import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai/index"; import { useAtom } from "jotai";
import { import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
pageEditorAtom,
readOnlyEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { Editor } from "@tiptap/core";
interface PageEditorProps { interface PageEditorProps {
title: string; title: string;
content: any; content: any;
pageId?: string;
} }
export default function ReadonlyPageEditor({ export default function ReadonlyPageEditor({
title, title,
content, content,
pageId,
}: PageEditorProps) { }: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
@ -56,6 +54,9 @@ export default function ReadonlyPageEditor({
content={content} content={content}
onCreate={({ editor }) => { onCreate={({ editor }) => {
if (editor) { if (editor) {
if (pageId) {
editor.storage.pageId = pageId;
}
// @ts-ignore // @ts-ignore
setReadOnlyEditor(editor); setReadOnlyEditor(editor);
} }

View File

@ -45,6 +45,10 @@
display: none; display: none;
pointer-events: none; pointer-events: none;
} }
&[data-direction='horizontal'] {
rotate: 90deg;
}
} }
.dark .drag-handle { .dark .drag-handle {

View File

@ -8,6 +8,16 @@
} }
} }
.table-dnd-preview {
padding: 0;
background-color: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(2px);
}
.table-dnd-drop-indicator {
background-color: #adf;
}
.ProseMirror { .ProseMirror {
table { table {
border-collapse: collapse; border-collapse: collapse;
@ -118,3 +128,13 @@
} }
} }
} }
.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
.prosemirror-dropcursor-block {
display: none;
}
.prosemirror-dropcursor-inline {
display: none;
}
}

View File

@ -26,6 +26,7 @@ import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts"; import localEmitter from "@/lib/local-emitter.ts";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts";
export interface TitleEditorProps { export interface TitleEditorProps {
pageId: string; pageId: string;
@ -86,6 +87,20 @@ export function TitleEditor({
content: title, content: title,
immediatelyRender: true, immediatelyRender: true,
shouldRerenderOnTransaction: false, shouldRerenderOnTransaction: false,
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
event.preventDefault();
return true;
}
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
},
},
},
}); });
useEffect(() => { useEffect(() => {

View File

@ -252,6 +252,7 @@ export function useGetSidebarPagesQuery(
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> { ): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["sidebar-pages", data], queryKey: ["sidebar-pages", data],
enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }), queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
initialPageParam: 1, initialPageParam: 1,
getPreviousPageParam: (firstPage) => getPreviousPageParam: (firstPage) =>

View File

@ -60,7 +60,7 @@ export interface ICopyPageToSpace {
} }
export interface SidebarPagesParams { export interface SidebarPagesParams {
spaceId: string; spaceId?: string;
pageId?: string; pageId?: string;
page?: number; // pagination page?: number; // pagination
} }

View File

@ -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>
);
}
}

View File

@ -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));
}
}

View File

@ -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>
);
}

View 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>
</>
);
}

View File

@ -9,6 +9,7 @@ import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib"; import { getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { shareSearchSpotlightStore } from "@/features/search/constants.ts"; import { shareSearchSpotlightStore } from "@/features/search/constants.ts";
import DOMPurify from "dompurify";
interface ShareSearchSpotlightProps { interface ShareSearchSpotlightProps {
shareId?: string; shareId?: string;
@ -47,7 +48,12 @@ export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) {
<Text <Text
opacity={0.6} opacity={0.6}
size="xs" size="xs"
dangerouslySetInnerHTML={{ __html: page.highlight }} dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(page.highlight, {
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
ALLOWED_ATTR: []
}),
}}
/> />
)} )}
</div> </div>

View 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,
});
}

View File

@ -1,15 +1,17 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query"; import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { import {
searchAttachments,
searchPage, searchPage,
searchShare, searchShare,
searchSuggestions, searchSuggestions,
} from "@/features/search/services/search-service"; } from '@/features/search/services/search-service';
import { import {
IAttachmentSearch,
IPageSearch, IPageSearch,
IPageSearchParams, IPageSearchParams,
ISuggestionResult, ISuggestionResult,
SearchSuggestionParams, SearchSuggestionParams,
} from "@/features/search/types/search.types"; } from '@/features/search/types/search.types';
export function usePageSearchQuery( export function usePageSearchQuery(
params: IPageSearchParams, params: IPageSearchParams,
@ -41,3 +43,13 @@ export function useShareSearchQuery(
enabled: !!params.query, enabled: !!params.query,
}); });
} }
export function useAttachmentSearchQuery(
params: IPageSearchParams,
): UseQueryResult<IAttachmentSearch[], Error> {
return useQuery({
queryKey: ["attachment-search", params],
queryFn: () => searchAttachments(params),
enabled: !!params.query,
});
}

View File

@ -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>
</>
);
}

View File

@ -1,10 +1,11 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { import {
IAttachmentSearch,
IPageSearch, IPageSearch,
IPageSearchParams, IPageSearchParams,
ISuggestionResult, ISuggestionResult,
SearchSuggestionParams, SearchSuggestionParams,
} from "@/features/search/types/search.types"; } from '@/features/search/types/search.types';
export async function searchPage( export async function searchPage(
params: IPageSearchParams, params: IPageSearchParams,
@ -26,3 +27,10 @@ export async function searchShare(
const req = await api.post<IPageSearch[]>("/search/share-search", params); const req = await api.post<IPageSearch[]>("/search/share-search", params);
return req.data; return req.data;
} }
export async function searchAttachments(
params: IPageSearchParams,
): Promise<IAttachmentSearch[]> {
const req = await api.post<IAttachmentSearch[]>("/search-attachments", params);
return req.data;
}

View File

@ -37,3 +37,25 @@ export interface IPageSearchParams {
spaceId?: string; spaceId?: string;
shareId?: string; shareId?: string;
} }
export interface IAttachmentSearch {
id: string;
fileName: string;
pageId: string;
creatorId: string;
createdAt: Date;
updatedAt: Date;
rank: string;
highlight: string;
space: {
id: string;
name: string;
slug: string;
icon: string;
};
page: {
id: string;
title: string;
slugId: string;
};
}

View 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);

View File

@ -1,9 +1,7 @@
import React from "react"; import React, { useEffect, useMemo } from "react";
import { import {
ActionIcon, ActionIcon,
Affix,
AppShell, AppShell,
Button,
Group, Group,
ScrollArea, ScrollArea,
Tooltip, Tooltip,
@ -14,8 +12,10 @@ import SharedTree from "@/features/share/components/shared-tree.tsx";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { ThemeToggle } from "@/components/theme-toggle.tsx"; import { ThemeToggle } from "@/components/theme-toggle.tsx";
import { useAtomValue } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { sharedPageTreeAtom, sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
import { buildSharedPageTree } from "@/features/share/utils";
import { import {
desktopSidebarAtom, desktopSidebarAtom,
mobileSidebarAtom, mobileSidebarAtom,
@ -34,7 +34,7 @@ import {
SearchControl, SearchControl,
SearchMobileControl, SearchMobileControl,
} from "@/features/search/components/search-control.tsx"; } from "@/features/search/components/search-control.tsx";
import { ShareSearchSpotlight } from "@/features/search/share-search-spotlight"; import { ShareSearchSpotlight } from "@/features/search/components/share-search-spotlight.tsx";
import { shareSearchSpotlight } from "@/features/search/constants"; import { shareSearchSpotlight } from "@/features/search/constants";
import ShareBranding from '@/features/share/components/share-branding.tsx'; import ShareBranding from '@/features/share/components/share-branding.tsx';
@ -60,6 +60,22 @@ export default function ShareShell({
const { data } = useGetSharedPageTreeQuery(shareId); const { data } = useGetSharedPageTreeQuery(shareId);
const readOnlyEditor = useAtomValue(readOnlyEditorAtom); const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
// @ts-ignore
const setSharedPageTree = useSetAtom(sharedPageTreeAtom);
// @ts-ignore
const setSharedTreeData = useSetAtom(sharedTreeDataAtom);
// Build and set the tree data when it changes
const treeData = useMemo(() => {
if (!data?.pageTree) return null;
return buildSharedPageTree(data.pageTree);
}, [data?.pageTree]);
useEffect(() => {
setSharedPageTree(data || null);
setSharedTreeData(treeData);
}, [data, treeData, setSharedPageTree, setSharedTreeData]);
return ( return (
<AppShell <AppShell
header={{ height: 50 }} header={{ height: 50 }}

View File

@ -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]);
}

View File

@ -19,7 +19,6 @@ import {
import classes from "./space-sidebar.module.css"; import classes from "./space-sidebar.module.css";
import React from "react"; import React from "react";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { Link, useLocation, useParams } from "react-router-dom"; import { Link, useLocation, useParams } from "react-router-dom";
import clsx from "clsx"; import clsx from "clsx";
@ -195,8 +194,6 @@ export function SpaceSidebar() {
onClose={closeSettings} onClose={closeSettings}
spaceId={space?.slug} spaceId={space?.slug}
/> />
<SearchSpotlight spaceId={space.id} />
</> </>
); );
} }

View File

@ -1,16 +1,41 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils"; import { atomWithStorage } from "jotai/utils";
import { ICurrentUser, IUser } from "@/features/user/types/user.types";
import { ICurrentUser } from "@/features/user/types/user.types"; import { IWorkspace } from "@/features/workspace/types/workspace.types";
import { focusAtom } from "jotai-optics";
export const currentUserAtom = atomWithStorage<ICurrentUser | null>( export const currentUserAtom = atomWithStorage<ICurrentUser | null>(
"currentUser", "currentUser",
null, null,
); );
export const userAtom = focusAtom(currentUserAtom, (optic) => export const userAtom = atom(
optic.prop("user"), (get) => {
const currentUser = get(currentUserAtom);
return currentUser?.user ?? null;
},
(get, set, newUser: IUser) => {
const currentUser = get(currentUserAtom);
if (currentUser) {
set(currentUserAtom, {
...currentUser,
user: newUser,
});
}
}
); );
export const workspaceAtom = focusAtom(currentUserAtom, (optic) =>
optic.prop("workspace"), export const workspaceAtom = atom(
(get) => {
const currentUser = get(currentUserAtom);
return currentUser?.workspace ?? null;
},
(get, set, newWorkspace: IWorkspace) => {
const currentUser = get(currentUserAtom);
if (currentUser) {
set(currentUserAtom, {
...currentUser,
workspace: newWorkspace,
});
}
}
); );

View File

@ -25,7 +25,7 @@ function LanguageSwitcher() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [user, setUser] = useAtom(userAtom); const [user, setUser] = useAtom(userAtom);
const [language, setLanguage] = useState( const [language, setLanguage] = useState(
user?.locale === "en" ? "en-US" : user.locale, user?.locale === "en" ? "en-US" : user?.locale,
); );
const handleChange = async (value: string) => { const handleChange = async (value: string) => {

View File

@ -20,6 +20,7 @@ export interface IUser {
deletedAt: Date; deletedAt: Date;
fullPageWidth: boolean; // used for update fullPageWidth: boolean; // used for update
pageEditMode: string; // used for update pageEditMode: string; // used for update
hasGeneratedPassword?: boolean;
} }
export interface ICurrentUser { export interface ICurrentUser {

View File

@ -5,7 +5,8 @@ import { useState } from "react";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts"; import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import { TextInput, Button } from "@mantine/core"; import { TextInput, Button } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form"; import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@ -1,6 +1,7 @@
const APP_ROUTE = { const APP_ROUTE = {
HOME: "/home", HOME: "/home",
SPACES: "/spaces", SPACES: "/spaces",
SEARCH: "/search",
AUTH: { AUTH: {
LOGIN: "/login", LOGIN: "/login",
SIGNUP: "/signup", SIGNUP: "/signup",

View File

@ -52,6 +52,7 @@ export default function SharedPage() {
key={data.page.id} key={data.page.id}
title={data.page.title} title={data.page.title}
content={data.page.content} content={data.page.content}
pageId={data.page.id}
/> />
</Container> </Container>

View File

@ -67,6 +67,8 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kysely": "^0.28.2", "kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
"ldapts": "^7.4.0",
"mammoth": "^1.10.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"nanoid": "3.3.11", "nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0", "nestjs-kysely": "^1.2.0",
@ -77,6 +79,7 @@
"p-limit": "^6.2.0", "p-limit": "^6.2.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdfjs-dist": "^5.4.54",
"pg": "^8.16.0", "pg": "^8.16.0",
"pg-tsquery": "^8.4.2", "pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1", "pgvector": "^0.2.1",

View File

@ -32,6 +32,7 @@ import {
Excalidraw, Excalidraw,
Embed, Embed,
Mention, Mention,
Subpages,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML } from '../common/helpers/prosemirror/html'; import { generateHTML } from '../common/helpers/prosemirror/html';
@ -79,6 +80,7 @@ export const tiptapExtensions = [
Excalidraw, Excalidraw,
Embed, Embed,
Mention, Mention,
Subpages,
] as any; ] as any;
export function jsonToHtml(tiptapJson: any) { export function jsonToHtml(tiptapJson: any) {

View File

@ -87,3 +87,12 @@ export function extractBearerTokenFromHeader(
const [type, token] = request.headers.authorization?.split(' ') ?? []; const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined; return type === 'Bearer' ? token : undefined;
} }
export function hasLicenseOrEE(opts: {
licenseKey: string;
plan: string;
isCloud: boolean;
}): boolean {
const { licenseKey, plan, isCloud } = opts;
return Boolean(licenseKey) || (isCloud && plan === 'business');
}

View File

@ -3,12 +3,15 @@ import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq'; import { Job } from 'bullmq';
import { AttachmentService } from '../services/attachment.service'; import { AttachmentService } from '../services/attachment.service';
import { QueueJob, QueueName } from 'src/integrations/queue/constants'; import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { Space } from '@docmost/db/types/entity.types'; import { ModuleRef } from '@nestjs/core';
@Processor(QueueName.ATTACHMENT_QUEUE) @Processor(QueueName.ATTACHMENT_QUEUE)
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy { export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(AttachmentProcessor.name); private readonly logger = new Logger(AttachmentProcessor.name);
constructor(private readonly attachmentService: AttachmentService) { constructor(
private readonly attachmentService: AttachmentService,
private moduleRef: ModuleRef,
) {
super(); super();
} }
@ -25,6 +28,33 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
job.data.pageId, job.data.pageId,
); );
} }
if (
job.name === QueueJob.ATTACHMENT_INDEX_CONTENT ||
job.name === QueueJob.ATTACHMENT_INDEXING
) {
let AttachmentEeModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
AttachmentEeModule = require('./../../../ee/attachments-ee/attachment-ee.service');
} catch (err) {
this.logger.error(
'Attachment enterprise module requested but EE module not bundled in this build',
);
return;
}
const attachmentEeService = this.moduleRef.get(
AttachmentEeModule.AttachmentEeService,
{ strict: false },
);
if (job.name === QueueJob.ATTACHMENT_INDEX_CONTENT) {
await attachmentEeService.indexAttachment(job.data.attachmentId);
} else if (job.name === QueueJob.ATTACHMENT_INDEXING) {
await attachmentEeService.indexAttachments(
job.data.workspaceId,
);
}
}
} catch (err) { } catch (err) {
throw err; throw err;
} }

View File

@ -22,6 +22,9 @@ import { executeTx } from '@docmost/db/utils';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
@Injectable() @Injectable()
export class AttachmentService { export class AttachmentService {
@ -33,6 +36,7 @@ export class AttachmentService {
private readonly workspaceRepo: WorkspaceRepo, private readonly workspaceRepo: WorkspaceRepo,
private readonly spaceRepo: SpaceRepo, private readonly spaceRepo: SpaceRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {} ) {}
async uploadFile(opts: { async uploadFile(opts: {
@ -99,6 +103,23 @@ export class AttachmentService {
pageId, pageId,
}); });
} }
// Only index PDFs and DOCX files
if (['.pdf', '.docx'].includes(attachment.fileExt.toLowerCase())) {
await this.attachmentQueue.add(
QueueJob.ATTACHMENT_INDEX_CONTENT,
{
attachmentId: attachmentId,
},
{
attempts: 2,
backoff: {
type: 'exponential',
delay: 10000,
},
},
);
}
} catch (err) { } catch (err) {
// delete uploaded file on error // delete uploaded file on error
this.logger.error(err); this.logger.error(err);
@ -367,4 +388,5 @@ export class AttachmentService {
throw err; throw err;
} }
} }
} }

View File

@ -106,6 +106,7 @@ export class AuthService {
await this.userRepo.updateUser( await this.userRepo.updateUser(
{ {
password: newPasswordHash, password: newPasswordHash,
hasGeneratedPassword: false,
}, },
userId, userId,
workspaceId, workspaceId,
@ -186,6 +187,7 @@ export class AuthService {
await this.userRepo.updateUser( await this.userRepo.updateUser(
{ {
password: newPasswordHash, password: newPasswordHash,
hasGeneratedPassword: false,
}, },
user.id, user.id,
workspace.id, workspace.id,

View File

@ -1,7 +1,11 @@
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString, IsUUID } from 'class-validator';
import { SpaceIdDto } from './page.dto'; import { SpaceIdDto } from './page.dto';
export class SidebarPageDto extends SpaceIdDto { export class SidebarPageDto {
@IsOptional()
@IsUUID()
spaceId: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
pageId: string; pageId: string;

View File

@ -254,21 +254,28 @@ export class PageController {
@Body() pagination: PaginationOptions, @Body() pagination: PaginationOptions,
@AuthUser() user: User, @AuthUser() user: User,
) { ) {
const ability = await this.spaceAbility.createForUser(user, dto.spaceId); if (!dto.spaceId && !dto.pageId) {
throw new BadRequestException(
'Either spaceId or pageId must be provided',
);
}
let spaceId = dto.spaceId;
if (dto.pageId) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new ForbiddenException();
}
spaceId = page.spaceId;
}
const ability = await this.spaceAbility.createForUser(user, spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
let pageId = null; return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId);
if (dto.pageId) {
const page = await this.pageRepo.findById(dto.pageId);
if (page.spaceId !== dto.spaceId) {
throw new ForbiddenException();
}
pageId = page.id;
}
return this.pageService.getSidebarPages(dto.spaceId, pagination, pageId);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)

View File

@ -5,15 +5,13 @@ import {
IsOptional, IsOptional,
IsString, IsString,
} from 'class-validator'; } from 'class-validator';
import { PartialType } from '@nestjs/mapped-types';
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
export class SearchDTO { export class SearchDTO {
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
query: string; query: string;
@IsNotEmpty() @IsOptional()
@IsString() @IsString()
spaceId: string; spaceId: string;

View File

@ -31,6 +31,7 @@ import { Public } from '../../common/decorators/public.decorator';
import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { EnvironmentService } from '../../integrations/environment/environment.service'; import { EnvironmentService } from '../../integrations/environment/environment.service';
import { hasLicenseOrEE } from '../../common/helpers';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('shares') @Controller('shares')
@ -65,9 +66,11 @@ export class ShareController {
return { return {
...(await this.shareService.getSharedPage(dto, workspace.id)), ...(await this.shareService.getSharedPage(dto, workspace.id)),
hasLicenseKey: hasLicenseKey: hasLicenseOrEE({
Boolean(workspace.licenseKey) || licenseKey: workspace.licenseKey,
(this.environmentService.isCloud() && workspace.plan === 'business'), isCloud: this.environmentService.isCloud(),
plan: workspace.plan,
}),
}; };
} }
@ -175,9 +178,11 @@ export class ShareController {
) { ) {
return { return {
...(await this.shareService.getShareTree(dto.shareId, workspace.id)), ...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
hasLicenseKey: hasLicenseKey: hasLicenseOrEE({
Boolean(workspace.licenseKey) || licenseKey: workspace.licenseKey,
(this.environmentService.isCloud() && workspace.plan === 'business'), isCloud: this.environmentService.isCloud(),
plan: workspace.plan,
}),
}; };
} }
} }

View File

@ -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();
}

View File

@ -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);
}

View File

@ -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();
}

View File

@ -12,6 +12,23 @@ import {
export class AttachmentRepo { export class AttachmentRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {} constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof Attachment> = [
'id',
'fileName',
'filePath',
'fileSize',
'fileExt',
'mimeType',
'type',
'creatorId',
'pageId',
'spaceId',
'workspaceId',
'createdAt',
'updatedAt',
'deletedAt',
];
async findById( async findById(
attachmentId: string, attachmentId: string,
opts?: { opts?: {
@ -22,7 +39,7 @@ export class AttachmentRepo {
return db return db
.selectFrom('attachments') .selectFrom('attachments')
.selectAll() .select(this.baseFields)
.where('id', '=', attachmentId) .where('id', '=', attachmentId)
.executeTakeFirst(); .executeTakeFirst();
} }
@ -36,7 +53,7 @@ export class AttachmentRepo {
return db return db
.insertInto('attachments') .insertInto('attachments')
.values(insertableAttachment) .values(insertableAttachment)
.returningAll() .returning(this.baseFields)
.executeTakeFirst(); .executeTakeFirst();
} }
@ -50,7 +67,7 @@ export class AttachmentRepo {
return db return db
.selectFrom('attachments') .selectFrom('attachments')
.selectAll() .select(this.baseFields)
.where('spaceId', '=', spaceId) .where('spaceId', '=', spaceId)
.execute(); .execute();
} }
@ -64,6 +81,7 @@ export class AttachmentRepo {
.updateTable('attachments') .updateTable('attachments')
.set(updatableAttachment) .set(updatableAttachment)
.where('pageId', 'in', pageIds) .where('pageId', 'in', pageIds)
.returning(this.baseFields)
.executeTakeFirst(); .executeTakeFirst();
} }
@ -75,7 +93,7 @@ export class AttachmentRepo {
.updateTable('attachments') .updateTable('attachments')
.set(updatableAttachment) .set(updatableAttachment)
.where('id', '=', attachmentId) .where('id', '=', attachmentId)
.returningAll() .returning(this.baseFields)
.executeTakeFirst(); .executeTakeFirst();
} }

View File

@ -401,6 +401,7 @@ export class PageRepo {
]) ])
.$if(opts?.includeContent, (qb) => qb.select('content')) .$if(opts?.includeContent, (qb) => qb.select('content'))
.where('id', '=', parentPageId) .where('id', '=', parentPageId)
.where('deletedAt', 'is', null)
.unionAll((exp) => .unionAll((exp) =>
exp exp
.selectFrom('pages as p') .selectFrom('pages as p')
@ -415,7 +416,8 @@ export class PageRepo {
'p.workspaceId', 'p.workspaceId',
]) ])
.$if(opts?.includeContent, (qb) => qb.select('p.content')) .$if(opts?.includeContent, (qb) => qb.select('p.content'))
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'), .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
.where('p.deletedAt', 'is', null),
), ),
) )
.selectFrom('page_hierarchy') .selectFrom('page_hierarchy')

View File

@ -34,6 +34,7 @@ export class UserRepo {
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
'deletedAt', 'deletedAt',
'hasGeneratedPassword',
]; ];
async findById( async findById(

View File

@ -5,8 +5,6 @@
import type { ColumnType } from "kysely"; import type { ColumnType } from "kysely";
export type AuthProviderType = "google" | "oidc" | "saml";
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U> export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U> ? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>; : ColumnType<T, T | undefined, T>;
@ -39,6 +37,8 @@ export interface Attachments {
mimeType: string | null; mimeType: string | null;
pageId: string | null; pageId: string | null;
spaceId: string | null; spaceId: string | null;
textContent: string | null;
tsv: string | null;
type: string | null; type: string | null;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
workspaceId: string; workspaceId: string;
@ -62,13 +62,24 @@ export interface AuthProviders {
deletedAt: Timestamp | null; deletedAt: Timestamp | null;
id: Generated<string>; id: Generated<string>;
isEnabled: Generated<boolean>; isEnabled: Generated<boolean>;
groupSync: Generated<boolean>;
ldapBaseDn: string | null;
ldapBindDn: string | null;
ldapBindPassword: string | null;
ldapTlsCaCert: string | null;
ldapTlsEnabled: Generated<boolean | null>;
ldapUrl: string | null;
ldapUserAttributes: Json | null;
ldapUserSearchFilter: string | null;
ldapConfig: Json | null;
settings: Json | null;
name: string; name: string;
oidcClientId: string | null; oidcClientId: string | null;
oidcClientSecret: string | null; oidcClientSecret: string | null;
oidcIssuer: string | null; oidcIssuer: string | null;
samlCertificate: string | null; samlCertificate: string | null;
samlUrl: string | null; samlUrl: string | null;
type: AuthProviderType; type: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
workspaceId: string; workspaceId: string;
} }
@ -275,6 +286,7 @@ export interface Users {
lastActiveAt: Timestamp | null; lastActiveAt: Timestamp | null;
lastLoginAt: Timestamp | null; lastLoginAt: Timestamp | null;
locale: string | null; locale: string | null;
hasGeneratedPassword: Generated<boolean | null>;
name: string | null; name: string | null;
password: string | null; password: string | null;
role: string | null; role: string | null;

View File

@ -46,7 +46,7 @@ export class ExportController {
includeContent: true, includeContent: true,
}); });
if (!page) { if (!page || page.deletedAt) {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }

View File

@ -10,6 +10,8 @@ export enum QueueName {
export enum QueueJob { export enum QueueJob {
SEND_EMAIL = 'send-email', SEND_EMAIL = 'send-email',
DELETE_SPACE_ATTACHMENTS = 'delete-space-attachments', DELETE_SPACE_ATTACHMENTS = 'delete-space-attachments',
ATTACHMENT_INDEX_CONTENT = 'attachment-index-content',
ATTACHMENT_INDEXING = 'attachment-indexing',
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments', DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
PAGE_CONTENT_UPDATE = 'page-content-update', PAGE_CONTENT_UPDATE = 'page-content-update',

View File

@ -20,6 +20,7 @@
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "^7.1.0", "@braintree/sanitize-url": "^7.1.0",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "^1.7.3",
"@hocuspocus/extension-redis": "^2.15.2", "@hocuspocus/extension-redis": "^2.15.2",
"@hocuspocus/provider": "^2.15.2", "@hocuspocus/provider": "^2.15.2",
"@hocuspocus/server": "^2.15.2", "@hocuspocus/server": "^2.15.2",

View File

@ -19,3 +19,4 @@ export * from "./lib/mention";
export * from "./lib/markdown"; export * from "./lib/markdown";
export * from "./lib/search-and-replace"; export * from "./lib/search-and-replace";
export * from "./lib/embed-provider"; export * from "./lib/embed-provider";
export * from "./lib/subpages";

View File

@ -18,6 +18,10 @@ export interface CalloutAttributes {
* The type of callout. * The type of callout.
*/ */
type: CalloutType; type: CalloutType;
/**
* The custom icon name for the callout.
*/
icon?: string;
} }
declare module "@tiptap/core" { declare module "@tiptap/core" {
@ -27,6 +31,7 @@ declare module "@tiptap/core" {
unsetCallout: () => ReturnType; unsetCallout: () => ReturnType;
toggleCallout: (attributes?: CalloutAttributes) => ReturnType; toggleCallout: (attributes?: CalloutAttributes) => ReturnType;
updateCalloutType: (type: CalloutType) => ReturnType; updateCalloutType: (type: CalloutType) => ReturnType;
updateCalloutIcon: (icon: string) => ReturnType;
}; };
} }
} }
@ -58,6 +63,13 @@ export const Callout = Node.create<CalloutOptions>({
"data-callout-type": attributes.type, "data-callout-type": attributes.type,
}), }),
}, },
icon: {
default: null,
parseHTML: (element) => element.getAttribute("data-callout-icon"),
renderHTML: (attributes) => ({
"data-callout-icon": attributes.icon,
}),
},
}; };
}, },
@ -107,6 +119,13 @@ export const Callout = Node.create<CalloutOptions>({
commands.updateAttributes("callout", { commands.updateAttributes("callout", {
type: getValidCalloutType(type), type: getValidCalloutType(type),
}), }),
updateCalloutIcon:
(icon: string) =>
({ commands }) =>
commands.updateAttributes("callout", {
icon: icon || null,
}),
}; };
}, },

View File

@ -0,0 +1,2 @@
export { Subpages } from "./subpages";
export type { SubpagesAttributes, SubpagesOptions } from "./subpages";

View 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);
},
});

View File

@ -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;
}
}
}

View 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')
}

View 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]
}
})

View File

@ -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`,
});
})
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1 @@
export * from './dnd-extension'

View File

@ -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`,
})
});
}
}

View File

@ -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,
}
},
}
}

View File

@ -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)
}
})
}
}

View 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 }
}

View File

@ -2,3 +2,4 @@ export * from "./row";
export * from "./cell"; export * from "./cell";
export * from "./header"; export * from "./header";
export * from "./table"; export * from "./table";
export * from "./dnd";

View File

@ -1,5 +1,6 @@
import Table from "@tiptap/extension-table"; import Table from "@tiptap/extension-table";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { DOMOutputSpec } from "@tiptap/pm/model";
const LIST_TYPES = ["bulletList", "orderedList", "taskList"]; const LIST_TYPES = ["bulletList", "orderedList", "taskList"];
@ -17,13 +18,17 @@ function isInList(editor: Editor): boolean {
} }
function handleListIndent(editor: Editor): boolean { function handleListIndent(editor: Editor): boolean {
return editor.commands.sinkListItem("listItem") || return (
editor.commands.sinkListItem("taskItem"); editor.commands.sinkListItem("listItem") ||
editor.commands.sinkListItem("taskItem")
);
} }
function handleListOutdent(editor: Editor): boolean { function handleListOutdent(editor: Editor): boolean {
return editor.commands.liftListItem("listItem") || return (
editor.commands.liftListItem("taskItem"); editor.commands.liftListItem("listItem") ||
editor.commands.liftListItem("taskItem")
);
} }
export const CustomTable = Table.extend({ export const CustomTable = Table.extend({
@ -62,4 +67,15 @@ export const CustomTable = Table.extend({
}, },
}; };
}, },
renderHTML({ node, HTMLAttributes }) {
// https://github.com/ueberdosis/tiptap/issues/4872#issuecomment-2717554498
const originalRender = this.parent?.({ node, HTMLAttributes });
const wrapper: DOMOutputSpec = [
"div",
{ class: "tableWrapper" },
originalRender,
];
return wrapper;
},
}); });

View File

@ -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
}

View File

@ -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
}

View 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 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 }
})
})
}

View 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 }
})
})
}

View File

@ -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 }
}

View File

@ -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 }
}

View 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