mirror of
https://github.com/docmost/docmost.git
synced 2026-08-28 17:27:06 +08:00
Merge branch 'main' into base
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import * as z from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { z } from "zod/v4";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
import { IForgotPassword } from "@/features/auth/types/auth.types";
|
||||
import { Box, Button, Container, Text, TextInput, Title } from "@mantine/core";
|
||||
import classes from "./auth.module.css";
|
||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||
@@ -10,10 +10,10 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { message: "Email is required" })
|
||||
.email({ message: "Invalid email address" }),
|
||||
.email()
|
||||
.min(1, { message: "Email is required" }),
|
||||
});
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function ForgotPasswordForm() {
|
||||
const { t } = useTranslation();
|
||||
@@ -21,14 +21,14 @@ export function ForgotPasswordForm() {
|
||||
const [isTokenSent, setIsTokenSent] = useState<boolean>(false);
|
||||
useRedirectIfAuthenticated();
|
||||
|
||||
const form = useForm<IForgotPassword>({
|
||||
validate: zodResolver(formSchema),
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: IForgotPassword) {
|
||||
async function onSubmit(data: FormValues) {
|
||||
if (await forgotPassword(data)) {
|
||||
setIsTokenSent(true);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import * as z from "zod";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { useForm } from "@mantine/form";
|
||||
import {
|
||||
@@ -11,9 +11,8 @@ import {
|
||||
Box,
|
||||
Stack,
|
||||
} from "@mantine/core";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { IRegister } from "@/features/auth/types/auth.types";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
import classes from "@/features/auth/components/auth.module.css";
|
||||
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||
@@ -40,14 +39,14 @@ export function InviteSignUpForm() {
|
||||
useRedirectIfAuthenticated();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: IRegister) {
|
||||
async function onSubmit(data: FormValues) {
|
||||
const invitationToken = searchParams.get("token");
|
||||
|
||||
await invitationSignup({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as z from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { z } from "zod/v4";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
import { ILogin } from "@/features/auth/types/auth.types";
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
@@ -24,11 +24,11 @@ import React from "react";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { message: "email is required" })
|
||||
.email({ message: "Invalid email address" }),
|
||||
.email()
|
||||
.min(1, { message: "email is required" }),
|
||||
password: z.string().min(1, { message: "Password is required" }),
|
||||
});
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function LoginForm() {
|
||||
const { t } = useTranslation();
|
||||
@@ -41,15 +41,15 @@ export function LoginForm() {
|
||||
error,
|
||||
} = useWorkspacePublicDataQuery();
|
||||
|
||||
const form = useForm<ILogin>({
|
||||
validate: zodResolver(formSchema),
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: ILogin) {
|
||||
async function onSubmit(data: FormValues) {
|
||||
await signIn(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as z from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { z } from "zod/v4";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
import { IPasswordReset } from "@/features/auth/types/auth.types";
|
||||
import { Box, Button, Container, PasswordInput, Title } from "@mantine/core";
|
||||
import classes from "./auth.module.css";
|
||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||
@@ -12,6 +12,7 @@ const formSchema = z.object({
|
||||
.string()
|
||||
.min(8, { message: "Password must contain at least 8 characters" }),
|
||||
});
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
interface PasswordResetFormProps {
|
||||
resetToken?: string;
|
||||
@@ -22,14 +23,14 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
|
||||
const { passwordReset, isLoading } = useAuth();
|
||||
useRedirectIfAuthenticated();
|
||||
|
||||
const form = useForm<IPasswordReset>({
|
||||
validate: zodResolver(formSchema),
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
newPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: IPasswordReset) {
|
||||
async function onSubmit(data: FormValues) {
|
||||
await passwordReset({
|
||||
token: resetToken,
|
||||
newPassword: data.newPassword,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as z from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { z } from "zod/v4";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
Anchor,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
import classes from "@/features/auth/components/auth.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -24,19 +24,19 @@ const formSchema = z.object({
|
||||
workspaceName: z.string().trim().max(50).optional(),
|
||||
name: z.string().min(1).max(50),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { message: "email is required" })
|
||||
.email({ message: "Invalid email address" }),
|
||||
.email()
|
||||
.min(1, { message: "email is required" }),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function SetupWorkspaceForm() {
|
||||
const { t } = useTranslation();
|
||||
const { setupWorkspace, isLoading } = useAuth();
|
||||
// useRedirectIfAuthenticated();
|
||||
|
||||
const form = useForm<ISetupWorkspace>({
|
||||
validate: zodResolver(formSchema),
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
workspaceName: "",
|
||||
name: "",
|
||||
@@ -45,7 +45,7 @@ export function SetupWorkspaceForm() {
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: ISetupWorkspace) {
|
||||
async function onSubmit(data: FormValues) {
|
||||
await setupWorkspace(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
acceptInvitation,
|
||||
createWorkspace,
|
||||
} from "@/features/workspace/services/workspace-service.ts";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
||||
import { RESET } from "jotai/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
@@ -44,11 +44,11 @@ export default function useAuth() {
|
||||
|
||||
// Check if MFA is required
|
||||
if (response?.userHasMfa) {
|
||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search);
|
||||
} else if (response?.requiresMfaSetup) {
|
||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search);
|
||||
} else {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
navigate(getPostLoginRedirect());
|
||||
}
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { getPostLoginRedirect } from "@/lib/app-route.ts";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function useRedirectIfAuthenticated() {
|
||||
@@ -9,7 +9,7 @@ export function useRedirectIfAuthenticated() {
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data?.user) {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
navigate(getPostLoginRedirect());
|
||||
}
|
||||
}, [isLoading, data]);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
|
||||
interface CommentDialogProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
@@ -37,8 +36,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const { isPending } = createCommentMutation;
|
||||
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
@@ -56,6 +53,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
pageId: pageId,
|
||||
content: JSON.stringify(comment),
|
||||
selection: selectedText,
|
||||
type: "inline",
|
||||
};
|
||||
|
||||
const createdComment =
|
||||
@@ -81,10 +79,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
);
|
||||
}, 400);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} finally {
|
||||
setShowCommentPopup(false);
|
||||
setDraftCommentId("");
|
||||
@@ -103,6 +97,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
size="lg"
|
||||
radius="md"
|
||||
w={300}
|
||||
zIndex={180}
|
||||
position={{ bottom: 500, right: 50 }}
|
||||
withCloseButton
|
||||
withBorder
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Group, Text, Box, Badge } from "@mantine/core";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
@@ -18,7 +18,6 @@ import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CommentListItemProps {
|
||||
@@ -45,8 +44,8 @@ function CommentListItem({
|
||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||
const resolveCommentMutation = useResolveCommentMutation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const emit = useQueryEmit();
|
||||
const isCloudEE = useIsCloudEE();
|
||||
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||
|
||||
useEffect(() => {
|
||||
setContent(comment.content);
|
||||
@@ -65,11 +64,6 @@ function CommentListItem({
|
||||
editContentRef.current = null;
|
||||
}
|
||||
setIsEditing(false);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update comment:", error);
|
||||
} finally {
|
||||
@@ -81,11 +75,6 @@ function CommentListItem({
|
||||
try {
|
||||
await deleteCommentMutation.mutateAsync(comment.id);
|
||||
editor?.commands.unsetComment(comment.id);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete comment:", error);
|
||||
}
|
||||
@@ -106,11 +95,6 @@ function CommentListItem({
|
||||
if (editor) {
|
||||
editor.commands.setCommentResolved(comment.id, !isResolved);
|
||||
}
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle resolved state:", error);
|
||||
}
|
||||
@@ -177,7 +161,7 @@ function CommentListItem({
|
||||
|
||||
<Group gap="xs">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
{timeAgo(comment.createdAt)}
|
||||
{createdAtAgo}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import React, { useState, useRef, useCallback, memo, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Divider, Paper, Tabs, Badge, Text, ScrollArea } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
Badge,
|
||||
Text,
|
||||
ScrollArea,
|
||||
} from "@mantine/core";
|
||||
import CommentListItem from "@/features/comment/components/comment-list-item";
|
||||
import {
|
||||
useCommentsQuery,
|
||||
@@ -14,14 +25,8 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react";
|
||||
|
||||
function CommentListWithTabs() {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,21 +36,12 @@ function CommentListWithTabs() {
|
||||
data: comments,
|
||||
isLoading: isCommentsLoading,
|
||||
isError,
|
||||
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
||||
} = useCommentsQuery({ pageId: page?.id });
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const emit = useQueryEmit();
|
||||
const isCloudEE = useIsCloudEE();
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const spaceRules = space?.membership?.permissions;
|
||||
const spaceAbility = useSpaceAbility(spaceRules);
|
||||
|
||||
|
||||
const canComment: boolean = spaceAbility.can(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page
|
||||
);
|
||||
const canComment = page?.permissions?.canEdit ?? false;
|
||||
|
||||
// Separate active and resolved comments
|
||||
const { activeComments, resolvedComments } = useMemo(() => {
|
||||
@@ -54,19 +50,47 @@ function CommentListWithTabs() {
|
||||
}
|
||||
|
||||
const parentComments = comments.items.filter(
|
||||
(comment: IComment) => comment.parentCommentId === null
|
||||
(comment: IComment) => comment.parentCommentId === null,
|
||||
);
|
||||
|
||||
const active = parentComments.filter(
|
||||
(comment: IComment) => !comment.resolvedAt
|
||||
(comment: IComment) => !comment.resolvedAt,
|
||||
);
|
||||
const resolved = parentComments.filter(
|
||||
(comment: IComment) => comment.resolvedAt
|
||||
(comment: IComment) => comment.resolvedAt,
|
||||
);
|
||||
|
||||
return { activeComments: active, resolvedComments: resolved };
|
||||
}, [comments]);
|
||||
|
||||
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
||||
|
||||
const handleAddPageComment = useCallback(
|
||||
async (_commentId: string, content: string) => {
|
||||
try {
|
||||
setIsPageCommentLoading(true);
|
||||
const createdComment = await createCommentMutation.mutateAsync({
|
||||
pageId: page?.id,
|
||||
content: JSON.stringify(content),
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}, 400);
|
||||
} catch (error) {
|
||||
console.error("Failed to post comment:", error);
|
||||
} finally {
|
||||
setIsPageCommentLoading(false);
|
||||
}
|
||||
},
|
||||
[createCommentMutation, page?.id],
|
||||
);
|
||||
|
||||
const handleAddReply = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
try {
|
||||
@@ -78,18 +102,13 @@ function CommentListWithTabs() {
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: page?.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to post comment:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[createCommentMutation, page?.id]
|
||||
[createCommentMutation, page?.id],
|
||||
);
|
||||
|
||||
const renderComments = useCallback(
|
||||
@@ -131,7 +150,7 @@ function CommentListWithTabs() {
|
||||
)}
|
||||
</Paper>
|
||||
),
|
||||
[comments, handleAddReply, isLoading, space?.membership?.role]
|
||||
[comments, handleAddReply, isLoading, space?.membership?.role],
|
||||
);
|
||||
|
||||
if (isCommentsLoading) {
|
||||
@@ -144,63 +163,32 @@ function CommentListWithTabs() {
|
||||
|
||||
const totalComments = activeComments.length + resolvedComments.length;
|
||||
|
||||
// If not cloud/enterprise, show simple list without tabs
|
||||
if (!isCloudEE) {
|
||||
if (totalComments === 0) {
|
||||
return <>{t("No comments yet.")}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea style={{ height: "85vh" }} scrollbarSize={5} type="scroll">
|
||||
<div style={{ paddingBottom: "200px" }}>
|
||||
{comments?.items
|
||||
.filter((comment: IComment) => comment.parentCommentId === null)
|
||||
.map((comment) => (
|
||||
<Paper
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
p="sm"
|
||||
mb="sm"
|
||||
withBorder
|
||||
key={comment.id}
|
||||
data-comment-id={comment.id}
|
||||
>
|
||||
<div>
|
||||
<CommentListItem
|
||||
comment={comment}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
parentId={comment.id}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canComment && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
const pageCommentInput = canComment ? (
|
||||
<PageCommentInput
|
||||
onSave={handleAddPageComment}
|
||||
isLoading={isPageCommentLoading}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
|
||||
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
defaultValue="open"
|
||||
variant="default"
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Tabs.List justify="center">
|
||||
<Tabs.Tab
|
||||
value="open"
|
||||
@@ -225,16 +213,25 @@ function CommentListWithTabs() {
|
||||
</Tabs.List>
|
||||
|
||||
<ScrollArea
|
||||
style={{ flex: "1 1 auto", height: "calc(85vh - 60px)" }}
|
||||
style={{ flex: "1 1 auto" }}
|
||||
scrollbarSize={5}
|
||||
type="scroll"
|
||||
>
|
||||
<div style={{ paddingBottom: "200px" }}>
|
||||
<div style={{ paddingBottom: "8px" }}>
|
||||
<Tabs.Panel value="open" pt="xs">
|
||||
{activeComments.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{t("No open comments.")}
|
||||
</Text>
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconMessageOff
|
||||
size={32}
|
||||
stroke={1.5}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("No open comments.")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
activeComments.map(renderComments)
|
||||
)}
|
||||
@@ -242,9 +239,18 @@ function CommentListWithTabs() {
|
||||
|
||||
<Tabs.Panel value="resolved" pt="xs">
|
||||
{resolvedComments.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{t("No resolved comments.")}
|
||||
</Text>
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconMessageOff
|
||||
size={32}
|
||||
stroke={1.5}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("No resolved comments.")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
resolvedComments.map(renderComments)
|
||||
)}
|
||||
@@ -252,6 +258,7 @@ function CommentListWithTabs() {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
{pageCommentInput}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -273,9 +280,9 @@ const ChildComments = ({
|
||||
const getChildComments = useCallback(
|
||||
(parentId: string) =>
|
||||
comments.items.filter(
|
||||
(comment: IComment) => comment.parentCommentId === parentId
|
||||
(comment: IComment) => comment.parentCommentId === parentId,
|
||||
),
|
||||
[comments.items]
|
||||
[comments.items],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -303,7 +310,12 @@ const ChildComments = ({
|
||||
|
||||
const MemoizedChildComments = memo(ChildComments);
|
||||
|
||||
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
||||
const CommentEditorWithActions = ({
|
||||
commentId,
|
||||
onSave,
|
||||
isLoading,
|
||||
placeholder = undefined,
|
||||
}) => {
|
||||
const [content, setContent] = useState("");
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const commentEditorRef = useRef(null);
|
||||
@@ -321,10 +333,57 @@ const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
||||
onUpdate={setContent}
|
||||
onSave={handleSave}
|
||||
editable={true}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageCommentInput = ({ onSave, isLoading }) => {
|
||||
const { t } = useTranslation();
|
||||
const [content, setContent] = useState("");
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const commentEditorRef = useRef(null);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(null, content);
|
||||
setContent("");
|
||||
commentEditorRef.current?.clearContent();
|
||||
}, [content, onSave]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
flex: "0 0 auto",
|
||||
borderTop: "1px solid var(--mantine-color-default-border)",
|
||||
paddingTop: "var(--mantine-spacing-sm)",
|
||||
paddingBottom: 25,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<CommentEditor
|
||||
ref={commentEditorRef}
|
||||
onUpdate={setContent}
|
||||
onSave={handleSave}
|
||||
editable={true}
|
||||
placeholder={t("Add a comment...")}
|
||||
/>
|
||||
{focused && (
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
loading={isLoading}
|
||||
style={{ position: "absolute", right: 8, bottom: 30 }}
|
||||
>
|
||||
<IconArrowUp size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentListWithTabs;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createComment,
|
||||
@@ -17,17 +17,40 @@ import {
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
||||
|
||||
export function useCommentsQuery(
|
||||
params: ICommentParams,
|
||||
): UseQueryResult<IPagination<IComment>, Error> {
|
||||
return useQuery({
|
||||
export function useCommentsQuery(params: ICommentParams) {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: RQ_KEY(params.pageId),
|
||||
queryFn: () => getPageComments(params),
|
||||
queryFn: ({ pageParam }) =>
|
||||
getPageComments({ pageId: params.pageId, cursor: pageParam, limit: 100 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
enabled: !!params.pageId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.hasNextPage && !query.isFetchingNextPage) {
|
||||
query.fetchNextPage();
|
||||
}
|
||||
}, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
|
||||
|
||||
const data = useMemo<IPagination<IComment> | undefined>(() => {
|
||||
if (!query.data) return undefined;
|
||||
return {
|
||||
items: query.data.pages.flatMap((p) => p.items),
|
||||
meta: query.data.pages[query.data.pages.length - 1].meta,
|
||||
};
|
||||
}, [query.data]);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading: query.isLoading || query.hasNextPage,
|
||||
isError: query.isError,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreateCommentMutation() {
|
||||
@@ -36,18 +59,26 @@ export function useCreateCommentMutation() {
|
||||
|
||||
return useMutation<IComment, Error, Partial<IComment>>({
|
||||
mutationFn: (data) => createComment(data),
|
||||
onSuccess: (data) => {
|
||||
//const newComment = data;
|
||||
// let comments = queryClient.getQueryData(RQ_KEY(data.pageId));
|
||||
// if (comments) {
|
||||
//comments = prevComments => [...prevComments, newComment];
|
||||
//queryClient.setQueryData(RQ_KEY(data.pageId), comments);
|
||||
//}
|
||||
onSuccess: (newComment) => {
|
||||
const cache = queryClient.getQueryData(
|
||||
RQ_KEY(newComment.pageId),
|
||||
) as InfiniteData<IPagination<IComment>> | undefined;
|
||||
|
||||
if (cache && cache.pages.length > 0) {
|
||||
const lastIdx = cache.pages.length - 1;
|
||||
queryClient.setQueryData(RQ_KEY(newComment.pageId), {
|
||||
...cache,
|
||||
pages: cache.pages.map((page, i) =>
|
||||
i === lastIdx
|
||||
? { ...page, items: [...page.items, newComment] }
|
||||
: page,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) });
|
||||
notifications.show({ message: t("Comment created successfully") });
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Error creating comment"),
|
||||
color: "red",
|
||||
@@ -57,14 +88,31 @@ export function useCreateCommentMutation() {
|
||||
}
|
||||
|
||||
export function useUpdateCommentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IComment, Error, Partial<IComment>>({
|
||||
mutationFn: (data) => updateComment(data),
|
||||
onSuccess: (data) => {
|
||||
onSuccess: (updatedComment) => {
|
||||
const cache = queryClient.getQueryData(
|
||||
RQ_KEY(updatedComment.pageId),
|
||||
) as InfiniteData<IPagination<IComment>> | undefined;
|
||||
|
||||
if (cache) {
|
||||
queryClient.setQueryData(RQ_KEY(updatedComment.pageId), {
|
||||
...cache,
|
||||
pages: cache.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((comment) =>
|
||||
comment.id === updatedComment.id ? updatedComment : comment,
|
||||
),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
notifications.show({ message: t("Comment updated successfully") });
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to update comment"),
|
||||
color: "red",
|
||||
@@ -79,25 +127,24 @@ export function useDeleteCommentMutation(pageId?: string) {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (commentId: string) => deleteComment(commentId),
|
||||
onSuccess: (data, variables) => {
|
||||
const comments = queryClient.getQueryData(
|
||||
onSuccess: (_data, commentId) => {
|
||||
const cache = queryClient.getQueryData(
|
||||
RQ_KEY(pageId),
|
||||
) as IPagination<IComment>;
|
||||
) as InfiniteData<IPagination<IComment>> | undefined;
|
||||
|
||||
if (comments && comments.items) {
|
||||
const commentId = variables;
|
||||
const newComments = comments.items.filter(
|
||||
(comment) => comment.id !== commentId,
|
||||
);
|
||||
if (cache) {
|
||||
queryClient.setQueryData(RQ_KEY(pageId), {
|
||||
...comments,
|
||||
items: newComments,
|
||||
...cache,
|
||||
pages: cache.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((comment) => comment.id !== commentId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
notifications.show({ message: t("Comment deleted successfully") });
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to delete comment"),
|
||||
color: "red",
|
||||
|
||||
@@ -164,7 +164,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
return (
|
||||
<BubbleMenu
|
||||
{...bubbleMenuProps}
|
||||
style={{ zIndex: 200, position: "relative" }}
|
||||
style={{ zIndex: 199, position: "relative" }}
|
||||
>
|
||||
<div className={classes.bubbleMenu}>
|
||||
{isGenerativeAiEnabled && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Dispatch, FC, SetStateAction, useCallback } from "react";
|
||||
import { IconLink } from "@tabler/icons-react";
|
||||
import { ActionIcon, Popover, Tooltip } from "@mantine/core";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { TextSelection } from "@tiptap/pm/state";
|
||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -20,7 +21,15 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
|
||||
const onLink = useCallback(
|
||||
(url: string) => {
|
||||
setIsOpen(false);
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setLink({ href: url })
|
||||
.command(({ tr }) => {
|
||||
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
},
|
||||
[editor, setIsOpen],
|
||||
);
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import React, { useCallback } from "react";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
IconAlertTriangleFilled,
|
||||
IconCircleCheckFilled,
|
||||
IconCircleXFilled,
|
||||
IconInfoCircleFilled,
|
||||
IconMoodSmile,
|
||||
IconNotes,
|
||||
} from "@tabler/icons-react";
|
||||
import { CalloutType } from "@docmost/editor-ext";
|
||||
import { CalloutType, isTextSelected } from "@docmost/editor-ext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,6 +29,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
if (isTextSelected(editor)) return false;
|
||||
|
||||
return editor.isActive("callout");
|
||||
},
|
||||
@@ -42,6 +46,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
return {
|
||||
isCallout: ctx.editor.isActive("callout"),
|
||||
isInfo: ctx.editor.isActive("callout", { type: "info" }),
|
||||
isNote: ctx.editor.isActive("callout", { type: "note" }),
|
||||
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
|
||||
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
|
||||
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
|
||||
@@ -126,48 +131,73 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group className="actionIconGroup">
|
||||
<Tooltip position="top" label={t("Info")}>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Info")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() => setCalloutType("info")}
|
||||
size="lg"
|
||||
aria-label={t("Info")}
|
||||
variant={editorState?.isInfo ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isInfo })}
|
||||
>
|
||||
<IconInfoCircleFilled size={18} />
|
||||
<IconInfoCircleFilled
|
||||
size={18}
|
||||
color="var(--mantine-color-blue-5)"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Success")}>
|
||||
<Tooltip position="top" label={t("Note")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() => setCalloutType("note")}
|
||||
size="lg"
|
||||
aria-label={t("Note")}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isNote })}
|
||||
>
|
||||
<IconNotes size={18} color="var(--mantine-color-grape-5)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Success")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() => setCalloutType("success")}
|
||||
size="lg"
|
||||
aria-label={t("Success")}
|
||||
variant={editorState?.isSuccess ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isSuccess })}
|
||||
>
|
||||
<IconCircleCheckFilled size={18} />
|
||||
<IconCircleCheckFilled
|
||||
size={18}
|
||||
color="var(--mantine-color-green-5)"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Warning")}>
|
||||
<Tooltip position="top" label={t("Warning")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() => setCalloutType("warning")}
|
||||
size="lg"
|
||||
aria-label={t("Warning")}
|
||||
variant={editorState?.isWarning ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isWarning })}
|
||||
>
|
||||
<IconAlertTriangleFilled size={18} />
|
||||
<IconAlertTriangleFilled
|
||||
size={18}
|
||||
color="var(--mantine-color-orange-5)"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Danger")}>
|
||||
<Tooltip position="top" label={t("Danger")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() => setCalloutType("danger")}
|
||||
size="lg"
|
||||
aria-label={t("Danger")}
|
||||
variant={editorState?.isDanger ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isDanger })}
|
||||
>
|
||||
<IconCircleXFilled size={18} />
|
||||
<IconCircleXFilled size={18} color="var(--mantine-color-red-5)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
@@ -178,11 +208,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
icon={currentIcon || <IconMoodSmile size={18} />}
|
||||
actionIconProps={{
|
||||
size: "lg",
|
||||
variant: "default",
|
||||
c: undefined,
|
||||
variant: "subtle",
|
||||
}}
|
||||
/>
|
||||
</ActionIcon.Group>
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IconCircleCheckFilled,
|
||||
IconCircleXFilled,
|
||||
IconInfoCircleFilled,
|
||||
IconNotes,
|
||||
} from "@tabler/icons-react";
|
||||
import { Alert } from "@mantine/core";
|
||||
import classes from "./callout.module.css";
|
||||
@@ -22,6 +23,7 @@ export default function CalloutView(props: NodeViewProps) {
|
||||
icon={getCalloutIcon(type, icon)}
|
||||
p="xs"
|
||||
classNames={{
|
||||
root: classes.root,
|
||||
message: classes.message,
|
||||
icon: classes.icon,
|
||||
}}
|
||||
@@ -34,12 +36,14 @@ export default function CalloutView(props: NodeViewProps) {
|
||||
|
||||
function getCalloutIcon(type: CalloutType, customIcon?: string) {
|
||||
if (customIcon && customIcon.trim() !== "") {
|
||||
return <span style={{ fontSize: '18px' }}>{customIcon}</span>;
|
||||
return <span style={{ fontSize: "18px" }}>{customIcon}</span>;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "info":
|
||||
return <IconInfoCircleFilled />;
|
||||
case "note":
|
||||
return <IconNotes />;
|
||||
case "success":
|
||||
return <IconCircleCheckFilled />;
|
||||
case "warning":
|
||||
@@ -55,6 +59,8 @@ function getCalloutColor(type: CalloutType) {
|
||||
switch (type) {
|
||||
case "info":
|
||||
return "blue";
|
||||
case "note":
|
||||
return "grape";
|
||||
case "success":
|
||||
return "green";
|
||||
case "warning":
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
.root {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-inline-end: var(--mantine-spacing-md);
|
||||
margin-inline-end: var(--mantine-spacing-xs);
|
||||
margin-top: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -11,18 +15,8 @@
|
||||
.message {
|
||||
font-size: var(--mantine-font-size-md);
|
||||
color: var(--mantine-color-default-color);
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/*
|
||||
@mixin where-light {
|
||||
color: var(--mantine-color-default-color);
|
||||
}
|
||||
|
||||
@mixin where-dark {
|
||||
color: var(--mantine-color-default-color);
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { DOMSerializer, Node as PMNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { ActionIcon, Tooltip, Popover, Button } from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconCheck,
|
||||
IconColumns2,
|
||||
IconColumns3,
|
||||
IconLayoutSidebar,
|
||||
IconLayoutSidebarRight,
|
||||
IconLayoutAlignCenter,
|
||||
IconCopy,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { isTextSelected } from "@docmost/editor-ext";
|
||||
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
type LayoutPreset = {
|
||||
layout: ColumnsLayout;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
};
|
||||
|
||||
const twoColumnPresets: LayoutPreset[] = [
|
||||
{ layout: "two_equal", label: "Equal columns", icon: IconColumns2 },
|
||||
{
|
||||
layout: "two_left_sidebar",
|
||||
label: "Left sidebar",
|
||||
icon: IconLayoutSidebar,
|
||||
},
|
||||
{
|
||||
layout: "two_right_sidebar",
|
||||
label: "Right sidebar",
|
||||
icon: IconLayoutSidebarRight,
|
||||
},
|
||||
];
|
||||
|
||||
const threeColumnPresets: LayoutPreset[] = [
|
||||
{ layout: "three_equal", label: "Equal columns", icon: IconColumns3 },
|
||||
{
|
||||
layout: "three_with_sidebars",
|
||||
label: "Wide center",
|
||||
icon: IconLayoutAlignCenter,
|
||||
},
|
||||
{
|
||||
layout: "three_left_wide",
|
||||
label: "Left wide",
|
||||
icon: IconLayoutSidebarRight,
|
||||
},
|
||||
{ layout: "three_right_wide", label: "Right wide", icon: IconLayoutSidebar },
|
||||
];
|
||||
|
||||
function getPresetsForCount(count: number): LayoutPreset[] {
|
||||
if (count === 2) return twoColumnPresets;
|
||||
if (count === 3) return threeColumnPresets;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function ColumnsMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isCountOpen, setIsCountOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const nodesWithMenus = [
|
||||
"callout",
|
||||
"image",
|
||||
"video",
|
||||
"drawio",
|
||||
"excalidraw",
|
||||
"table",
|
||||
];
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) return false;
|
||||
if (!editor.isActive("columns")) return false;
|
||||
if (isTextSelected(editor)) return false;
|
||||
if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
|
||||
|
||||
const parent = findParentNode(
|
||||
(node: PMNode) => node.type.name === "columns",
|
||||
)(state.selection);
|
||||
if (!parent) return false;
|
||||
|
||||
const dom = editor.view.nodeDOM(parent.pos) as HTMLElement;
|
||||
if (!dom) return false;
|
||||
|
||||
const rect = dom.getBoundingClientRect();
|
||||
return rect.bottom > 0 && rect.top < window.innerHeight;
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) return null;
|
||||
|
||||
const { selection } = ctx.editor.state;
|
||||
const parent = findParentNode(
|
||||
(node: PMNode) => node.type.name === "columns",
|
||||
)(selection);
|
||||
|
||||
return {
|
||||
columnCount: parent?.node.childCount || 2,
|
||||
layout: (parent?.node.attrs.layout as ColumnsLayout) || "two_equal",
|
||||
isNormal: ctx.editor.isActive("columns", { widthMode: "normal" }),
|
||||
isWide: ctx.editor.isActive("columns", { widthMode: "wide" }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "columns";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
|
||||
// Columns entirely out of viewport — return real rect so menu goes off-screen
|
||||
if (domRect.bottom <= 0 || domRect.top >= window.innerHeight) {
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}
|
||||
|
||||
// Clamp bottom so menu stays within viewport when columns extend below it
|
||||
// 55px = 15px offset + ~40px menu height
|
||||
const maxBottom = window.innerHeight - 55;
|
||||
if (domRect.bottom > maxBottom) {
|
||||
const clamped = new DOMRect(
|
||||
domRect.x,
|
||||
domRect.y,
|
||||
domRect.width,
|
||||
maxBottom - domRect.y,
|
||||
);
|
||||
return {
|
||||
getBoundingClientRect: () => clamped,
|
||||
getClientRects: () => [clamped],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}
|
||||
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const setColumnCount = useCallback(
|
||||
(count: number) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setColumnCount(count)
|
||||
.run();
|
||||
setIsCountOpen(false);
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const setLayout = useCallback(
|
||||
(layout: ColumnsLayout) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setColumnsLayout(layout)
|
||||
.run();
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
const { state } = editor;
|
||||
const parent = findParentNode(
|
||||
(node: PMNode) => node.type.name === "columns",
|
||||
)(state.selection);
|
||||
if (!parent) return;
|
||||
|
||||
const serializer = DOMSerializer.fromSchema(state.schema);
|
||||
const dom = serializer.serializeNode(parent.node);
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.appendChild(dom);
|
||||
|
||||
const onSuccess = () => {
|
||||
clearTimeout(copyTimerRef.current);
|
||||
setCopied(true);
|
||||
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
if (navigator.clipboard?.write) {
|
||||
navigator.clipboard
|
||||
.write([
|
||||
new ClipboardItem({
|
||||
"text/html": new Blob([wrapper.innerHTML], { type: "text/html" }),
|
||||
"text/plain": new Blob([parent.node.textContent], {
|
||||
type: "text/plain",
|
||||
}),
|
||||
}),
|
||||
])
|
||||
.then(onSuccess)
|
||||
.catch(execCommandFallback);
|
||||
} else {
|
||||
execCommandFallback();
|
||||
}
|
||||
|
||||
function execCommandFallback() {
|
||||
wrapper.style.position = "fixed";
|
||||
wrapper.style.left = "-9999px";
|
||||
document.body.appendChild(wrapper);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(wrapper);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
document.execCommand("copy");
|
||||
sel?.removeAllRanges();
|
||||
document.body.removeChild(wrapper);
|
||||
editor.view.focus();
|
||||
onSuccess();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
const parent = findParentNode(
|
||||
(node: PMNode) => node.type.name === "columns",
|
||||
)(editor.state.selection);
|
||||
if (!parent) return;
|
||||
editor.chain().focus().setNodeSelection(parent.pos).deleteSelection().run();
|
||||
}, [editor]);
|
||||
|
||||
const columnCount = editorState?.columnCount || 2;
|
||||
const currentLayout = editorState?.layout || "two_equal";
|
||||
const presets = getPresetsForCount(columnCount);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey="columns-menu"
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "bottom",
|
||||
offset: {
|
||||
mainAxis: 5,
|
||||
},
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
<Popover opened={isCountOpen} onChange={setIsCountOpen} withArrow>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="compact-sm"
|
||||
rightSection={<IconChevronDown size={12} />}
|
||||
onClick={() => setIsCountOpen(!isCountOpen)}
|
||||
aria-label={t("Column count")}
|
||||
>
|
||||
{t("{{count}} Columns", { count: columnCount })}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
<Button.Group orientation="vertical">
|
||||
{[2, 3, 4, 5].map((n) => (
|
||||
<Button
|
||||
key={n}
|
||||
variant={n === columnCount ? "light" : "subtle"}
|
||||
color={n === columnCount ? "blue" : "dark"}
|
||||
justify="space-between"
|
||||
fullWidth
|
||||
rightSection={
|
||||
n === columnCount ? <IconCheck size={14} /> : null
|
||||
}
|
||||
onClick={() => setColumnCount(n)}
|
||||
size="xs"
|
||||
>
|
||||
{t("{{count}} Columns", { count: n })}
|
||||
</Button>
|
||||
))}
|
||||
</Button.Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
{presets.length > 0 && <div className={classes.divider} />}
|
||||
|
||||
{presets.map((preset) => (
|
||||
<Tooltip key={preset.layout} position="top" label={t(preset.label)}>
|
||||
<ActionIcon
|
||||
onClick={() => setLayout(preset.layout)}
|
||||
size="lg"
|
||||
aria-label={t(preset.label)}
|
||||
variant="subtle"
|
||||
className={clsx({
|
||||
[classes.active]: currentLayout === preset.layout,
|
||||
})}
|
||||
>
|
||||
<preset.icon size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={copied ? t("Copied") : t("Copy")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={handleCopy}
|
||||
size="lg"
|
||||
aria-label={t("Copy")}
|
||||
variant="subtle"
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck size={18} color="var(--mantine-color-green-6)" />
|
||||
) : (
|
||||
<IconCopy size={18} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDelete}
|
||||
size="lg"
|
||||
aria-label={t("Delete")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColumnsMenu;
|
||||
@@ -4,6 +4,20 @@ import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import {
|
||||
getAttachmentInfo,
|
||||
uploadFile,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
|
||||
const ATTACHMENT_NODE_TYPES = [
|
||||
"image",
|
||||
"video",
|
||||
"attachment",
|
||||
"excalidraw",
|
||||
"drawio",
|
||||
];
|
||||
|
||||
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
|
||||
|
||||
export const handlePaste = (
|
||||
editor: Editor,
|
||||
@@ -19,7 +33,6 @@ export const handlePaste = (
|
||||
const url = clipboardData.trim();
|
||||
const { from: pos, empty } = editor.state.selection;
|
||||
const match = INTERNAL_LINK_REGEX.exec(url);
|
||||
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
||||
|
||||
// pasted link must be from the same workspace/domain and must not be on a selection
|
||||
if (!empty || match[2] !== window.location.host) {
|
||||
@@ -27,12 +40,6 @@ export const handlePaste = (
|
||||
return false;
|
||||
}
|
||||
|
||||
// for now, we only support internal links from the same space
|
||||
// compare space name
|
||||
if (currentPageMatch[4].toLowerCase() !== match[4].toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorId = match[6] ? match[6].split("#")[0] : undefined;
|
||||
const urlWithoutAnchor = anchorId
|
||||
? url.substring(0, url.indexOf("#"))
|
||||
@@ -47,7 +54,10 @@ export const handlePaste = (
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.clipboardData?.files.length) {
|
||||
const htmlData = event.clipboardData?.getData("text/html");
|
||||
const hasHtmlTable = htmlData && /<table[\s>]/i.test(htmlData);
|
||||
|
||||
if (event.clipboardData?.files.length && !hasHtmlTable) {
|
||||
event.preventDefault();
|
||||
for (const file of event.clipboardData.files) {
|
||||
const pos = editor.state.selection.from;
|
||||
@@ -57,9 +67,151 @@ export const handlePaste = (
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (htmlData && ATTACHMENT_URL_RE.test(htmlData)) {
|
||||
const pasteFrom = editor.state.selection.from;
|
||||
setTimeout(() => {
|
||||
reuploadPastedAttachments(editor, pageId, pasteFrom);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
async function reuploadPastedAttachments(
|
||||
editor: Editor,
|
||||
pageId: string,
|
||||
pasteFrom: number,
|
||||
) {
|
||||
const pasteEnd = editor.state.selection.from;
|
||||
if (pasteEnd <= pasteFrom) return;
|
||||
|
||||
type PastedNode = {
|
||||
pos: number;
|
||||
attachmentId: string;
|
||||
nodeTypeName: string;
|
||||
src?: string;
|
||||
url?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
const pastedNodes: PastedNode[] = [];
|
||||
const seenAttachmentIds = new Set<string>();
|
||||
|
||||
editor.state.doc.nodesBetween(pasteFrom, pasteEnd, (node, pos) => {
|
||||
if (!ATTACHMENT_NODE_TYPES.includes(node.type.name)) return;
|
||||
const attachmentId = node.attrs.attachmentId;
|
||||
if (!attachmentId) return;
|
||||
|
||||
const src = node.attrs.src || node.attrs.url || "";
|
||||
const match = ATTACHMENT_URL_RE.exec(src);
|
||||
if (!match) return;
|
||||
|
||||
const fileName =
|
||||
node.attrs.name || src.split("/").pop() || "file";
|
||||
|
||||
pastedNodes.push({
|
||||
pos,
|
||||
attachmentId,
|
||||
nodeTypeName: node.type.name,
|
||||
src: node.attrs.src,
|
||||
url: node.attrs.url,
|
||||
fileName,
|
||||
});
|
||||
seenAttachmentIds.add(attachmentId);
|
||||
});
|
||||
|
||||
if (pastedNodes.length === 0) return;
|
||||
|
||||
const attachmentPageMap = new Map<string, string | null>();
|
||||
await Promise.all(
|
||||
[...seenAttachmentIds].map(async (id) => {
|
||||
try {
|
||||
const info = await getAttachmentInfo(id);
|
||||
attachmentPageMap.set(id, info.pageId);
|
||||
} catch {
|
||||
attachmentPageMap.set(id, null);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const nodesToReupload = pastedNodes.filter((n) => {
|
||||
const ownerPageId = attachmentPageMap.get(n.attachmentId);
|
||||
return ownerPageId !== null && ownerPageId !== pageId;
|
||||
});
|
||||
|
||||
if (nodesToReupload.length === 0) return;
|
||||
|
||||
const uniqueNodes = new Map<string, (typeof nodesToReupload)[0]>();
|
||||
for (const node of nodesToReupload) {
|
||||
if (!uniqueNodes.has(node.attachmentId)) {
|
||||
uniqueNodes.set(node.attachmentId, node);
|
||||
}
|
||||
}
|
||||
|
||||
const reuploadResults = new Map<
|
||||
string,
|
||||
{ id: string; fileName: string; fileSize: number; mimeType: string }
|
||||
>();
|
||||
|
||||
await Promise.all(
|
||||
[...uniqueNodes.values()].map(async (node) => {
|
||||
const fileUrl = node.src || node.url;
|
||||
if (!fileUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(fileUrl, { credentials: "include" });
|
||||
if (!response.ok) return;
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], node.fileName, { type: blob.type });
|
||||
const newAttachment = await uploadFile(file, pageId);
|
||||
reuploadResults.set(node.attachmentId, {
|
||||
id: newAttachment.id,
|
||||
fileName: newAttachment.fileName,
|
||||
fileSize: newAttachment.fileSize,
|
||||
mimeType: newAttachment.mimeType,
|
||||
});
|
||||
} catch {
|
||||
// keep original reference on failure
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (reuploadResults.size === 0) return;
|
||||
|
||||
editor.chain().command(({ tr }) => {
|
||||
const sorted = [...nodesToReupload].sort((a, b) => b.pos - a.pos);
|
||||
|
||||
for (const pastedNode of sorted) {
|
||||
const result = reuploadResults.get(pastedNode.attachmentId);
|
||||
if (!result) continue;
|
||||
|
||||
const node = tr.doc.nodeAt(pastedNode.pos);
|
||||
if (!node || node.attrs.attachmentId !== pastedNode.attachmentId)
|
||||
continue;
|
||||
|
||||
const newAttrs = { ...node.attrs };
|
||||
newAttrs.attachmentId = result.id;
|
||||
|
||||
if (newAttrs.src) {
|
||||
newAttrs.src = `/api/files/${result.id}/${result.fileName}`;
|
||||
}
|
||||
if (newAttrs.url) {
|
||||
newAttrs.url = `/api/files/${result.id}/${result.fileName}`;
|
||||
}
|
||||
if (pastedNode.nodeTypeName === "attachment") {
|
||||
newAttrs.name = result.fileName;
|
||||
newAttrs.mime = result.mimeType;
|
||||
newAttrs.size = result.fileSize;
|
||||
}
|
||||
|
||||
tr.setNodeMarkup(pastedNode.pos, undefined, newAttrs);
|
||||
}
|
||||
|
||||
return true;
|
||||
}).run();
|
||||
}
|
||||
|
||||
export const handleFileDrop = (
|
||||
editor: Editor,
|
||||
event: DragEvent,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||
import classes from "./node-resize.module.css";
|
||||
|
||||
export function createResizeHandle(
|
||||
direction: ResizableNodeViewDirection,
|
||||
): HTMLElement {
|
||||
const handle = document.createElement("div");
|
||||
handle.dataset.resizeHandle = direction;
|
||||
handle.style.position = "absolute";
|
||||
handle.className = classes.handle;
|
||||
|
||||
if (direction === "left") {
|
||||
handle.style.left = "-8px";
|
||||
handle.style.top = "0";
|
||||
handle.style.bottom = "0";
|
||||
} else if (direction === "right") {
|
||||
handle.style.right = "-8px";
|
||||
handle.style.top = "0";
|
||||
handle.style.bottom = "0";
|
||||
}
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = classes.handleBar;
|
||||
handle.appendChild(bar);
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
export function buildResizeClasses(nodeClass: string) {
|
||||
return {
|
||||
container: `${classes.container} ${nodeClass}`,
|
||||
wrapper: classes.wrapper,
|
||||
resizing: classes.resizing,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: visible;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wrapper img,
|
||||
.wrapper video {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.resizing {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: ew-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.handle[data-resize-handle="left"] {
|
||||
left: -8px;
|
||||
}
|
||||
|
||||
.handle[data-resize-handle="right"] {
|
||||
right: -8px;
|
||||
}
|
||||
|
||||
.wrapper:hover .handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.container:global(.ProseMirror-selectednode) .handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resizing .handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.handleBar {
|
||||
width: 4px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
|
||||
}
|
||||
|
||||
.handle:hover .handleBar {
|
||||
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||
}
|
||||
|
||||
.resizing .handleBar {
|
||||
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||
}
|
||||
|
||||
@media print {
|
||||
.handle {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.resizing {
|
||||
user-select: none;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
@@ -20,12 +18,118 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cornerHandle {
|
||||
position: absolute;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
touch-action: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-radius: 1px;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-4),
|
||||
var(--mantine-color-blue-5)
|
||||
);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
&::before {
|
||||
width: 28px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
width: 3px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
&:hover::before,
|
||||
&:hover::after {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-6),
|
||||
var(--mantine-color-blue-4)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.cornerHandleTL {
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
cursor: nwse-resize;
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cornerHandleTR {
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
cursor: nesw-resize;
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cornerHandleBL {
|
||||
bottom: -2px;
|
||||
left: -2px;
|
||||
cursor: nesw-resize;
|
||||
|
||||
&::before {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cornerHandleBR {
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
cursor: nwse-resize;
|
||||
|
||||
&::before {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.resizeHandleBottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
bottom: -4px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
height: 12px;
|
||||
cursor: ns-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
@@ -36,61 +140,53 @@
|
||||
touch-action: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
@mixin light {
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@mixin light {
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.1)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper:hover .resizeHandleBottom,
|
||||
.resizing .resizeHandleBottom {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resizeBar {
|
||||
width: 50px;
|
||||
height: 4px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.2s ease;
|
||||
transition: background-color 0.15s ease;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-4),
|
||||
var(--mantine-color-blue-5)
|
||||
);
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
.resizeHandleBottom:hover .resizeBar {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-6),
|
||||
var(--mantine-color-blue-4)
|
||||
);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-gray-6);
|
||||
}
|
||||
.wrapper:hover .cornerHandle,
|
||||
.wrapper:hover .resizeHandleBottom,
|
||||
.wrapper:global(.ProseMirror-selectednode) .cornerHandle,
|
||||
.wrapper:global(.ProseMirror-selectednode) .resizeHandleBottom,
|
||||
.resizing .cornerHandle,
|
||||
.resizing .resizeHandleBottom {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resizing .cornerHandle::before,
|
||||
.resizing .cornerHandle::after {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-6),
|
||||
var(--mantine-color-blue-4)
|
||||
);
|
||||
}
|
||||
|
||||
.resizeHandleBottom:hover .resizeBar,
|
||||
.resizing .resizeBar {
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-7);
|
||||
}
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-6),
|
||||
var(--mantine-color-blue-4)
|
||||
);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-gray-4);
|
||||
@media print {
|
||||
.cornerHandle,
|
||||
.resizeHandleBottom {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,111 +2,163 @@ import React, { ReactNode, useCallback, useEffect, useRef, useState } from "reac
|
||||
import clsx from "clsx";
|
||||
import classes from "./resizable-wrapper.module.css";
|
||||
|
||||
type Handle = "tl" | "tr" | "bl" | "br" | "bottom";
|
||||
|
||||
const HANDLE_SIGN: Record<Handle, { x: number; y: number }> = {
|
||||
br: { x: 1, y: 1 },
|
||||
bl: { x: -1, y: 1 },
|
||||
tr: { x: 1, y: -1 },
|
||||
tl: { x: -1, y: -1 },
|
||||
bottom: { x: 0, y: 1 },
|
||||
};
|
||||
|
||||
const HANDLE_CURSOR: Record<Handle, string> = {
|
||||
br: "nwse-resize",
|
||||
tl: "nwse-resize",
|
||||
bl: "nesw-resize",
|
||||
tr: "nesw-resize",
|
||||
bottom: "ns-resize",
|
||||
};
|
||||
|
||||
const CORNER_CLASSES: Record<string, string> = {
|
||||
tl: classes.cornerHandleTL,
|
||||
tr: classes.cornerHandleTR,
|
||||
bl: classes.cornerHandleBL,
|
||||
br: classes.cornerHandleBR,
|
||||
};
|
||||
|
||||
interface ResizableWrapperProps {
|
||||
children: ReactNode;
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
onResize?: (height: number) => void;
|
||||
onResize?: (width: number, height: number) => void;
|
||||
isEditable?: boolean;
|
||||
className?: string;
|
||||
showHandles?: "always" | "hover";
|
||||
direction?: "vertical" | "horizontal" | "both";
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
handle: Handle;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startWidth: number;
|
||||
startHeight: number;
|
||||
};
|
||||
|
||||
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
||||
children,
|
||||
initialWidth = 640,
|
||||
initialHeight = 480,
|
||||
minWidth = 200,
|
||||
maxWidth = 1200,
|
||||
minHeight = 200,
|
||||
maxHeight = 1200,
|
||||
onResize,
|
||||
isEditable = true,
|
||||
className,
|
||||
showHandles = "hover",
|
||||
direction = "vertical",
|
||||
selected = false,
|
||||
}) => {
|
||||
const [resizeParams, setResizeParams] = useState<{
|
||||
initialSize: number;
|
||||
initialClientY: number;
|
||||
initialClientX: number;
|
||||
} | null>(null);
|
||||
const [currentHeight, setCurrentHeight] = useState(initialHeight);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizeParams) return;
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const widthRef = useRef(initialWidth);
|
||||
const heightRef = useRef(initialHeight);
|
||||
const onResizeRef = useRef(onResize);
|
||||
onResizeRef.current = onResize;
|
||||
const constraintsRef = useRef({ minWidth, maxWidth, minHeight, maxHeight });
|
||||
constraintsRef.current = { minWidth, maxWidth, minHeight, maxHeight };
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!wrapperRef.current) return;
|
||||
const handleMouseMove = useRef((e: MouseEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || !wrapperRef.current) return;
|
||||
|
||||
if (direction === "vertical" || direction === "both") {
|
||||
const deltaY = e.clientY - resizeParams.initialClientY;
|
||||
const newHeight = Math.min(
|
||||
Math.max(resizeParams.initialSize + deltaY, minHeight),
|
||||
maxHeight
|
||||
);
|
||||
setCurrentHeight(newHeight);
|
||||
wrapperRef.current.style.height = `${newHeight}px`;
|
||||
}
|
||||
const sign = HANDLE_SIGN[drag.handle];
|
||||
const { minWidth, maxWidth, minHeight, maxHeight } = constraintsRef.current;
|
||||
|
||||
const deltaY = e.clientY - drag.startY;
|
||||
const newHeight = Math.min(Math.max(drag.startHeight + deltaY * sign.y, minHeight), maxHeight);
|
||||
heightRef.current = newHeight;
|
||||
wrapperRef.current.style.height = `${newHeight}px`;
|
||||
|
||||
if (sign.x !== 0) {
|
||||
const deltaX = e.clientX - drag.startX;
|
||||
const newWidth = Math.min(Math.max(drag.startWidth + deltaX * sign.x, minWidth), maxWidth);
|
||||
widthRef.current = newWidth;
|
||||
wrapperRef.current.style.width = `${newWidth}px`;
|
||||
}
|
||||
}).current;
|
||||
|
||||
const handleMouseUp = useRef(() => {
|
||||
dragRef.current = null;
|
||||
setIsResizing(false);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
onResizeRef.current?.(widthRef.current, heightRef.current);
|
||||
}).current;
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent, handle: Handle) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragRef.current = {
|
||||
handle,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startWidth: widthRef.current,
|
||||
startHeight: heightRef.current,
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizeParams(null);
|
||||
if (onResize && currentHeight !== initialHeight) {
|
||||
onResize(currentHeight);
|
||||
}
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
setIsResizing(true);
|
||||
document.body.style.cursor = HANDLE_CURSOR[handle];
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setResizeParams({
|
||||
initialSize: currentHeight,
|
||||
initialClientY: e.clientY,
|
||||
initialClientX: e.clientX,
|
||||
});
|
||||
|
||||
document.body.style.cursor = "ns-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, [currentHeight]);
|
||||
|
||||
const shouldShowHandles =
|
||||
isEditable &&
|
||||
(showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams)));
|
||||
const shouldShowHandles = isEditable && (isHovered || isResizing || selected);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={clsx(classes.wrapper, className, {
|
||||
[classes.resizing]: !!resizeParams,
|
||||
[classes.resizing]: isResizing,
|
||||
})}
|
||||
style={{ height: currentHeight }}
|
||||
style={{ width: widthRef.current, height: heightRef.current }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{children}
|
||||
{!!resizeParams && <div className={classes.overlay} />}
|
||||
{shouldShowHandles && direction === "vertical" && (
|
||||
<div
|
||||
className={classes.resizeHandleBottom}
|
||||
onMouseDown={handleResizeStart}
|
||||
>
|
||||
<div className={classes.resizeBar} />
|
||||
</div>
|
||||
{isResizing && <div className={classes.overlay} />}
|
||||
{shouldShowHandles && (
|
||||
<>
|
||||
{(["tl", "tr", "bl", "br"] as const).map((corner) => (
|
||||
<div
|
||||
key={corner}
|
||||
className={clsx(classes.cornerHandle, CORNER_CLASSES[corner])}
|
||||
onMouseDown={(e) => handleResizeStart(e, corner)}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={classes.resizeHandleBottom}
|
||||
onMouseDown={(e) => handleResizeStart(e, "bottom")}
|
||||
>
|
||||
<div className={classes.resizeBar} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
box-shadow: 0 2px 12px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
|
||||
.toolbar :global(.mantine-ActionIcon-root) {
|
||||
--ai-color: light-dark(var(--mantine-color-dark-7), var(--mantine-color-gray-4)) !important;
|
||||
--ai-hover: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)) !important;
|
||||
}
|
||||
|
||||
.toolbar .active {
|
||||
--ai-color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)) !important;
|
||||
--ai-hover: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5)) !important;
|
||||
background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
align-self: center;
|
||||
margin: 0 2px;
|
||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3));
|
||||
}
|
||||
@@ -1,24 +1,46 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import { useCallback } from "react";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
||||
import {
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Tooltip,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
IconLayoutAlignCenter,
|
||||
IconLayoutAlignLeft,
|
||||
IconLayoutAlignRight,
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import {
|
||||
DrawIoEmbed,
|
||||
DrawIoEmbedRef,
|
||||
EventExit,
|
||||
EventSave,
|
||||
} from "react-drawio";
|
||||
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
|
||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [initialXML, setInitialXML] = useState<string>("");
|
||||
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
@@ -30,11 +52,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
const drawioAttr = ctx.editor.getAttributes("drawio");
|
||||
return {
|
||||
isDrawio: ctx.editor.isActive("drawio"),
|
||||
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
|
||||
isAlignLeft: ctx.editor.isActive("drawio", { align: "left" }),
|
||||
isAlignCenter: ctx.editor.isActive("drawio", { align: "center" }),
|
||||
isAlignRight: ctx.editor.isActive("drawio", { align: "right" }),
|
||||
src: drawioAttr?.src || null,
|
||||
attachmentId: drawioAttr?.attachmentId || null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const { selection } = editor.state;
|
||||
@@ -57,38 +94,222 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
(value: number) => {
|
||||
editor.commands.updateAttributes("drawio", { width: `${value}%` });
|
||||
const alignLeft = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setDrawioAlign("left")
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const alignCenter = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setDrawioAlign("center")
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const alignRight = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setDrawioAlign("right")
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!editorState?.src) return;
|
||||
const url = getFileUrl(editorState.src);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
}, [editorState?.src]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
editor.commands.deleteSelection();
|
||||
}, [editor]);
|
||||
|
||||
const handleOpen = useCallback(async () => {
|
||||
if (!editorState?.src) return;
|
||||
|
||||
try {
|
||||
const url = getFileUrl(editorState.src);
|
||||
const request = await fetch(url, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
const blob = await request.blob();
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob);
|
||||
reader.onloadend = () => {
|
||||
const base64data = (reader.result || "") as string;
|
||||
setInitialXML(base64data);
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
open();
|
||||
}
|
||||
}, [editorState?.src, open]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (data: EventSave) => {
|
||||
const svgString = decodeBase64ToSvgString(data.xml);
|
||||
const fileName = "diagram.drawio.svg";
|
||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
const attachmentId = editorState?.attachmentId;
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
if (attachmentId) {
|
||||
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
||||
} else {
|
||||
attachment = await uploadFile(drawioSVGFile, pageId);
|
||||
}
|
||||
|
||||
editor.commands.updateAttributes("drawio", {
|
||||
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||
title: attachment.fileName,
|
||||
size: attachment.fileSize,
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
close();
|
||||
},
|
||||
[editor],
|
||||
[editor, editorState?.attachmentId, close],
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`drawio-menu`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
<>
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`drawio-menu`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
{editorState?.width && (
|
||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||
)}
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignLeft}
|
||||
size="lg"
|
||||
aria-label={t("Align left")}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
||||
>
|
||||
<IconLayoutAlignLeft size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Align center")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={alignCenter}
|
||||
size="lg"
|
||||
aria-label={t("Align center")}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
||||
>
|
||||
<IconLayoutAlignCenter size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Align right")}>
|
||||
<ActionIcon
|
||||
onClick={alignRight}
|
||||
size="lg"
|
||||
aria-label={t("Align right")}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
||||
>
|
||||
<IconLayoutAlignRight size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
size="lg"
|
||||
aria-label={t("Edit")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDownload}
|
||||
size="lg"
|
||||
aria-label={t("Download")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDelete}
|
||||
size="lg"
|
||||
aria-label={t("Delete")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
|
||||
<Modal.Root opened={opened} onClose={close} fullScreen>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Body>
|
||||
<div style={{ height: "100vh" }}>
|
||||
<DrawIoEmbed
|
||||
ref={drawioRef}
|
||||
xml={initialXML}
|
||||
baseUrl={getDrawioUrl()}
|
||||
urlParameters={{
|
||||
ui: computedColorScheme === "light" ? "kennedy" : "dark",
|
||||
spin: true,
|
||||
libraries: true,
|
||||
saveAndExit: true,
|
||||
noSaveBtn: true,
|
||||
}}
|
||||
onSave={(data: EventSave) => {
|
||||
if (data.parentEvent !== "save") {
|
||||
return;
|
||||
}
|
||||
handleSave(data);
|
||||
}}
|
||||
onClose={(data: EventExit) => {
|
||||
if (data.parentEvent) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Card,
|
||||
Image,
|
||||
Modal,
|
||||
Text,
|
||||
useComputedColorScheme,
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
import { useRef, useState } from "react";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
|
||||
import { getDrawioUrl } from "@/lib/config.ts";
|
||||
import {
|
||||
DrawIoEmbed,
|
||||
DrawIoEmbedRef,
|
||||
@@ -26,7 +25,7 @@ import { useTranslation } from "react-i18next";
|
||||
export default function DrawioView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, updateAttributes, editor, selected } = props;
|
||||
const { src, title, width, attachmentId } = node.attrs;
|
||||
const { attachmentId } = node.attrs;
|
||||
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||
const [initialXML, setInitialXML] = useState<string>("");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
@@ -36,33 +35,11 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (src) {
|
||||
const url = getFileUrl(src);
|
||||
const request = await fetch(url, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
const blob = await request.blob();
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob);
|
||||
reader.onloadend = () => {
|
||||
const base64data = (reader.result || "") as string;
|
||||
setInitialXML(base64data);
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
open();
|
||||
}
|
||||
open();
|
||||
};
|
||||
|
||||
const handleSave = async (data: EventSave) => {
|
||||
const svgString = decodeBase64ToSvgString(data.xml);
|
||||
|
||||
const fileName = "diagram.drawio.svg";
|
||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
@@ -70,7 +47,6 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
const pageId = editor.storage?.pageId;
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
|
||||
if (attachmentId) {
|
||||
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
||||
} else {
|
||||
@@ -106,14 +82,12 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
noSaveBtn: true,
|
||||
}}
|
||||
onSave={(data: EventSave) => {
|
||||
// If the save is triggered by another event, then do nothing
|
||||
if (data.parentEvent !== "save") {
|
||||
return;
|
||||
}
|
||||
handleSave(data);
|
||||
}}
|
||||
onClose={(data: EventExit) => {
|
||||
// If the exit is triggered by another event, then do nothing
|
||||
if (data.parentEvent) {
|
||||
return;
|
||||
}
|
||||
@@ -125,62 +99,28 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
{src ? (
|
||||
<div style={{ position: "relative" }}>
|
||||
<Image
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
radius="md"
|
||||
fit="contain"
|
||||
w={width}
|
||||
src={getFileUrl(src)}
|
||||
alt={title}
|
||||
className={clsx(
|
||||
selected ? "ProseMirror-selectednode" : "",
|
||||
"alignCenter",
|
||||
)}
|
||||
/>
|
||||
<Card
|
||||
radius="md"
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
p="xs"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
withBorder
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<ActionIcon variant="transparent" color="gray">
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
{selected && editor.isEditable && (
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
variant="default"
|
||||
color="gray"
|
||||
mx="xs"
|
||||
className="print-hide"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
{t("Double-click to edit Draw.io diagram")}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<Card
|
||||
radius="md"
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
p="xs"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
withBorder
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<ActionIcon variant="transparent" color="gray">
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
{t("Double-click to edit Draw.io diagram")}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
:global(.ProseMirror .node-embed.ProseMirror-selectednode) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.embedContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.embedWrapper {
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
@@ -13,4 +22,4 @@
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconEdit } from "@tabler/icons-react";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v4";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
@@ -27,16 +27,13 @@ import { ResizableWrapper } from "../common/resizable-wrapper";
|
||||
import classes from "./embed-view.module.css";
|
||||
|
||||
const schema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
.url({ message: i18n.t("Please enter a valid url") }),
|
||||
url: z.url({ message: i18n.t("Please enter a valid url") }).trim(),
|
||||
});
|
||||
|
||||
export default function EmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected, updateAttributes, editor } = props;
|
||||
const { src, provider, height: nodeHeight } = node.attrs;
|
||||
const { src, provider, width: nodeWidth, height: nodeHeight } = node.attrs;
|
||||
|
||||
const embedUrl = useMemo(() => {
|
||||
if (src) {
|
||||
@@ -49,12 +46,12 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
initialValues: {
|
||||
url: "",
|
||||
},
|
||||
validate: zodResolver(schema),
|
||||
validate: zod4Resolver(schema),
|
||||
});
|
||||
|
||||
const handleResize = useCallback(
|
||||
(newHeight: number) => {
|
||||
updateAttributes({ height: newHeight });
|
||||
(newWidth: number, newHeight: number) => {
|
||||
updateAttributes({ width: newWidth, height: newHeight });
|
||||
},
|
||||
[updateAttributes],
|
||||
);
|
||||
@@ -85,27 +82,33 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<NodeViewWrapper data-drag-handle className={classes.embedNodeView}>
|
||||
{embedUrl ? (
|
||||
<ResizableWrapper
|
||||
initialHeight={nodeHeight || 480}
|
||||
minHeight={200}
|
||||
maxHeight={1200}
|
||||
onResize={handleResize}
|
||||
isEditable={editor.isEditable}
|
||||
className={clsx(classes.embedWrapper, {
|
||||
"ProseMirror-selectednode": selected,
|
||||
})}
|
||||
>
|
||||
<iframe
|
||||
className={classes.embedIframe}
|
||||
src={sanitizeUrl(embedUrl)}
|
||||
allow="encrypted-media"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
allowFullScreen
|
||||
frameBorder="0"
|
||||
/>
|
||||
</ResizableWrapper>
|
||||
<div className={classes.embedContainer}>
|
||||
<ResizableWrapper
|
||||
initialWidth={nodeWidth || 640}
|
||||
initialHeight={nodeHeight || 480}
|
||||
minWidth={200}
|
||||
maxWidth={1200}
|
||||
minHeight={200}
|
||||
maxHeight={1200}
|
||||
onResize={handleResize}
|
||||
isEditable={editor.isEditable}
|
||||
selected={selected}
|
||||
className={clsx(classes.embedWrapper, {
|
||||
"ProseMirror-selectednode": selected,
|
||||
})}
|
||||
>
|
||||
<iframe
|
||||
className={classes.embedIframe}
|
||||
src={sanitizeUrl(embedUrl)}
|
||||
allow="encrypted-media"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
allowFullScreen
|
||||
frameBorder="0"
|
||||
/>
|
||||
</ResizableWrapper>
|
||||
</div>
|
||||
) : (
|
||||
<Popover
|
||||
width={300}
|
||||
|
||||
@@ -1,14 +1,77 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import { useCallback } from "react";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import { lazy, Suspense, useCallback, useState } from "react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Tooltip,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
IconLayoutAlignCenter,
|
||||
IconLayoutAlignLeft,
|
||||
IconLayoutAlignRight,
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { svgStringToFile } from "@/lib";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||
import ReactClearModal from "react-clear-modal";
|
||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
const ExcalidrawComponent = lazy(() =>
|
||||
import("@excalidraw/excalidraw").then((module) => ({
|
||||
default: module.Excalidraw,
|
||||
})),
|
||||
);
|
||||
|
||||
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [excalidrawAPI, setExcalidrawAPI] =
|
||||
useState<ExcalidrawImperativeAPI>(null);
|
||||
useHandleLibrary({
|
||||
excalidrawAPI,
|
||||
adapter: localStorageLibraryAdapter,
|
||||
});
|
||||
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
|
||||
return {
|
||||
isExcalidraw: ctx.editor.isActive("excalidraw"),
|
||||
isAlignLeft: ctx.editor.isActive("excalidraw", { align: "left" }),
|
||||
isAlignCenter: ctx.editor.isActive("excalidraw", { align: "center" }),
|
||||
isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }),
|
||||
src: excalidrawAttr?.src || null,
|
||||
attachmentId: excalidrawAttr?.attachmentId || null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
@@ -22,21 +85,6 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
[editor],
|
||||
);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
|
||||
return {
|
||||
isExcalidraw: ctx.editor.isActive("excalidraw"),
|
||||
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const { selection } = editor.state;
|
||||
@@ -59,38 +107,252 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
(value: number) => {
|
||||
editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
const alignLeft = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setExcalidrawAlign("left")
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const alignCenter = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setExcalidrawAlign("center")
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const alignRight = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setExcalidrawAlign("right")
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!editorState?.src) return;
|
||||
const url = getFileUrl(editorState.src);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
}, [editorState?.src]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
editor.commands.deleteSelection();
|
||||
}, [editor]);
|
||||
|
||||
const handleOpen = useCallback(async () => {
|
||||
if (!editorState?.src) return;
|
||||
|
||||
try {
|
||||
const url = getFileUrl(editorState.src);
|
||||
const request = await fetch(url, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const { loadFromBlob } = await import("@excalidraw/excalidraw");
|
||||
const data = await loadFromBlob(await request.blob(), null, null);
|
||||
setExcalidrawData(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
open();
|
||||
}
|
||||
}, [editorState?.src, open]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { exportToSvg } = await import("@excalidraw/excalidraw");
|
||||
|
||||
const svg = await exportToSvg({
|
||||
elements: excalidrawAPI?.getSceneElements(),
|
||||
appState: {
|
||||
exportEmbedScene: true,
|
||||
exportWithDarkMode: false,
|
||||
},
|
||||
files: excalidrawAPI?.getFiles(),
|
||||
});
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
let svgString = serializer.serializeToString(svg);
|
||||
|
||||
svgString = svgString.replace(
|
||||
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
|
||||
"https://unpkg.com/@excalidraw/excalidraw@latest",
|
||||
);
|
||||
|
||||
const fileName = "diagram.excalidraw.svg";
|
||||
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
const attachmentId = editorState?.attachmentId;
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
if (attachmentId) {
|
||||
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
|
||||
} else {
|
||||
attachment = await uploadFile(excalidrawSvgFile, pageId);
|
||||
}
|
||||
|
||||
editor.commands.updateAttributes("excalidraw", {
|
||||
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||
title: attachment.fileName,
|
||||
size: attachment.fileSize,
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
close();
|
||||
}, [editor, excalidrawAPI, editorState?.attachmentId, close]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`excalidraw-menu`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div
|
||||
<>
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`excalidraw-menu`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignLeft}
|
||||
size="lg"
|
||||
aria-label={t("Align left")}
|
||||
variant="subtle"
|
||||
className={clsx({
|
||||
[classes.active]: editorState?.isAlignLeft,
|
||||
})}
|
||||
>
|
||||
<IconLayoutAlignLeft size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Align center")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={alignCenter}
|
||||
size="lg"
|
||||
aria-label={t("Align center")}
|
||||
variant="subtle"
|
||||
className={clsx({
|
||||
[classes.active]: editorState?.isAlignCenter,
|
||||
})}
|
||||
>
|
||||
<IconLayoutAlignCenter size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Align right")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignRight}
|
||||
size="lg"
|
||||
aria-label={t("Align right")}
|
||||
variant="subtle"
|
||||
className={clsx({
|
||||
[classes.active]: editorState?.isAlignRight,
|
||||
})}
|
||||
>
|
||||
<IconLayoutAlignRight size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
size="lg"
|
||||
aria-label={t("Edit")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDownload}
|
||||
size="lg"
|
||||
aria-label={t("Download")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDelete}
|
||||
size="lg"
|
||||
aria-label={t("Delete")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
|
||||
<ReactClearModal
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
padding: 0,
|
||||
zIndex: 200,
|
||||
}}
|
||||
isOpen={opened}
|
||||
onRequestClose={close}
|
||||
disableCloseOnBgClick={true}
|
||||
contentProps={{
|
||||
style: {
|
||||
padding: 0,
|
||||
width: "90vw",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{editorState?.width && (
|
||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||
)}
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
<Group
|
||||
justify="flex-end"
|
||||
wrap="nowrap"
|
||||
bg="var(--mantine-color-body)"
|
||||
p="xs"
|
||||
>
|
||||
<Button onClick={handleSave} size={"compact-sm"}>
|
||||
{t("Save & Exit")}
|
||||
</Button>
|
||||
<Button onClick={close} color="red" size={"compact-sm"}>
|
||||
{t("Exit")}
|
||||
</Button>
|
||||
</Group>
|
||||
<div style={{ height: "90vh" }}>
|
||||
<Suspense fallback={null}>
|
||||
<ExcalidrawComponent
|
||||
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||
initialData={{
|
||||
...excalidrawData,
|
||||
scrollToContent: true,
|
||||
}}
|
||||
theme={computedColorScheme}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ReactClearModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,28 +4,24 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
Text,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { svgStringToFile } from "@/lib";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||
import ReactClearModal from "react-clear-modal";
|
||||
import clsx from "clsx";
|
||||
import { IconEdit } from "@tabler/icons-react";
|
||||
import { lazy } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||
|
||||
const Excalidraw = lazy(() =>
|
||||
const ExcalidrawComponent = lazy(() =>
|
||||
import("@excalidraw/excalidraw").then((module) => ({
|
||||
default: module.Excalidraw,
|
||||
})),
|
||||
@@ -34,7 +30,7 @@ const Excalidraw = lazy(() =>
|
||||
export default function ExcalidrawView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, updateAttributes, editor, selected } = props;
|
||||
const { src, title, width, attachmentId } = node.attrs;
|
||||
const { attachmentId } = node.attrs;
|
||||
|
||||
const [excalidrawAPI, setExcalidrawAPI] =
|
||||
useState<ExcalidrawImperativeAPI>(null);
|
||||
@@ -50,25 +46,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (src) {
|
||||
const url = getFileUrl(src);
|
||||
const request = await fetch(url, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const { loadFromBlob } = await import("@excalidraw/excalidraw");
|
||||
|
||||
const data = await loadFromBlob(await request.blob(), null, null);
|
||||
setExcalidrawData(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
open();
|
||||
}
|
||||
open();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -151,7 +129,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
</Group>
|
||||
<div style={{ height: "90vh" }}>
|
||||
<Suspense fallback={null}>
|
||||
<Excalidraw
|
||||
<ExcalidrawComponent
|
||||
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||
initialData={{
|
||||
...excalidrawData,
|
||||
@@ -163,62 +141,28 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
</div>
|
||||
</ReactClearModal>
|
||||
|
||||
{src ? (
|
||||
<div style={{ position: "relative" }}>
|
||||
<Image
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
radius="md"
|
||||
fit="contain"
|
||||
w={width}
|
||||
src={getFileUrl(src)}
|
||||
alt={title}
|
||||
className={clsx(
|
||||
selected ? "ProseMirror-selectednode" : "",
|
||||
"alignCenter",
|
||||
)}
|
||||
/>
|
||||
<Card
|
||||
radius="md"
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
p="xs"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
withBorder
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<ActionIcon variant="transparent" color="gray">
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
{selected && editor.isEditable && (
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
variant="default"
|
||||
color="gray"
|
||||
mx="xs"
|
||||
className="print-hide"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
{t("Double-click to edit Excalidraw diagram")}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<Card
|
||||
radius="md"
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
p="xs"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
withBorder
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<ActionIcon variant="transparent" color="gray">
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
{t("Double-click to edit Excalidraw diagram")}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import React, { useCallback } from "react";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
IconLayoutAlignCenter,
|
||||
IconLayoutAlignLeft,
|
||||
IconLayoutAlignRight,
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
@@ -32,7 +39,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
|
||||
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
|
||||
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
||||
width: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
|
||||
src: imageAttrs?.src || null,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -94,17 +101,40 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
(value: number) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setImageWidth(value)
|
||||
.run();
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!editorState?.src) return;
|
||||
const url = getFileUrl(editorState.src);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
}, [editorState?.src]);
|
||||
|
||||
const handleReplace = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
if (pageId) {
|
||||
const pos = editor.state.selection.from;
|
||||
uploadImageAction(file, editor, pos, pageId);
|
||||
}
|
||||
// Reset so the same file can be selected again
|
||||
e.target.value = "";
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
editor.commands.deleteSelection();
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
@@ -118,44 +148,86 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group className="actionIconGroup">
|
||||
<Tooltip position="top" label={t("Align left")}>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignImageLeft}
|
||||
size="lg"
|
||||
aria-label={t("Align left")}
|
||||
variant={editorState?.isAlignLeft ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
||||
>
|
||||
<IconLayoutAlignLeft size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Align center")}>
|
||||
<Tooltip position="top" label={t("Align center")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignImageCenter}
|
||||
size="lg"
|
||||
aria-label={t("Align center")}
|
||||
variant={editorState?.isAlignCenter ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
||||
>
|
||||
<IconLayoutAlignCenter size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Align right")}>
|
||||
<Tooltip position="top" label={t("Align right")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignImageRight}
|
||||
size="lg"
|
||||
aria-label={t("Align right")}
|
||||
variant={editorState?.isAlignRight ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
||||
>
|
||||
<IconLayoutAlignRight size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
|
||||
{editorState?.width && (
|
||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||
)}
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDownload}
|
||||
size="lg"
|
||||
aria-label={t("Download")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Replace image")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleReplace}
|
||||
size="lg"
|
||||
aria-label={t("Replace image")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconRefresh size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDelete}
|
||||
size="lg"
|
||||
aria-label={t("Delete")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import {
|
||||
createResizeHandle,
|
||||
buildResizeClasses,
|
||||
} from "../common/node-resize-handles";
|
||||
|
||||
export const createImageHandle = createResizeHandle;
|
||||
export const imageResizeClasses = buildResizeClasses("node-image");
|
||||
@@ -0,0 +1,64 @@
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: visible;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wrapper img {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.resizing {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: ew-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.handle[data-resize-handle="left"] {
|
||||
left: -8px;
|
||||
}
|
||||
|
||||
.handle[data-resize-handle="right"] {
|
||||
right: -8px;
|
||||
}
|
||||
|
||||
.wrapper:hover .handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resizing .handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.handleBar {
|
||||
width: 4px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
|
||||
}
|
||||
|
||||
.handle:hover .handleBar {
|
||||
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||
}
|
||||
|
||||
.resizing .handleBar {
|
||||
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { TextSelection } from "@tiptap/pm/state";
|
||||
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
|
||||
@@ -37,6 +38,10 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
||||
.focus()
|
||||
.extendMarkRange("link")
|
||||
.setLink({ href: url })
|
||||
.command(({ tr }) => {
|
||||
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
setShowEdit(false);
|
||||
},
|
||||
|
||||
@@ -56,8 +56,11 @@ export default function MathBlockView(props: NodeViewProps) {
|
||||
}, [debouncedPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsEditing(!!props.selected);
|
||||
if (props.selected) setPreview(node.attrs.text);
|
||||
const pos = getPos();
|
||||
const { from, to } = editor.state.selection;
|
||||
const nodeSelected = props.selected && from === pos && to === pos + node.nodeSize;
|
||||
setIsEditing(nodeSelected);
|
||||
if (nodeSelected) setPreview(node.attrs.text);
|
||||
}, [props.selected]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -46,8 +46,11 @@ export default function MathInlineView(props: NodeViewProps) {
|
||||
}, [preview, isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsEditing(!!props.selected);
|
||||
if (props.selected) setPreview(node.attrs.text);
|
||||
const pos = getPos();
|
||||
const { from, to } = editor.state.selection;
|
||||
const nodeSelected = props.selected && from === pos && to === pos + node.nodeSize;
|
||||
setIsEditing(nodeSelected);
|
||||
if (nodeSelected) setPreview(node.attrs.text);
|
||||
}, [props.selected]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -31,13 +31,17 @@ import {
|
||||
MentionSuggestionItem,
|
||||
} from "@/features/editor/components/mention/mention.type.ts";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { useCreatePageMutation, usePageQuery } from "@/features/page/queries/page-query";
|
||||
import {
|
||||
useCreatePageMutation,
|
||||
usePageQuery,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
import { SimpleTree } from "react-arborist";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||
|
||||
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(1);
|
||||
@@ -59,11 +63,11 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
includeUsers: true,
|
||||
includePages: true,
|
||||
spaceId: space.id,
|
||||
limit: 10,
|
||||
limit: props.query ? 10 : 5,
|
||||
preload: true,
|
||||
});
|
||||
|
||||
const createPageItem = (label: string) : MentionSuggestionItem => {
|
||||
const createPageItem = (label: string): MentionSuggestionItem => {
|
||||
return {
|
||||
id: null,
|
||||
label: label,
|
||||
@@ -71,15 +75,15 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
entityId: null,
|
||||
slugId: null,
|
||||
icon: null,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (suggestion && !isLoading) {
|
||||
let items: MentionSuggestionItem[] = [];
|
||||
|
||||
if (suggestion?.users?.length > 0) {
|
||||
items.push({ entityType: "header", label: t("Users") });
|
||||
items.push({ entityType: "header", label: t("People") });
|
||||
|
||||
items = items.concat(
|
||||
suggestion.users.map((user) => ({
|
||||
@@ -97,11 +101,13 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
items = items.concat(
|
||||
suggestion.pages.map((page) => ({
|
||||
id: uuid7(),
|
||||
label: page.title || "Untitled",
|
||||
label: page.title || t("Untitled"),
|
||||
entityType: "page",
|
||||
entityId: page.id,
|
||||
slugId: page.slugId,
|
||||
icon: page.icon,
|
||||
spaceName: page.space?.name,
|
||||
spaceSlug: page.space?.slug,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -129,17 +135,17 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
creatorId: currentUser?.user.id,
|
||||
});
|
||||
}
|
||||
if (item.entityType === "page" && item.id!==null) {
|
||||
if (item.entityType === "page" && item.id !== null) {
|
||||
props.command({
|
||||
id: item.id,
|
||||
label: item.label || "Untitled",
|
||||
label: item.label || t("Untitled"),
|
||||
entityType: "page",
|
||||
entityId: item.entityId,
|
||||
slugId: item.slugId,
|
||||
creatorId: currentUser?.user.id,
|
||||
});
|
||||
}
|
||||
if (item.entityType === "page" && item.id===null) {
|
||||
if (item.entityType === "page" && item.id === null) {
|
||||
createPage(item.label);
|
||||
}
|
||||
}
|
||||
@@ -207,7 +213,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
const payload: { spaceId: string; parentPageId?: string; title: string } = {
|
||||
spaceId: space.id,
|
||||
parentPageId: page.id || null,
|
||||
title: title
|
||||
title: title,
|
||||
};
|
||||
|
||||
let createdPage: IPage;
|
||||
@@ -231,7 +237,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
|
||||
props.command({
|
||||
id: uuid7(),
|
||||
label: createdPage.title || "Untitled",
|
||||
label: createdPage.title || "Untitled",
|
||||
entityType: "page",
|
||||
entityId: createdPage.id,
|
||||
slugId: createdPage.slugId,
|
||||
@@ -239,21 +245,20 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "addTreeNode",
|
||||
spaceId: space.id,
|
||||
payload: {
|
||||
parentId,
|
||||
index: lastIndex,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
|
||||
emit({
|
||||
operation: "addTreeNode",
|
||||
spaceId: space.id,
|
||||
payload: {
|
||||
parentId,
|
||||
index: lastIndex,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
} catch (err) {
|
||||
throw new Error("Failed to create page");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
viewportRef.current
|
||||
@@ -267,15 +272,19 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
return (
|
||||
<Paper id="mention" shadow="md" py="xs" withBorder radius="md">
|
||||
<Text c="dimmed" size="sm" px="sm">
|
||||
{ t("No results") }
|
||||
{t("No results")}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
const hasUsers = renderItems.some((item) => item.entityType === "user");
|
||||
const hasPages = renderItems.some((item) => item.entityType === "page" && item.id !== null);
|
||||
const createPageItemData = renderItems.find((item) => item.entityType === "page" && item.id === null);
|
||||
const hasPages = renderItems.some(
|
||||
(item) => item.entityType === "page" && item.id !== null,
|
||||
);
|
||||
const createPageItemData = renderItems.find(
|
||||
(item) => item.entityType === "page" && item.id === null,
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper id="mention" shadow="md" withBorder radius="md" py={6}>
|
||||
@@ -283,7 +292,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
viewportRef={viewportRef}
|
||||
mah={350}
|
||||
w={popupWidth}
|
||||
scrollbars={"y"}
|
||||
scrollbarSize={6}
|
||||
styles={{ content: { minWidth: 0 } }}
|
||||
>
|
||||
{renderItems?.map((item, index) => {
|
||||
if (item.entityType === "header") {
|
||||
@@ -299,6 +310,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
pt={isFirst ? 2 : 4}
|
||||
pb={4}
|
||||
tt="uppercase"
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
@@ -323,9 +335,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
<AutoTooltipText size="sm" fw={500}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</AutoTooltipText>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
@@ -355,9 +367,14 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
</ActionIcon>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
<AutoTooltipText size="sm" fw={500} truncate>
|
||||
{item.label}
|
||||
</Text>
|
||||
</AutoTooltipText>
|
||||
{item.spaceName && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{item.spaceName}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
@@ -372,9 +389,12 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
{(hasUsers || hasPages) && <Divider my={6} />}
|
||||
<UnstyledButton
|
||||
data-item-index={renderItems.indexOf(createPageItemData)}
|
||||
onClick={() => selectItem(renderItems.indexOf(createPageItemData))}
|
||||
onClick={() =>
|
||||
selectItem(renderItems.indexOf(createPageItemData))
|
||||
}
|
||||
className={clsx(classes.menuBtn, {
|
||||
[classes.selectedItem]: renderItems.indexOf(createPageItemData) === selectedIndex,
|
||||
[classes.selectedItem]:
|
||||
renderItems.indexOf(createPageItemData) === selectedIndex,
|
||||
})}
|
||||
px="sm"
|
||||
>
|
||||
@@ -388,7 +408,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
<IconPlus size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: "hidden" }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{t("Create page")}: {createPageItemData.label}
|
||||
</Text>
|
||||
|
||||
@@ -106,7 +106,7 @@ const mentionRenderItems = () => {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
position: "absolute",
|
||||
zIndex: "9999",
|
||||
zIndex: "190",
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -54,12 +54,20 @@ export default function MentionView(props: NodeViewProps) {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{entityType === "page" && (
|
||||
{entityType === "page" && isError && (
|
||||
<Text component="span" c="dimmed" size="sm">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{entityType === "page" && !isError && (
|
||||
<Anchor
|
||||
component={Link}
|
||||
fw={500}
|
||||
to={
|
||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
|
||||
isShareRoute
|
||||
? shareSlugUrl
|
||||
: buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)
|
||||
}
|
||||
onClick={handleClick}
|
||||
underline="never"
|
||||
|
||||
@@ -26,4 +26,6 @@ export type MentionSuggestionItem =
|
||||
entityId: string;
|
||||
slugId: string;
|
||||
icon: string;
|
||||
spaceName?: string;
|
||||
spaceSlug?: string;
|
||||
};
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
IconCalendar,
|
||||
IconAppWindow,
|
||||
IconSitemap,
|
||||
IconColumns3,
|
||||
IconColumns2,
|
||||
IconTag,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
CommandProps,
|
||||
@@ -31,6 +34,8 @@ import { uploadAttachmentAction } from "@/features/editor/components/attachment/
|
||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||
import IconMermaid from "@/components/icons/icon-mermaid";
|
||||
import IconDrawio from "@/components/icons/icon-drawio";
|
||||
import { IconColumns4 } from "@/components/icons/icon-columns-4";
|
||||
import { IconColumns5 } from "@/components/icons/icon-columns-5";
|
||||
import {
|
||||
AirtableIcon,
|
||||
FigmaIcon,
|
||||
@@ -381,6 +386,20 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
description: "Insert inline status badge.",
|
||||
searchTerms: ["status", "badge", "label", "lozenge"],
|
||||
icon: IconTag,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setStatus({ text: "", color: "gray" })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Subpages (Child pages)",
|
||||
description: "List all subpages of the current page",
|
||||
@@ -390,6 +409,58 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "2 Columns",
|
||||
description: "Split content into two columns.",
|
||||
searchTerms: ["columns", "layout", "split", "side"],
|
||||
icon: IconColumns2,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertColumns({ layout: "two_equal" })
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: "3 Columns",
|
||||
description: "Split content into three columns.",
|
||||
searchTerms: ["columns", "layout", "split", "triple"],
|
||||
icon: IconColumns3,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertColumns({ layout: "three_equal" })
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: "4 Columns",
|
||||
description: "Split content into four columns.",
|
||||
searchTerms: ["columns", "layout", "split"],
|
||||
icon: IconColumns4,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertColumns({ layout: "four_equal" })
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: "5 Columns",
|
||||
description: "Split content into five columns.",
|
||||
searchTerms: ["columns", "layout", "split"],
|
||||
icon: IconColumns5,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertColumns({ layout: "five_equal" })
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: "Iframe embed",
|
||||
description: "Embed any Iframe",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Popover, TextInput, Group, Box } from "@mantine/core";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./status.module.css";
|
||||
import type { StatusColor } from "@docmost/editor-ext";
|
||||
|
||||
const STATUS_COLORS: { name: StatusColor; bg: string }[] = [
|
||||
{ name: "gray", bg: "var(--mantine-color-gray-4)" },
|
||||
{ name: "blue", bg: "var(--mantine-color-blue-4)" },
|
||||
{ name: "green", bg: "var(--mantine-color-green-4)" },
|
||||
{ name: "yellow", bg: "var(--mantine-color-yellow-4)" },
|
||||
{ name: "red", bg: "var(--mantine-color-red-4)" },
|
||||
{ name: "purple", bg: "var(--mantine-color-violet-4)" },
|
||||
];
|
||||
|
||||
const colorClassMap: Record<StatusColor, string> = {
|
||||
gray: classes.colorGray,
|
||||
blue: classes.colorBlue,
|
||||
green: classes.colorGreen,
|
||||
yellow: classes.colorYellow,
|
||||
red: classes.colorRed,
|
||||
purple: classes.colorPurple,
|
||||
};
|
||||
|
||||
export default function StatusView(props: NodeViewProps) {
|
||||
const { node, updateAttributes, deleteNode, editor, getPos } = props;
|
||||
const { text, color } = node.attrs as {
|
||||
text: string;
|
||||
color: StatusColor;
|
||||
};
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(text);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const storage = editor.storage?.status;
|
||||
if (storage?.autoOpen) {
|
||||
storage.autoOpen = false;
|
||||
setOpened(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setInputValue(text);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const debouncedUpdateAttributes = useDebouncedCallback(
|
||||
(val: string) => updateAttributes({ text: val }),
|
||||
100,
|
||||
);
|
||||
|
||||
const handleTextChange = (val: string) => {
|
||||
setInputValue(val);
|
||||
debouncedUpdateAttributes(val);
|
||||
};
|
||||
|
||||
const handleColorChange = (newColor: StatusColor) => {
|
||||
updateAttributes({ color: newColor });
|
||||
};
|
||||
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={(open) => {
|
||||
if (!open && !text) {
|
||||
deleteNode();
|
||||
return;
|
||||
}
|
||||
setOpened(open);
|
||||
}}
|
||||
width={220}
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="md"
|
||||
trapFocus
|
||||
>
|
||||
<Popover.Target>
|
||||
<span
|
||||
className={clsx(
|
||||
"status-badge",
|
||||
classes.status,
|
||||
colorClassMap[color],
|
||||
)}
|
||||
onClick={() => isEditable && setOpened(true)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{text || "SET STATUS"}
|
||||
</span>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) =>
|
||||
handleTextChange(e.currentTarget.value.toUpperCase())
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setOpened(false);
|
||||
editor.commands.focus(getPos() + node.nodeSize);
|
||||
}
|
||||
}}
|
||||
placeholder="Status text"
|
||||
size="sm"
|
||||
mb="xs"
|
||||
/>
|
||||
|
||||
<Group gap={6} justify="center">
|
||||
{STATUS_COLORS.map(({ name, bg }) => (
|
||||
<Box
|
||||
key={name}
|
||||
className={clsx(
|
||||
classes.swatch,
|
||||
color === name && classes.swatchActive,
|
||||
)}
|
||||
style={{ backgroundColor: bg }}
|
||||
onClick={() => handleColorChange(name)}
|
||||
>
|
||||
{color === name && <IconCheck size={14} />}
|
||||
</Box>
|
||||
))}
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.status {
|
||||
display: inline-block;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.6;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.colorGray {
|
||||
background-color: light-dark(rgb(223 223 215), rgba(168, 162, 158, 0.4));
|
||||
color: light-dark(#3d3d3d, var(--mantine-color-gray-3));
|
||||
}
|
||||
|
||||
.colorBlue {
|
||||
background-color: light-dark(rgb(191 227 253), rgba(37, 99, 235, 0.4));
|
||||
color: light-dark(#1a4d99, var(--mantine-color-blue-3));
|
||||
}
|
||||
|
||||
.colorGreen {
|
||||
background-color: light-dark(rgb(187 240 173), rgba(0, 138, 0, 0.4));
|
||||
color: light-dark(#135c13, var(--mantine-color-green-3));
|
||||
}
|
||||
|
||||
.colorYellow {
|
||||
background-color: light-dark(rgb(249 238 148), rgba(234, 179, 8, 0.4));
|
||||
color: light-dark(#6b5300, var(--mantine-color-yellow-3));
|
||||
}
|
||||
|
||||
.colorRed {
|
||||
background-color: light-dark(rgb(255 200 195), rgba(224, 0, 0, 0.4));
|
||||
color: light-dark(#a10000, var(--mantine-color-red-3));
|
||||
}
|
||||
|
||||
.colorPurple {
|
||||
background-color: light-dark(rgb(225 207 245), rgba(147, 51, 234, 0.4));
|
||||
color: light-dark(#5b21a6, var(--mantine-color-violet-3));
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.swatch:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.swatchActive {
|
||||
border-color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4));
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Background color")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Background color")}
|
||||
onClick={() => setOpened(!opened)}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TableBackgroundColor } from "./table-background-color";
|
||||
import { TableTextAlignment } from "./table-text-alignment";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export const TableCellMenu = React.memo(
|
||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||
@@ -69,14 +70,16 @@ export const TableCellMenu = React.memo(
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group>
|
||||
<div className={classes.toolbar}>
|
||||
<TableBackgroundColor editor={editor} />
|
||||
<TableTextAlignment editor={editor} />
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Merge cells")}>
|
||||
<ActionIcon
|
||||
onClick={mergeCells}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Merge cells")}
|
||||
>
|
||||
@@ -87,7 +90,7 @@ export const TableCellMenu = React.memo(
|
||||
<Tooltip position="top" label={t("Split cell")}>
|
||||
<ActionIcon
|
||||
onClick={splitCell}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Split cell")}
|
||||
>
|
||||
@@ -95,10 +98,12 @@ export const TableCellMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Delete column")}>
|
||||
<ActionIcon
|
||||
onClick={deleteColumn}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Delete column")}
|
||||
>
|
||||
@@ -109,7 +114,7 @@ export const TableCellMenu = React.memo(
|
||||
<Tooltip position="top" label={t("Delete row")}>
|
||||
<ActionIcon
|
||||
onClick={deleteRow}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Delete row")}
|
||||
>
|
||||
@@ -117,17 +122,19 @@ export const TableCellMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header cell")}>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderCell}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Toggle header cell")}
|
||||
>
|
||||
<IconTableRow size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
IconTrashX,
|
||||
} from "@tabler/icons-react";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
import { isCellSelection } from "@docmost/editor-ext";
|
||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export const TableMenu = React.memo(
|
||||
({ editor }: EditorMenuProps): JSX.Element => {
|
||||
@@ -30,6 +31,7 @@ export const TableMenu = React.memo(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isTextSelected(editor)) return false;
|
||||
return editor.isActive("table") && !isCellSelection(state.selection);
|
||||
},
|
||||
[editor]
|
||||
@@ -118,11 +120,11 @@ export const TableMenu = React.memo(
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Add left column")}>
|
||||
<ActionIcon
|
||||
onClick={addColumnLeft}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Add left column")}
|
||||
>
|
||||
@@ -133,7 +135,7 @@ export const TableMenu = React.memo(
|
||||
<Tooltip position="top" label={t("Add right column")}>
|
||||
<ActionIcon
|
||||
onClick={addColumnRight}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Add right column")}
|
||||
>
|
||||
@@ -144,7 +146,7 @@ export const TableMenu = React.memo(
|
||||
<Tooltip position="top" label={t("Delete column")}>
|
||||
<ActionIcon
|
||||
onClick={deleteColumn}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Delete column")}
|
||||
>
|
||||
@@ -152,10 +154,12 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Add row above")}>
|
||||
<ActionIcon
|
||||
onClick={addRowAbove}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Add row above")}
|
||||
>
|
||||
@@ -166,7 +170,7 @@ export const TableMenu = React.memo(
|
||||
<Tooltip position="top" label={t("Add row below")}>
|
||||
<ActionIcon
|
||||
onClick={addRowBelow}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Add row below")}
|
||||
>
|
||||
@@ -177,7 +181,7 @@ export const TableMenu = React.memo(
|
||||
<Tooltip position="top" label={t("Delete row")}>
|
||||
<ActionIcon
|
||||
onClick={deleteRow}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Delete row")}
|
||||
>
|
||||
@@ -185,10 +189,12 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header row")}>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderRow}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Toggle header row")}
|
||||
>
|
||||
@@ -199,7 +205,7 @@ export const TableMenu = React.memo(
|
||||
<Tooltip position="top" label={t("Toggle header column")}>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderColumn}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Toggle header column")}
|
||||
>
|
||||
@@ -207,18 +213,19 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Delete table")}>
|
||||
<ActionIcon
|
||||
onClick={deleteTable}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
color="red"
|
||||
aria-label={t("Delete table")}
|
||||
>
|
||||
<IconTrashX size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Text alignment")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Text alignment")}
|
||||
onClick={() => setOpened(!opened)}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import React, { useCallback } from "react";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import { useCallback } from "react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
IconLayoutAlignCenter,
|
||||
IconLayoutAlignLeft,
|
||||
IconLayoutAlignRight,
|
||||
IconDownload,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -32,7 +36,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
|
||||
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
|
||||
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
|
||||
width: videoAttrs?.width ? parseInt(videoAttrs.width) : null,
|
||||
src: videoAttrs?.src || null,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -70,7 +74,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const alignVideoLeft = useCallback(() => {
|
||||
const alignLeft = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
@@ -78,7 +82,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const alignVideoCenter = useCallback(() => {
|
||||
const alignCenter = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
@@ -86,7 +90,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const alignVideoRight = useCallback(() => {
|
||||
const alignRight = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
@@ -94,16 +98,18 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
(value: number) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setVideoWidth(value)
|
||||
.run();
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!editorState?.src) return;
|
||||
const url = getFileUrl(editorState.src);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
}, [editorState?.src]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
editor.commands.deleteSelection();
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
@@ -118,44 +124,67 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group className="actionIconGroup">
|
||||
<Tooltip position="top" label={t("Align left")}>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignVideoLeft}
|
||||
onClick={alignLeft}
|
||||
size="lg"
|
||||
aria-label={t("Align left")}
|
||||
variant={editorState?.isAlignLeft ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
||||
>
|
||||
<IconLayoutAlignLeft size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Align center")}>
|
||||
<Tooltip position="top" label={t("Align center")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignVideoCenter}
|
||||
onClick={alignCenter}
|
||||
size="lg"
|
||||
aria-label={t("Align center")}
|
||||
variant={editorState?.isAlignCenter ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
||||
>
|
||||
<IconLayoutAlignCenter size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Align right")}>
|
||||
<Tooltip position="top" label={t("Align right")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignVideoRight}
|
||||
onClick={alignRight}
|
||||
size="lg"
|
||||
aria-label={t("Align right")}
|
||||
variant={editorState?.isAlignRight ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
||||
>
|
||||
<IconLayoutAlignRight size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
|
||||
{editorState?.width && (
|
||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||
)}
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDownload}
|
||||
size="lg"
|
||||
aria-label={t("Download")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDelete}
|
||||
size="lg"
|
||||
aria-label={t("Delete")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
|
||||
@@ -15,6 +15,14 @@ const Command = Extension.create({
|
||||
command: ({ editor, range, props }) => {
|
||||
props.command({ editor, range, props });
|
||||
},
|
||||
allow: ({ state, range }) => {
|
||||
const $from = state.doc.resolve(range.from);
|
||||
// Disable emoji menu inside code blocks
|
||||
if ($from.parent.type.name === "codeBlock") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
} as Partial<SuggestionOptions>,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { markInputRule } from "@tiptap/core";
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Code } from "@tiptap/extension-code";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||
import { Placeholder, CharacterCount } from "@tiptap/extensions";
|
||||
@@ -43,16 +45,28 @@ import {
|
||||
Highlight,
|
||||
UniqueID,
|
||||
SharedStorage,
|
||||
Columns,
|
||||
Column,
|
||||
Status
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
userColors,
|
||||
} from "@/features/editor/extensions/utils.ts";
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import {
|
||||
createImageHandle,
|
||||
imageResizeClasses,
|
||||
} from "@/features/editor/components/image/image-resize-handles.ts";
|
||||
import {
|
||||
createResizeHandle,
|
||||
buildResizeClasses,
|
||||
} from "@/features/editor/components/common/node-resize-handles.ts";
|
||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
||||
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||
import StatusView from "@/features/editor/components/status/status-view.tsx";
|
||||
import VideoView from "@/features/editor/components/video/video-view.tsx";
|
||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||
@@ -91,6 +105,7 @@ lowlight.register("fortran", fortran);
|
||||
lowlight.register("haskell", haskell);
|
||||
lowlight.register("scala", scala);
|
||||
|
||||
// @ts-ignore
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
@@ -102,10 +117,24 @@ export const mainExtensions = [
|
||||
color: "#70CFF8",
|
||||
},
|
||||
codeBlock: false,
|
||||
code: {
|
||||
HTMLAttributes: {
|
||||
spellcheck: false,
|
||||
},
|
||||
code: false,
|
||||
}),
|
||||
// Override TipTap's Code extension to fix the inline code input rule.
|
||||
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
|
||||
// before the opening backtick as part of the match, causing markInputRule
|
||||
// to delete it. Using a lookbehind avoids including it in the match.
|
||||
Code.configure({
|
||||
HTMLAttributes: {
|
||||
spellcheck: false,
|
||||
},
|
||||
}).extend({
|
||||
addInputRules() {
|
||||
return [
|
||||
markInputRule({
|
||||
find: /(?:^|(?<=[^`]))`([^`]+)`(?!`)$/,
|
||||
type: this.type,
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
SharedStorage,
|
||||
@@ -115,7 +144,7 @@ export const mainExtensions = [
|
||||
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
placeholder: ({ editor, node, pos }) => {
|
||||
if (node.type.name === "heading") {
|
||||
return i18n.t("Heading {{level}}", { level: node.attrs.level });
|
||||
}
|
||||
@@ -123,6 +152,17 @@ export const mainExtensions = [
|
||||
return i18n.t("Toggle title");
|
||||
}
|
||||
if (node.type.name === "paragraph") {
|
||||
const $pos = editor.state.doc.resolve(pos);
|
||||
const parentName = $pos.parent.type.name;
|
||||
if (
|
||||
parentName === "column" ||
|
||||
parentName === "tableCell" ||
|
||||
parentName === "tableHeader" ||
|
||||
parentName === "callout" ||
|
||||
parentName === "blockquote"
|
||||
) {
|
||||
return i18n.t("Write...");
|
||||
}
|
||||
return i18n.t('Write anything. Enter "/" for commands');
|
||||
}
|
||||
},
|
||||
@@ -200,9 +240,29 @@ export const mainExtensions = [
|
||||
TiptapImage.configure({
|
||||
view: ImageView,
|
||||
allowBase64: false,
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createImageHandle,
|
||||
className: imageResizeClasses,
|
||||
},
|
||||
}),
|
||||
TiptapVideo.configure({
|
||||
view: VideoView,
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createResizeHandle,
|
||||
className: buildResizeClasses("node-video"),
|
||||
},
|
||||
}),
|
||||
Callout.configure({
|
||||
view: CalloutView,
|
||||
@@ -221,9 +281,29 @@ export const mainExtensions = [
|
||||
}),
|
||||
Drawio.configure({
|
||||
view: DrawioView,
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createResizeHandle,
|
||||
className: buildResizeClasses("node-drawio"),
|
||||
},
|
||||
}),
|
||||
Excalidraw.configure({
|
||||
view: ExcalidrawView,
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createResizeHandle,
|
||||
className: buildResizeClasses("node-excalidraw"),
|
||||
},
|
||||
}),
|
||||
Embed.configure({
|
||||
view: EmbedView,
|
||||
@@ -231,6 +311,9 @@ export const mainExtensions = [
|
||||
Subpages.configure({
|
||||
view: SubpagesView,
|
||||
}),
|
||||
Status.configure({
|
||||
view: StatusView,
|
||||
}),
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
@@ -253,6 +336,8 @@ export const mainExtensions = [
|
||||
};
|
||||
},
|
||||
}).configure(),
|
||||
Columns,
|
||||
Column,
|
||||
] as any;
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
|
||||
@@ -17,6 +17,14 @@ const Command = Extension.create({
|
||||
command: ({ editor, range, props }) => {
|
||||
props.command({ editor, range, props });
|
||||
},
|
||||
allow: ({ state, range }) => {
|
||||
const $from = state.doc.resolve(range.from);
|
||||
// Disable slash menu inside code blocks
|
||||
if ($from.parent.type.name === 'codeBlock') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
} as Partial<SuggestionOptions>,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -67,6 +67,7 @@ import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -416,6 +417,7 @@ export default function PageEditor({
|
||||
<SubpagesMenu editor={editor} />
|
||||
<ExcalidrawMenu editor={editor} />
|
||||
<DrawioMenu editor={editor} />
|
||||
<ColumnsMenu editor={editor} />
|
||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -66,12 +66,14 @@ export default function ReadonlyPageEditor({
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={titleExtensions}
|
||||
content={title}
|
||||
></EditorProvider>
|
||||
<div className="page-title">
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={titleExtensions}
|
||||
content={title}
|
||||
></EditorProvider>
|
||||
</div>
|
||||
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
div[data-type="columns"] {
|
||||
display: flex;
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.5em 0;
|
||||
}
|
||||
|
||||
div[data-type="columns"] > div[data-type="column"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
div[data-type="columns"] > div[data-type="column"]:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
|
||||
border-left: 1px solid transparent;
|
||||
padding-left: 1rem;
|
||||
transition: border 0.3s;
|
||||
}
|
||||
|
||||
div[data-type="columns"]:hover
|
||||
> div[data-type="column"]
|
||||
+ div[data-type="column"],
|
||||
div[data-type="columns"].has-focus
|
||||
> div[data-type="column"]
|
||||
+ div[data-type="column"] {
|
||||
border-left: 1px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-7));
|
||||
}
|
||||
|
||||
/* Confluence layout types */
|
||||
div[data-type="columns"][data-layout="two_left_sidebar"]
|
||||
> div[data-type="column"]:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
div[data-type="columns"][data-layout="two_left_sidebar"]
|
||||
> div[data-type="column"]:last-child {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
div[data-type="columns"][data-layout="two_right_sidebar"]
|
||||
> div[data-type="column"]:first-child {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
div[data-type="columns"][data-layout="two_right_sidebar"]
|
||||
> div[data-type="column"]:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
div[data-type="columns"][data-layout="three_left_wide"]
|
||||
> div[data-type="column"]:first-child {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
div[data-type="columns"][data-layout="three_right_wide"]
|
||||
> div[data-type="column"]:last-child {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
div[data-type="columns"][data-layout="three_with_sidebars"]
|
||||
> div[data-type="column"]:first-child,
|
||||
div[data-type="columns"][data-layout="three_with_sidebars"]
|
||||
> div[data-type="column"]:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
div[data-type="columns"][data-layout="three_with_sidebars"]
|
||||
> div[data-type="column"]:nth-child(2) {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
/* Stack columns vertically on small viewports */
|
||||
@media (max-width: 680px) {
|
||||
div[data-type="columns"] {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
div[data-type="columns"]:hover
|
||||
> div[data-type="column"]
|
||||
+ div[data-type="column"] {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Wide width mode — extends columns to full container width */
|
||||
div[data-type="columns"][data-width-mode="wide"] {
|
||||
margin-left: -3rem;
|
||||
margin-right: -3rem;
|
||||
width: calc(100% + 6rem);
|
||||
}
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
div[data-type="columns"][data-width-mode="wide"] {
|
||||
margin-left: -1rem;
|
||||
margin-right: -1rem;
|
||||
width: calc(100% + 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
div[data-type="columns"] {
|
||||
flex-direction: row !important;
|
||||
}
|
||||
|
||||
div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
|
||||
border-left: none;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
div[data-type="columns"][data-width-mode="wide"] {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,34 @@
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h3 {
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h1 strong,
|
||||
h2 strong,
|
||||
h3 strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
margin-top: 1.875rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@@ -82,13 +109,9 @@
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-left: 25px;
|
||||
padding-right: 25px;
|
||||
border-left: 2px solid var(--mantine-color-gray-6);
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-8)
|
||||
);
|
||||
padding-left: 1rem;
|
||||
border-left: 3px solid
|
||||
light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -126,13 +149,18 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.node-callout {
|
||||
div[style*="white-space: inherit;"] {
|
||||
> :first-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.react-renderer.node-callout div[style*="white-space: inherit;"] > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.react-renderer.node-callout div[style*="white-space: inherit;"] > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.react-renderer.node-callout + .react-renderer.node-callout {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
.selection {
|
||||
@@ -242,3 +270,10 @@
|
||||
.actionIconGroup {
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.page-title .ProseMirror h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
@@ -13,3 +13,5 @@
|
||||
@import "./mention.css";
|
||||
@import "./ordered-list.css";
|
||||
@import "./highlight.css";
|
||||
@import "./columns.css";
|
||||
@import "./status.css";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.node-status {
|
||||
margin-right: 2px;
|
||||
|
||||
&.ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&.selection,
|
||||
& *::selection {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.ProseMirror-selectednode .status-badge {
|
||||
box-shadow: 0 0 0 2px light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-7));
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
@@ -171,11 +171,14 @@ export function TitleEditor({
|
||||
}, [pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
// honor user default page edit mode preference
|
||||
if (userPageEditMode && titleEditor && editable) {
|
||||
if (userPageEditMode === PageEditMode.Edit) {
|
||||
titleEditor.setEditable(true);
|
||||
} else if (userPageEditMode === PageEditMode.Read) {
|
||||
if (titleEditor) {
|
||||
if (userPageEditMode && editable) {
|
||||
if (userPageEditMode === PageEditMode.Edit) {
|
||||
titleEditor.setEditable(true);
|
||||
} else if (userPageEditMode === PageEditMode.Read) {
|
||||
titleEditor.setEditable(false);
|
||||
}
|
||||
} else {
|
||||
titleEditor.setEditable(false);
|
||||
}
|
||||
}
|
||||
@@ -241,15 +244,17 @@ export function TitleEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorContent
|
||||
editor={titleEditor}
|
||||
onKeyDown={(event) => {
|
||||
// First handle the search hotkey
|
||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||
<div className="page-title">
|
||||
<EditorContent
|
||||
editor={titleEditor}
|
||||
onKeyDown={(event) => {
|
||||
// First handle the search hotkey
|
||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||
|
||||
// Then handle other key events
|
||||
handleTitleKeyDown(event);
|
||||
}}
|
||||
/>
|
||||
// Then handle other key events
|
||||
handleTitleKeyDown(event);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts";
|
||||
import { useForm } from "@mantine/form";
|
||||
import * as z from "zod";
|
||||
import { z } from "zod/v4";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { zodResolver } from 'mantine-form-zod-resolver';
|
||||
import { zod4Resolver } from 'mantine-form-zod-resolver';
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().trim().min(2).max(100),
|
||||
@@ -22,7 +22,7 @@ export function CreateGroupForm() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
useUpdateGroupMutation,
|
||||
} from "@/features/group/queries/group-query.ts";
|
||||
import { useForm } from "@mantine/form";
|
||||
import * as z from "zod";
|
||||
import { z } from "zod/v4";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(100),
|
||||
@@ -35,7 +35,7 @@ export function EditGroupForm({ onClose }: EditGroupFormProps) {
|
||||
}, [isSuccess]);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: group?.name,
|
||||
description: group?.description,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
||||
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatMemberCount } from "@/lib";
|
||||
@@ -10,11 +9,14 @@ import Paginate from "@/components/common/paginate.tsx";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { getGroupMembers } from "@/features/group/services/group-service.ts";
|
||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||
import NoTableResults from "@/components/common/no-table-results.tsx";
|
||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
||||
|
||||
export default function GroupList() {
|
||||
const { t } = useTranslation();
|
||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||
const { data, isLoading } = useGetGroupsQuery({ cursor });
|
||||
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
|
||||
const { data, isLoading } = useGetGroupsQuery({ cursor, query: search });
|
||||
|
||||
const prefetchGroupMembers = (groupId: string) => {
|
||||
queryClient.prefetchQuery({
|
||||
@@ -25,6 +27,7 @@ export default function GroupList() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchInput onSearch={handleSearch} />
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm" layout="fixed">
|
||||
<Table.Thead>
|
||||
@@ -35,7 +38,8 @@ export default function GroupList() {
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((group: IGroup, index: number) => (
|
||||
{data?.items.length > 0 ? (
|
||||
data?.items.map((group: IGroup, index: number) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td onMouseEnter={() => prefetchGroupMembers(group.id)}>
|
||||
<Anchor
|
||||
@@ -77,7 +81,10 @@ export default function GroupList() {
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<NoTableResults colSpan={2} />
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
@@ -46,6 +46,10 @@ export function NotificationItem({
|
||||
return t("resolved a comment");
|
||||
case "page.user_mention":
|
||||
return t("mentioned you on a page");
|
||||
case "page.permission_granted":
|
||||
return notification.data?.role === "writer"
|
||||
? t("gave you edit access to a page")
|
||||
: t("gave you view access to a page");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ export type NotificationType =
|
||||
| "comment.user_mention"
|
||||
| "comment.created"
|
||||
| "comment.resolved"
|
||||
| "page.user_mention";
|
||||
| "page.user_mention"
|
||||
| "page.permission_granted";
|
||||
|
||||
export type INotification = {
|
||||
id: string;
|
||||
|
||||
@@ -39,7 +39,7 @@ import { formattedDate } from "@/lib/time.ts";
|
||||
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import ShareModal from "@/features/share/components/share-modal.tsx";
|
||||
import { PageShareModal } from "@/ee/page-permission";
|
||||
|
||||
interface PageHeaderMenuProps {
|
||||
readOnly?: boolean;
|
||||
@@ -75,7 +75,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
|
||||
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
||||
|
||||
<ShareModal readOnly={readOnly} />
|
||||
<PageShareModal readOnly={readOnly} />
|
||||
|
||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
|
||||
@@ -34,6 +34,7 @@ import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { getFileTaskById } from "@/features/file-task/services/file-task-service.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import bytes from "bytes";
|
||||
|
||||
interface PageImportModalProps {
|
||||
spaceId: string;
|
||||
@@ -100,6 +101,17 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxSize = getFileImportSizeLimit();
|
||||
if (selectedFile.size > maxSize) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: t("File exceeds the {{limit}} import limit", {
|
||||
limit: formatBytes(maxSize),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
onClose();
|
||||
|
||||
@@ -230,11 +242,26 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
}, 3000);
|
||||
}, [fileTaskId]);
|
||||
|
||||
const maxSingleFileSize = bytes("20mb");
|
||||
|
||||
const handleFileUpload = async (selectedFiles: File[]) => {
|
||||
if (!selectedFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oversizedFiles = selectedFiles.filter(
|
||||
(f) => f.size > maxSingleFileSize,
|
||||
);
|
||||
if (oversizedFiles.length > 0) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: t("File exceeds the {{limit}} import limit", {
|
||||
limit: formatBytes(maxSingleFileSize),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
const alert = notifications.show({
|
||||
|
||||
@@ -155,7 +155,9 @@ export function useDeletePageMutation() {
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
||||
const message =
|
||||
error["response"]?.data?.message || t("Failed to delete page");
|
||||
notifications.show({ message, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,6 +158,15 @@ export async function importZip(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getAttachmentInfo(
|
||||
attachmentId: string,
|
||||
): Promise<IAttachment> {
|
||||
const req = await api.post<IAttachment>("/files/info", {
|
||||
attachmentId,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
pageId: string,
|
||||
|
||||
@@ -31,9 +31,12 @@ import TrashPageContentModal from "@/features/page/trash/components/trash-page-c
|
||||
import { UserInfo } from "@/components/common/user-info.tsx";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
|
||||
export default function Trash() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const { spaceSlug } = useParams();
|
||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
@@ -108,7 +111,7 @@ export default function Trash() {
|
||||
|
||||
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
|
||||
<Text size="sm">
|
||||
{t("Pages in trash will be permanently deleted after 30 days.")}
|
||||
{t("Pages in trash will be permanently deleted after {{count}} days.", { count: workspace?.trashRetentionDays ?? 30 })}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
|
||||
@@ -53,11 +53,7 @@ import {
|
||||
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
||||
import {
|
||||
useDisclosure,
|
||||
useElementSize,
|
||||
useMergedRef,
|
||||
} from "@mantine/hooks";
|
||||
import { useDisclosure, useElementSize, useMergedRef } from "@mantine/hooks";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { dfs } from "react-arborist/dist/module/utils";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
@@ -244,9 +240,19 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
{isRootReady && rootElement.current && (
|
||||
<Tree
|
||||
data={filteredData}
|
||||
disableDrag={readOnly}
|
||||
disableDrop={readOnly}
|
||||
disableEdit={readOnly}
|
||||
disableDrag={
|
||||
readOnly
|
||||
? true
|
||||
: (data) => {
|
||||
return data.canEdit === false;
|
||||
}
|
||||
}
|
||||
disableDrop={
|
||||
readOnly
|
||||
? true
|
||||
: ({ parentNode }) => parentNode?.data?.canEdit === false
|
||||
}
|
||||
disableEdit={readOnly ? true : (data) => data.canEdit === false}
|
||||
{...controllers}
|
||||
width={width}
|
||||
height={rootElement.current.clientHeight}
|
||||
@@ -417,7 +423,9 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
<IconFileDescription size="18" />
|
||||
)
|
||||
}
|
||||
readOnly={tree.props.disableEdit as boolean}
|
||||
readOnly={
|
||||
tree.props.disableEdit === true || node.data.canEdit === false
|
||||
}
|
||||
removeEmojiAction={handleRemoveEmoji}
|
||||
/>
|
||||
</div>
|
||||
@@ -427,7 +435,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} />
|
||||
|
||||
{!tree.props.disableEdit && (
|
||||
{tree.props.disableEdit !== true && node.data.canEdit !== false && (
|
||||
<CreateNode
|
||||
node={node}
|
||||
treeApi={tree}
|
||||
@@ -532,6 +540,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
||||
parentPageId: duplicatedPage.parentPageId,
|
||||
icon: duplicatedPage.icon,
|
||||
hasChildren: duplicatedPage.hasChildren,
|
||||
canEdit: true,
|
||||
children: [],
|
||||
};
|
||||
|
||||
@@ -610,55 +619,56 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
||||
{t("Export page")}
|
||||
</Menu.Item>
|
||||
|
||||
{!(treeApi.props.disableEdit as boolean) && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDuplicatePage();
|
||||
}}
|
||||
>
|
||||
{t("Duplicate")}
|
||||
</Menu.Item>
|
||||
{treeApi.props.disableEdit !== true &&
|
||||
node.data.canEdit !== false && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDuplicatePage();
|
||||
}}
|
||||
>
|
||||
{t("Duplicate")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowRight size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openMovePageModal();
|
||||
}}
|
||||
>
|
||||
{t("Move")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowRight size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openMovePageModal();
|
||||
}}
|
||||
>
|
||||
{t("Move")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openCopyPageModal();
|
||||
}}
|
||||
>
|
||||
{t("Copy to space")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openCopyPageModal();
|
||||
}}
|
||||
>
|
||||
{t("Copy to space")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
c="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||
}}
|
||||
>
|
||||
{t("Move to trash")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
c="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||
}}
|
||||
>
|
||||
{t("Move to trash")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
|
||||
@@ -7,5 +7,6 @@ export type SpaceTreeNode = {
|
||||
spaceId: string;
|
||||
parentPageId: string;
|
||||
hasChildren: boolean;
|
||||
canEdit?: boolean;
|
||||
children: SpaceTreeNode[];
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||
hasChildren: page.hasChildren,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -18,10 +18,15 @@ export interface IPage {
|
||||
deletedAt: Date;
|
||||
position: string;
|
||||
hasChildren: boolean;
|
||||
canEdit?: boolean;
|
||||
creator: ICreator;
|
||||
lastUpdatedBy: ILastUpdatedBy;
|
||||
deletedBy: IDeletedBy;
|
||||
space: Partial<ISpace>;
|
||||
permissions?: {
|
||||
canEdit: boolean;
|
||||
hasRestriction: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface ICreator {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Table, Group, Text, Anchor } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconWorld } from "@tabler/icons-react";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
|
||||
@@ -11,6 +12,7 @@ import ShareActionMenu from "@/features/share/components/share-action-menu.tsx";
|
||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getPageIcon } from "@/lib";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import classes from "./share.module.css";
|
||||
|
||||
export default function ShareList() {
|
||||
@@ -18,6 +20,10 @@ export default function ShareList() {
|
||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||
const { data, isLoading } = useGetSharesQuery({ cursor });
|
||||
|
||||
if (!isLoading && data?.items.length === 0) {
|
||||
return <EmptyState icon={IconWorld} title={t("No shared pages")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
|
||||
@@ -69,19 +69,20 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
setIsPagePublic(value);
|
||||
|
||||
if (value) {
|
||||
createShareMutation.mutateAsync({
|
||||
pageId: pageId,
|
||||
includeSubPages: true,
|
||||
searchIndexing: false,
|
||||
});
|
||||
setIsPagePublic(value);
|
||||
} else {
|
||||
if (share && share.id) {
|
||||
deleteShareMutation.mutateAsync(share.id);
|
||||
setIsPagePublic(value);
|
||||
try {
|
||||
if (value) {
|
||||
await createShareMutation.mutateAsync({
|
||||
pageId: pageId,
|
||||
includeSubPages: true,
|
||||
searchIndexing: false,
|
||||
});
|
||||
} else if (share && share.id) {
|
||||
await deleteShareMutation.mutateAsync(share.id);
|
||||
}
|
||||
} catch {
|
||||
setIsPagePublic(!value);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,20 +90,28 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const value = event.currentTarget.checked;
|
||||
updateShareMutation.mutateAsync({
|
||||
shareId: share.id,
|
||||
includeSubPages: value,
|
||||
});
|
||||
try {
|
||||
await updateShareMutation.mutateAsync({
|
||||
shareId: share.id,
|
||||
includeSubPages: value,
|
||||
});
|
||||
} catch {
|
||||
// query invalidation will revert the UI
|
||||
}
|
||||
};
|
||||
|
||||
const handleIndexSearchChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const value = event.currentTarget.checked;
|
||||
updateShareMutation.mutateAsync({
|
||||
shareId: share.id,
|
||||
searchIndexing: value,
|
||||
});
|
||||
try {
|
||||
await updateShareMutation.mutateAsync({
|
||||
shareId: share.id,
|
||||
searchIndexing: value,
|
||||
});
|
||||
} catch {
|
||||
// query invalidation will revert the UI
|
||||
}
|
||||
};
|
||||
|
||||
const shareLink = useMemo(
|
||||
|
||||
@@ -90,7 +90,10 @@ export function useCreateShareMutation() {
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: t("Failed to share page"), color: "red" });
|
||||
notifications.show({
|
||||
message: error?.["response"]?.data?.message || t("Failed to share page"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,3 +66,20 @@ export function buildSharedPageTree(
|
||||
|
||||
return sortTree(tree);
|
||||
}
|
||||
|
||||
|
||||
// Recursively checks if a page exists in the shared page tree.
|
||||
export function isPageInTree(
|
||||
tree: SharedPageTreeNode[],
|
||||
pageSlugId: string,
|
||||
): boolean {
|
||||
for (const node of tree) {
|
||||
if (node.slugId === pageSlugId) {
|
||||
return true;
|
||||
}
|
||||
if (isPageInTree(node.children, pageSlugId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||
import React, { useEffect } from "react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import * as z from "zod";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod/v4";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
||||
import { computeSpaceSlug } from "@/lib";
|
||||
@@ -30,7 +30,7 @@ export function CreateSpaceForm() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
validate: zod4Resolver(formSchema),
|
||||
validateInputOnChange: ["slug"],
|
||||
initialValues: {
|
||||
name: "",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import * as z from "zod";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod/v4";
|
||||
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -29,7 +30,7 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: space?.name,
|
||||
description: space?.description || "",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface MultiMemberSelectProps {
|
||||
value?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
}
|
||||
|
||||
@@ -33,7 +34,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||
</Group>
|
||||
);
|
||||
|
||||
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
||||
export function MultiMemberSelect({ value, onChange }: MultiMemberSelectProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
||||
@@ -85,6 +86,7 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
||||
return (
|
||||
<MultiSelect
|
||||
data={data}
|
||||
value={value}
|
||||
renderOption={renderMultiSelectOption}
|
||||
hidePickedOptions
|
||||
maxDropdownHeight={300}
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function SpaceSettingsModal({
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div style={{ height: rem(600) }}>
|
||||
<Tabs defaultValue="members">
|
||||
<Tabs color="dark" defaultValue="members">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab fw={500} value="general">
|
||||
{t("Settings")}
|
||||
@@ -63,7 +63,7 @@ export default function SpaceSettingsModal({
|
||||
|
||||
<Tabs.Panel value="general">
|
||||
<ScrollArea h={580} scrollbarSize={5} pr={8}>
|
||||
<div style={{ paddingBottom: "100px"}}>
|
||||
<div style={{ paddingBottom: "100px" }}>
|
||||
<SpaceDetails
|
||||
spaceId={space?.id}
|
||||
readOnly={spaceAbility.cannot(
|
||||
@@ -72,7 +72,6 @@ export default function SpaceSettingsModal({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</ScrollArea>
|
||||
</Tabs.Panel>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Group, Table, Text } from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
@@ -10,11 +9,14 @@ import Paginate from "@/components/common/paginate.tsx";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||
import NoTableResults from "@/components/common/no-table-results.tsx";
|
||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
||||
|
||||
export default function SpaceList() {
|
||||
const { t } = useTranslation();
|
||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||
const { data, isLoading } = useGetSpacesQuery({ cursor });
|
||||
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
|
||||
const { data, isLoading } = useGetSpacesQuery({ cursor, query: search });
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
|
||||
|
||||
@@ -25,6 +27,7 @@ export default function SpaceList() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchInput onSearch={handleSearch} />
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm" layout="fixed">
|
||||
<Table.Thead>
|
||||
@@ -35,7 +38,8 @@ export default function SpaceList() {
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((space, index) => (
|
||||
{data?.items.length > 0 ? (
|
||||
data?.items.map((space, index) => (
|
||||
<Table.Tr
|
||||
key={index}
|
||||
style={{ cursor: "pointer" }}
|
||||
@@ -66,7 +70,10 @@ export default function SpaceList() {
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<NoTableResults colSpan={2} />
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import {
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Table,
|
||||
Text,
|
||||
Menu,
|
||||
ActionIcon,
|
||||
ScrollArea,
|
||||
} from "@mantine/core";
|
||||
import React from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { IconDots } from "@tabler/icons-react";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import {
|
||||
useChangeSpaceMemberRoleMutation,
|
||||
useRemoveSpaceMemberMutation,
|
||||
useSpaceMembersQuery,
|
||||
useSpaceMembersInfiniteQuery,
|
||||
} from "@/features/space/queries/space-query.ts";
|
||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
||||
import { IRemoveSpaceMember } from "@/features/space/types/space.types.ts";
|
||||
@@ -24,9 +26,7 @@ import {
|
||||
} from "@/features/space/types/space-role-data.ts";
|
||||
import { formatMemberCount } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||
|
||||
type MemberType = "user" | "group";
|
||||
@@ -41,12 +41,32 @@ export default function SpaceMembersList({
|
||||
readOnly,
|
||||
}: SpaceMembersProps) {
|
||||
const { t } = useTranslation();
|
||||
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
|
||||
const { data, isLoading } = useSpaceMembersQuery(spaceId, {
|
||||
cursor,
|
||||
limit: 100,
|
||||
query: search,
|
||||
});
|
||||
const [search, setSearch] = useState("");
|
||||
const handleSearch = useCallback((query: string) => setSearch(query), []);
|
||||
|
||||
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } =
|
||||
useSpaceMembersInfiniteQuery(spaceId, search);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ root: viewportRef.current, threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const removeSpaceMember = useRemoveSpaceMemberMutation();
|
||||
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
|
||||
|
||||
@@ -111,10 +131,12 @@ export default function SpaceMembersList({
|
||||
onConfirm: () => onRemove(memberId, type),
|
||||
});
|
||||
|
||||
const members = data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchInput onSearch={handleSearch} />
|
||||
<ScrollArea h={450}>
|
||||
<ScrollArea h={450} viewportRef={viewportRef}>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing={8}>
|
||||
<Table.Thead>
|
||||
@@ -126,7 +148,7 @@ export default function SpaceMembersList({
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((member, index) => (
|
||||
{members.map((member, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
@@ -154,19 +176,24 @@ export default function SpaceMembersList({
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<RoleSelectMenu
|
||||
roles={spaceRoleData}
|
||||
roleName={getSpaceRoleLabel(member.role)}
|
||||
onChange={(newRole) =>
|
||||
handleRoleChange(
|
||||
member.id,
|
||||
member.type,
|
||||
newRole,
|
||||
member.role,
|
||||
)
|
||||
}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
{readOnly ? (
|
||||
<Text fz="sm">
|
||||
{t(getSpaceRoleLabel(member.role))}
|
||||
</Text>
|
||||
) : (
|
||||
<RoleSelectMenu
|
||||
roles={spaceRoleData}
|
||||
roleName={getSpaceRoleLabel(member.role)}
|
||||
onChange={(newRole) =>
|
||||
handleRoleChange(
|
||||
member.id,
|
||||
member.type,
|
||||
newRole,
|
||||
member.role,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
@@ -202,16 +229,15 @@ export default function SpaceMembersList({
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</ScrollArea>
|
||||
|
||||
{data?.items.length > 0 && (
|
||||
<Paginate
|
||||
hasPrevPage={data?.meta?.hasPrevPage}
|
||||
hasNextPage={data?.meta?.hasNextPage}
|
||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||
onPrev={goPrev}
|
||||
/>
|
||||
)}
|
||||
<div ref={sentinelRef} style={{ height: 1 }} />
|
||||
|
||||
{isFetchingNextPage && (
|
||||
<Center py="xs">
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
IChangeSpaceMemberRole,
|
||||
IRemoveSpaceMember,
|
||||
ISpace,
|
||||
ISpaceMember,
|
||||
} from "@/features/space/types/space.types";
|
||||
import {
|
||||
addSpaceMember,
|
||||
@@ -190,15 +190,19 @@ export function useDeleteSpaceMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSpaceMembersQuery(
|
||||
export function useSpaceMembersInfiniteQuery(
|
||||
spaceId: string,
|
||||
params?: QueryParams,
|
||||
): UseQueryResult<IPagination<ISpaceMember>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["spaceMembers", spaceId, params],
|
||||
queryFn: () => getSpaceMembers(spaceId, params),
|
||||
query?: string,
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["spaceMembers", spaceId, query],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
|
||||
enabled: !!spaceId,
|
||||
placeholderData: keepPreviousData,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { focusAtom } from "jotai-optics";
|
||||
import * as z from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { z } from "zod/v4";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { updateUser } from "@/features/user/services/user-service.ts";
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
@@ -25,7 +26,7 @@ export default function AccountNameForm() {
|
||||
const [, setUser] = useAtom(userAtom);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: currentUser?.user.name,
|
||||
},
|
||||
|
||||
@@ -6,13 +6,14 @@ import {
|
||||
Group,
|
||||
PasswordInput,
|
||||
} from "@mantine/core";
|
||||
import * as z from "zod";
|
||||
import { z } from "zod/v4";
|
||||
import { useState } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import * as React from "react";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ChangeEmail() {
|
||||
@@ -48,9 +49,9 @@ export default function ChangeEmail() {
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string({ required_error: "New email is required" }).email(),
|
||||
email: z.email({ error: "New email is required" }),
|
||||
password: z
|
||||
.string({ required_error: "your current password is required" })
|
||||
.string({ error: "your current password is required" })
|
||||
.min(8),
|
||||
});
|
||||
|
||||
@@ -61,7 +62,7 @@ function ChangeEmailForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
password: "",
|
||||
email: "",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Button, Group, Text, Modal, PasswordInput } from "@mantine/core";
|
||||
import * as z from "zod";
|
||||
import { z } from "zod/v4";
|
||||
import { useState } from "react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import * as React from "react";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { changePassword } from "@/features/auth/services/auth-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -42,9 +43,9 @@ export default function ChangePassword() {
|
||||
|
||||
const formSchema = z.object({
|
||||
oldPassword: z
|
||||
.string({ required_error: "your current password is required" })
|
||||
.string({ error: "your current password is required" })
|
||||
.min(8),
|
||||
newPassword: z.string({ required_error: "New password is required" }).min(8),
|
||||
newPassword: z.string({ error: "New password is required" }).min(8),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
@@ -57,7 +58,7 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
oldPassword: "",
|
||||
newPassword: "",
|
||||
|
||||
@@ -40,12 +40,17 @@ export function PageStateSegmentedControl({
|
||||
const [value, setValue] = useState(pageEditMode);
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (value: string) => {
|
||||
const updatedUser = await updateUser({ pageEditMode: value });
|
||||
setValue(value);
|
||||
setUser(updatedUser);
|
||||
async (newValue: string) => {
|
||||
const prevValue = value;
|
||||
setValue(newValue);
|
||||
try {
|
||||
const updatedUser = await updateUser({ pageEditMode: newValue });
|
||||
setUser(updatedUser);
|
||||
} catch {
|
||||
setValue(prevValue);
|
||||
}
|
||||
},
|
||||
[user, setUser],
|
||||
[value, setUser],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -39,9 +39,13 @@ export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
const updatedUser = await updateUser({ fullPageWidth: value });
|
||||
setChecked(value);
|
||||
setUser(updatedUser);
|
||||
try {
|
||||
const updatedUser = await updateUser({ fullPageWidth: value });
|
||||
setUser(updatedUser);
|
||||
} catch {
|
||||
setChecked(!value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
export type InvalidateEvent = {
|
||||
operation: "invalidate";
|
||||
@@ -8,9 +9,28 @@ export type InvalidateEvent = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type InvalidateCommentsEvent = {
|
||||
operation: "invalidateComment";
|
||||
export type CommentCreatedEvent = {
|
||||
operation: "commentCreated";
|
||||
pageId: string;
|
||||
comment: IComment;
|
||||
};
|
||||
|
||||
export type CommentUpdatedEvent = {
|
||||
operation: "commentUpdated";
|
||||
pageId: string;
|
||||
comment: IComment;
|
||||
};
|
||||
|
||||
export type CommentDeletedEvent = {
|
||||
operation: "commentDeleted";
|
||||
pageId: string;
|
||||
commentId: string;
|
||||
};
|
||||
|
||||
export type CommentResolvedEvent = {
|
||||
operation: "commentResolved";
|
||||
pageId: string;
|
||||
comment: IComment;
|
||||
};
|
||||
|
||||
export type UpdateEvent = {
|
||||
@@ -65,27 +85,15 @@ export type RefetchRootTreeNodeEvent = {
|
||||
spaceId: string;
|
||||
};
|
||||
|
||||
export type ResolveCommentEvent = {
|
||||
operation: "resolveComment";
|
||||
pageId: string;
|
||||
commentId: string;
|
||||
resolved: boolean;
|
||||
resolvedAt?: Date;
|
||||
resolvedById?: string;
|
||||
resolvedBy?: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebSocketEvent =
|
||||
| InvalidateEvent
|
||||
| InvalidateCommentsEvent
|
||||
| CommentCreatedEvent
|
||||
| CommentUpdatedEvent
|
||||
| CommentDeletedEvent
|
||||
| CommentResolvedEvent
|
||||
| UpdateEvent
|
||||
| DeleteEvent
|
||||
| AddTreeNodeEvent
|
||||
| MoveTreeNodeEvent
|
||||
| DeleteTreeNodeEvent
|
||||
| RefetchRootTreeNodeEvent
|
||||
| ResolveCommentEvent;
|
||||
| RefetchRootTreeNodeEvent;
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
invalidateOnUpdatePage,
|
||||
} from "../page/queries/page-query";
|
||||
import { RQ_KEY } from "../comment/queries/comment-query";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
export const useQuerySubscription = () => {
|
||||
@@ -32,11 +31,66 @@ export const useQuerySubscription = () => {
|
||||
queryKey: [...data.entity, data.id].filter(Boolean),
|
||||
});
|
||||
break;
|
||||
case "invalidateComment":
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: RQ_KEY(data.pageId),
|
||||
});
|
||||
case "commentCreated": {
|
||||
const createCache = queryClient.getQueryData(
|
||||
RQ_KEY(data.pageId),
|
||||
) as InfiniteData<IPagination<IComment>> | undefined;
|
||||
|
||||
if (createCache && createCache.pages.length > 0) {
|
||||
const alreadyExists = createCache.pages.some((page) =>
|
||||
page.items.some((c) => c.id === data.comment.id),
|
||||
);
|
||||
if (alreadyExists) break;
|
||||
|
||||
const lastIdx = createCache.pages.length - 1;
|
||||
queryClient.setQueryData(RQ_KEY(data.pageId), {
|
||||
...createCache,
|
||||
pages: createCache.pages.map((page, i) =>
|
||||
i === lastIdx
|
||||
? { ...page, items: [...page.items, data.comment] }
|
||||
: page,
|
||||
),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "commentUpdated":
|
||||
case "commentResolved": {
|
||||
const updateCache = queryClient.getQueryData(
|
||||
RQ_KEY(data.pageId),
|
||||
) as InfiniteData<IPagination<IComment>> | undefined;
|
||||
|
||||
if (updateCache) {
|
||||
queryClient.setQueryData(RQ_KEY(data.pageId), {
|
||||
...updateCache,
|
||||
pages: updateCache.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((comment) =>
|
||||
comment.id === data.comment.id ? data.comment : comment,
|
||||
),
|
||||
})),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "commentDeleted": {
|
||||
const deleteCache = queryClient.getQueryData(
|
||||
RQ_KEY(data.pageId),
|
||||
) as InfiniteData<IPagination<IComment>> | undefined;
|
||||
|
||||
if (deleteCache) {
|
||||
queryClient.setQueryData(RQ_KEY(data.pageId), {
|
||||
...deleteCache,
|
||||
pages: deleteCache.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter(
|
||||
(comment) => comment.id !== data.commentId,
|
||||
),
|
||||
})),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "addTreeNode":
|
||||
invalidateOnCreatePage(data.payload.data);
|
||||
break;
|
||||
@@ -103,30 +157,6 @@ export const useQuerySubscription = () => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "resolveComment": {
|
||||
const currentComments = queryClient.getQueryData(
|
||||
RQ_KEY(data.pageId),
|
||||
) as IPagination<IComment>;
|
||||
|
||||
if (currentComments && currentComments.items) {
|
||||
const updatedComments = currentComments.items.map((comment) =>
|
||||
comment.id === data.commentId
|
||||
? {
|
||||
...comment,
|
||||
resolvedAt: data.resolvedAt,
|
||||
resolvedById: data.resolvedById,
|
||||
resolvedBy: data.resolvedBy
|
||||
}
|
||||
: comment,
|
||||
);
|
||||
|
||||
queryClient.setQueryData(RQ_KEY(data.pageId), {
|
||||
...currentComments,
|
||||
items: updatedComments,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [queryClient, socket]);
|
||||
|
||||
+3
-1
@@ -26,7 +26,9 @@ export default function InviteActionMenu({ invitationId }: Props) {
|
||||
const handleCopyLink = async (invitationId: string) => {
|
||||
try {
|
||||
const link = await getInviteLink({ invitationId });
|
||||
clipboard.copy(link.inviteLink);
|
||||
const url = new URL(link.inviteLink);
|
||||
const inviteLink = `${window.location.origin}${url.pathname}${url.search}`;
|
||||
clipboard.copy(inviteLink);
|
||||
notifications.show({ message: t("Link copied") });
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
|
||||
+57
-3
@@ -1,19 +1,57 @@
|
||||
import { Menu, ActionIcon, Text } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { IconDots, IconTrash } from "@tabler/icons-react";
|
||||
import { IconDots, IconTrash, IconUserOff, IconUserCheck } from "@tabler/icons-react";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useDeleteWorkspaceMemberMutation } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import {
|
||||
useDeleteWorkspaceMemberMutation,
|
||||
useDeactivateWorkspaceMemberMutation,
|
||||
useActivateWorkspaceMemberMutation,
|
||||
} from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
deactivatedAt: Date | null;
|
||||
}
|
||||
export default function MemberActionMenu({ userId }: Props) {
|
||||
export default function MemberActionMenu({ userId, deactivatedAt }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
|
||||
const deactivateMutation = useDeactivateWorkspaceMemberMutation();
|
||||
const activateMutation = useActivateWorkspaceMemberMutation();
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const isDeactivated = !!deactivatedAt;
|
||||
|
||||
const onDeactivate = async () => {
|
||||
await deactivateMutation.mutateAsync({ userId });
|
||||
};
|
||||
|
||||
const onActivate = async () => {
|
||||
await activateMutation.mutateAsync({ userId });
|
||||
};
|
||||
|
||||
const openDeactivateModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: isDeactivated ? t("Activate member") : t("Deactivate member"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{isDeactivated
|
||||
? t("Are you sure you want to activate this workspace member?")
|
||||
: t(
|
||||
"Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.",
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: {
|
||||
confirm: isDeactivated ? t("Activate") : t("Deactivate"),
|
||||
cancel: t("Cancel"),
|
||||
},
|
||||
confirmProps: { color: isDeactivated ? "blue" : "orange" },
|
||||
onConfirm: isDeactivated ? onActivate : onDeactivate,
|
||||
});
|
||||
|
||||
const onRevoke = async () => {
|
||||
await deleteWorkspaceMemberMutation.mutateAsync({ userId });
|
||||
};
|
||||
@@ -51,6 +89,22 @@ export default function MemberActionMenu({ userId }: Props) {
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={openDeactivateModal}
|
||||
leftSection={
|
||||
isDeactivated ? (
|
||||
<IconUserCheck size={16} />
|
||||
) : (
|
||||
<IconUserOff size={16} />
|
||||
)
|
||||
}
|
||||
disabled={!isAdmin}
|
||||
>
|
||||
{isDeactivated ? t("Activate member") : t("Deactivate member")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
c="red"
|
||||
onClick={openRevokeModal}
|
||||
|
||||
+24
-10
@@ -85,20 +85,34 @@ export default function WorkspaceMembersTable() {
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">{t("Active")}</Badge>
|
||||
{user.deactivatedAt ? (
|
||||
<Badge variant="light" color="orange">
|
||||
{t("Deactivated")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="light">{t("Active")}</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RoleSelectMenu
|
||||
roles={assignableUserRoles}
|
||||
roleName={getUserRoleLabel(user.role)}
|
||||
onChange={(newRole) =>
|
||||
handleRoleChange(user.id, user.role, newRole)
|
||||
}
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
{isAdmin ? (
|
||||
<RoleSelectMenu
|
||||
roles={assignableUserRoles}
|
||||
roleName={getUserRoleLabel(user.role)}
|
||||
onChange={(newRole) =>
|
||||
handleRoleChange(user.id, user.role, newRole)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Text fz="sm">{t(getUserRoleLabel(user.role))}</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{isAdmin && <MemberActionMenu userId={user.id} />}
|
||||
{isAdmin && (
|
||||
<MemberActionMenu
|
||||
userId={user.id}
|
||||
deactivatedAt={user.deactivatedAt}
|
||||
/>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import * as z from "zod";
|
||||
import { z } from "zod/v4";
|
||||
import { useState } from "react";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||
import { TextInput, Button } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -24,7 +24,7 @@ export default function WorkspaceNameForm() {
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: workspace?.name,
|
||||
},
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
getWorkspacePublicData,
|
||||
getAppVersion,
|
||||
deleteWorkspaceMember,
|
||||
deactivateWorkspaceMember,
|
||||
activateWorkspaceMember,
|
||||
} from "@/features/workspace/services/workspace-service";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
@@ -81,6 +83,52 @@ export function useDeleteWorkspaceMemberMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeactivateWorkspaceMemberMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
void,
|
||||
Error,
|
||||
{
|
||||
userId: string;
|
||||
}
|
||||
>({
|
||||
mutationFn: (data) => deactivateWorkspaceMember(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workspaceMembers"],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivateWorkspaceMemberMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
void,
|
||||
Error,
|
||||
{
|
||||
userId: string;
|
||||
}
|
||||
>({
|
||||
mutationFn: (data) => activateWorkspaceMember(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workspaceMembers"],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useChangeMemberRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -42,6 +42,18 @@ export async function deleteWorkspaceMember(data: {
|
||||
await api.post("/workspace/members/delete", data);
|
||||
}
|
||||
|
||||
export async function deactivateWorkspaceMember(data: {
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
await api.post("/workspace/members/deactivate", data);
|
||||
}
|
||||
|
||||
export async function activateWorkspaceMember(data: {
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
await api.post("/workspace/members/activate", data);
|
||||
}
|
||||
|
||||
export async function updateWorkspace(data: Partial<IWorkspace>) {
|
||||
const req = await api.post<IWorkspace>("/workspace/update", data);
|
||||
return req.data;
|
||||
|
||||
@@ -25,16 +25,25 @@ export interface IWorkspace {
|
||||
aiSearch?: boolean;
|
||||
generativeAi?: boolean;
|
||||
disablePublicSharing?: boolean;
|
||||
mcpEnabled?: boolean;
|
||||
trashRetentionDays?: number;
|
||||
restrictApiToAdmins?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSettings {
|
||||
ai?: IWorkspaceAiSettings;
|
||||
sharing?: IWorkspaceSharingSettings;
|
||||
api?: IWorkspaceApiSettings;
|
||||
}
|
||||
|
||||
export interface IWorkspaceApiSettings {
|
||||
restrictToAdmins?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceAiSettings {
|
||||
search?: boolean;
|
||||
generative?: boolean;
|
||||
mcp?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSharingSettings {
|
||||
|
||||
Reference in New Issue
Block a user