Compare commits

...

8 Commits

Author SHA1 Message Date
fa398e7d54 Add second plan 2025-05-16 17:00:43 -07:00
e3ba817723 feat: comment editor emoji picker and ctrl+enter action (#1121)
* commenteditor-emoji-picker

* capture Mac command key
* remove tooltip

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-05-16 20:01:27 +01:00
b0491d5da4 feat: create new page from mention (#1153)
* init

* create page in relative parent root
2025-05-16 19:15:11 +01:00
1c200dbd0f fix(table-hover): adjust row height to prevent unexpected scrollbar on hover (#1124)
fix: Hover table style height error causing scrollbar to appear #1108
2025-05-16 16:26:05 +01:00
fb7e4a7956 fix: copy/move select (#1174) 2025-05-16 16:24:31 +01:00
1413033568 feat: realtime comments (#1144)
* init

* fix: close bubblemenu after comment and wait before scroll

* scroll to comment when click

* highlight comment animation
2025-05-16 16:18:23 +01:00
00f4588c21 fix title update (#1154) 2025-05-16 16:11:29 +01:00
3a75251e75 fix alignment in shared page (#1123) 2025-05-16 16:00:47 +01:00
21 changed files with 273 additions and 53 deletions

View File

@ -1,5 +1,6 @@
export enum BillingPlan { export enum BillingPlan {
STANDARD = "standard", STANDARD = "standard",
BUSINESS = "business",
} }
export interface IBilling { export interface IBilling {

View File

@ -2,14 +2,18 @@ import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { BillingPlan } from "@/ee/billing/types/billing.types.ts"; import { BillingPlan } from "@/ee/billing/types/billing.types.ts";
export const usePlan = () => { const usePlan = () => {
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const isStandard = const isStandard =
typeof workspace?.plan === "string" && typeof workspace?.plan === "string" &&
workspace?.plan.toLowerCase() === BillingPlan.STANDARD.toLowerCase(); workspace?.plan.toLowerCase() === BillingPlan.STANDARD.toLowerCase();
return { isStandard }; const isBusiness =
typeof workspace?.plan === "string" &&
workspace?.plan.toLowerCase() === BillingPlan.BUSINESS.toLowerCase();
return { isStandard, isBusiness };
}; };
export default usePlan; export default usePlan;

View File

@ -10,11 +10,13 @@ import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx"; import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx"; import useLicense from "@/ee/hooks/use-license.tsx";
import usePlan from "@/ee/hooks/use-plan.tsx";
export default function Security() { export default function Security() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const { hasLicenseKey } = useLicense(); const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
if (!isAdmin) { if (!isAdmin) {
return null; return null;
@ -35,8 +37,7 @@ export default function Security() {
Single sign-on (SSO) Single sign-on (SSO)
</Title> </Title>
{/*TODO: revisit when we add a second plan */} {(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
{!isCloud() && hasLicenseKey ? (
<> <>
<EnforceSso /> <EnforceSso />
<Divider my="lg" /> <Divider my="lg" />

View File

@ -1,4 +1,4 @@
import { Button, Group } from "@mantine/core"; import { Button, Group, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type CommentActionsProps = { type CommentActionsProps = {

View File

@ -15,6 +15,7 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
import { useEditor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
interface CommentDialogProps { interface CommentDialogProps {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
@ -35,6 +36,8 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
const createCommentMutation = useCreateCommentMutation(); const createCommentMutation = useCreateCommentMutation();
const { isPending } = createCommentMutation; const { isPending } = createCommentMutation;
const emit = useQueryEmit();
const handleDialogClose = () => { const handleDialogClose = () => {
setShowCommentPopup(false); setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run(); editor.chain().focus().unsetCommentDecoration().run();
@ -63,11 +66,23 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
.run(); .run();
setActiveCommentId(createdComment.id); setActiveCommentId(createdComment.id);
//unselect text to close bubble menu
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
setAsideState({ tab: "comments", isAsideOpen: true }); setAsideState({ tab: "comments", isAsideOpen: true });
setTimeout(() => { setTimeout(() => {
const selector = `div[data-comment-id="${createdComment.id}"]`; const selector = `div[data-comment-id="${createdComment.id}"]`;
const commentElement = document.querySelector(selector); const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView(); commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
editor.view.dispatch(
editor.state.tr.scrollIntoView()
);
}, 400);
emit({
operation: "invalidateComment",
pageId: pageId,
}); });
} finally { } finally {
setShowCommentPopup(false); setShowCommentPopup(false);
@ -109,6 +124,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
<CommentEditor <CommentEditor
onUpdate={handleCommentEditorChange} onUpdate={handleCommentEditorChange}
onSave={handleAddComment}
placeholder={t("Write a comment")} placeholder={t("Write a comment")}
editable={true} editable={true}
autofocus={true} autofocus={true}

View File

@ -8,10 +8,12 @@ import { useFocusWithin } from "@mantine/hooks";
import clsx from "clsx"; import clsx from "clsx";
import { forwardRef, useEffect, useImperativeHandle } from "react"; import { forwardRef, useEffect, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command";
interface CommentEditorProps { interface CommentEditorProps {
defaultContent?: any; defaultContent?: any;
onUpdate?: any; onUpdate?: any;
onSave?: any;
editable: boolean; editable: boolean;
placeholder?: string; placeholder?: string;
autofocus?: boolean; autofocus?: boolean;
@ -22,6 +24,7 @@ const CommentEditor = forwardRef(
{ {
defaultContent, defaultContent,
onUpdate, onUpdate,
onSave,
editable, editable,
placeholder, placeholder,
autofocus, autofocus,
@ -42,7 +45,35 @@ const CommentEditor = forwardRef(
}), }),
Underline, Underline,
Link, Link,
EmojiCommand,
], ],
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
if (
[
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Enter",
].includes(event.key)
) {
const emojiCommand = document.querySelector("#emoji-command");
if (emojiCommand) {
return true;
}
}
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
if (onSave) onSave();
return true;
}
},
},
},
onUpdate({ editor }) { onUpdate({ editor }) {
if (onUpdate) onUpdate(editor.getJSON()); if (onUpdate) onUpdate(editor.getJSON());
}, },
@ -53,6 +84,10 @@ const CommentEditor = forwardRef(
autofocus: (autofocus && "end") || false, autofocus: (autofocus && "end") || false,
}); });
useEffect(() => {
commentEditor.commands.setContent(defaultContent);
}, [defaultContent]);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
if (autofocus) { if (autofocus) {

View File

@ -1,5 +1,5 @@
import { Group, Text, Box } from "@mantine/core"; import { Group, Text, Box } from "@mantine/core";
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import classes from "./comment.module.css"; import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
@ -15,12 +15,14 @@ import {
import { IComment } from "@/features/comment/types/comment.types"; import { IComment } from "@/features/comment/types/comment.types";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
interface CommentListItemProps { interface CommentListItemProps {
comment: IComment; comment: IComment;
pageId: string;
} }
function CommentListItem({ comment }: CommentListItemProps) { function CommentListItem({ comment, pageId }: CommentListItemProps) {
const { hovered, ref } = useHover(); const { hovered, ref } = useHover();
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -29,6 +31,11 @@ function CommentListItem({ comment }: CommentListItemProps) {
const updateCommentMutation = useUpdateCommentMutation(); const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const emit = useQueryEmit();
useEffect(() => {
setContent(comment.content)
}, [comment]);
async function handleUpdateComment() { async function handleUpdateComment() {
try { try {
@ -39,6 +46,11 @@ function CommentListItem({ comment }: CommentListItemProps) {
}; };
await updateCommentMutation.mutateAsync(commentToUpdate); await updateCommentMutation.mutateAsync(commentToUpdate);
setIsEditing(false); setIsEditing(false);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) { } catch (error) {
console.error("Failed to update comment:", error); console.error("Failed to update comment:", error);
} finally { } finally {
@ -50,11 +62,27 @@ function CommentListItem({ comment }: CommentListItemProps) {
try { try {
await deleteCommentMutation.mutateAsync(comment.id); await deleteCommentMutation.mutateAsync(comment.id);
editor?.commands.unsetComment(comment.id); editor?.commands.unsetComment(comment.id);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) { } catch (error) {
console.error("Failed to delete comment:", error); console.error("Failed to delete comment:", error);
} }
} }
function handleCommentClick(comment: IComment) {
const el = document.querySelector(`.comment-mark[data-comment-id="${comment.id}"]`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("comment-highlight");
setTimeout(() => {
el.classList.remove("comment-highlight");
}, 3000);
}
}
function handleEditToggle() { function handleEditToggle() {
setIsEditing(true); setIsEditing(true);
} }
@ -99,7 +127,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
<div> <div>
{!comment.parentCommentId && comment?.selection && ( {!comment.parentCommentId && comment?.selection && (
<Box className={classes.textSelection}> <Box className={classes.textSelection} onClick={() => handleCommentClick(comment)}>
<Text size="sm">{comment?.selection}</Text> <Text size="sm">{comment?.selection}</Text>
</Box> </Box>
)} )}
@ -112,6 +140,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
defaultContent={content} defaultContent={content}
editable={true} editable={true}
onUpdate={(newContent: any) => setContent(newContent)} onUpdate={(newContent: any) => setContent(newContent)}
onSave={handleUpdateComment}
autofocus={true} autofocus={true}
/> />

View File

@ -14,6 +14,7 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
function CommentList() { function CommentList() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -26,6 +27,7 @@ function CommentList() {
} = useCommentsQuery({ pageId: page?.id, limit: 100 }); } = useCommentsQuery({ pageId: page?.id, limit: 100 });
const createCommentMutation = useCreateCommentMutation(); const createCommentMutation = useCreateCommentMutation();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const emit = useQueryEmit();
const handleAddReply = useCallback( const handleAddReply = useCallback(
async (commentId: string, content: string) => { async (commentId: string, content: string) => {
@ -38,6 +40,11 @@ function CommentList() {
}; };
await createCommentMutation.mutateAsync(commentData); await createCommentMutation.mutateAsync(commentData);
emit({
operation: "invalidateComment",
pageId: page?.id,
});
} catch (error) { } catch (error) {
console.error("Failed to post comment:", error); console.error("Failed to post comment:", error);
} finally { } finally {
@ -59,8 +66,8 @@ function CommentList() {
data-comment-id={comment.id} data-comment-id={comment.id}
> >
<div> <div>
<CommentListItem comment={comment} /> <CommentListItem comment={comment} pageId={page?.id} />
<MemoizedChildComments comments={comments} parentId={comment.id} /> <MemoizedChildComments comments={comments} parentId={comment.id} pageId={page?.id} />
</div> </div>
<Divider my={4} /> <Divider my={4} />
@ -99,8 +106,9 @@ function CommentList() {
interface ChildCommentsProps { interface ChildCommentsProps {
comments: IPagination<IComment>; comments: IPagination<IComment>;
parentId: string; parentId: string;
pageId: string;
} }
const ChildComments = ({ comments, parentId }: ChildCommentsProps) => { const ChildComments = ({ comments, parentId, pageId }: ChildCommentsProps) => {
const getChildComments = useCallback( const getChildComments = useCallback(
(parentId: string) => (parentId: string) =>
comments.items.filter( comments.items.filter(
@ -113,10 +121,11 @@ const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
<div> <div>
{getChildComments(parentId).map((childComment) => ( {getChildComments(parentId).map((childComment) => (
<div key={childComment.id}> <div key={childComment.id}>
<CommentListItem comment={childComment} /> <CommentListItem comment={childComment} pageId={pageId} />
<MemoizedChildComments <MemoizedChildComments
comments={comments} comments={comments}
parentId={childComment.id} parentId={childComment.id}
pageId={pageId}
/> />
</div> </div>
))} ))}
@ -142,6 +151,7 @@ const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
<CommentEditor <CommentEditor
ref={commentEditorRef} ref={commentEditorRef}
onUpdate={setContent} onUpdate={setContent}
onSave={handleSave}
editable={true} editable={true}
/> />
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />} {focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}

View File

@ -11,22 +11,25 @@
border-left: 2px solid var(--mantine-color-gray-6); border-left: 2px solid var(--mantine-color-gray-6);
padding: 8px; padding: 8px;
background: var(--mantine-color-gray-light); background: var(--mantine-color-gray-light);
cursor: pointer;
} }
.commentEditor { .commentEditor {
.focused { .focused {
border-radius: var(--mantine-radius-sm);
box-shadow: 0 0 0 2px var(--mantine-color-blue-3); box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
} }
.ProseMirror :global(.ProseMirror){ .ProseMirror :global(.ProseMirror){
border-radius: var(--mantine-radius-sm);
max-width: 100%; max-width: 100%;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
max-height: 20vh; max-height: 20vh;
padding-left: 6px; padding-left: 6px;
padding-right: 6px; padding-right: 6px;
margin-top: 2px; margin-top: 10px;
margin-bottom: 2px; margin-bottom: 2px;
overflow: hidden auto; overflow: hidden auto;
} }

View File

@ -33,7 +33,7 @@ const renderEmojiItems = () => {
showOnCreate: true, showOnCreate: true,
interactive: true, interactive: true,
trigger: "manual", trigger: "manual",
placement: "bottom-start", placement: "bottom",
}); });
}, },
onStart: (props: { onStart: (props: {

View File

@ -3,6 +3,7 @@ import React, {
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
@ -18,7 +19,7 @@ import {
import clsx from "clsx"; import clsx from "clsx";
import classes from "./mention.module.css"; import classes from "./mention.module.css";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { IconFileDescription } from "@tabler/icons-react"; import { IconFileDescription, IconPlus } from "@tabler/icons-react";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { v7 as uuid7 } from "uuid"; import { v7 as uuid7 } from "uuid";
@ -28,14 +29,28 @@ import {
MentionListProps, MentionListProps,
MentionSuggestionItem, MentionSuggestionItem,
} from "@/features/editor/components/mention/mention.type.ts"; } 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 { 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";
const MentionList = forwardRef<any, MentionListProps>((props, ref) => { const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(1); const [selectedIndex, setSelectedIndex] = useState(1);
const viewportRef = useRef<HTMLDivElement>(null); const viewportRef = useRef<HTMLDivElement>(null);
const { spaceSlug } = useParams(); const { pageSlug, spaceSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useSpaceQuery(spaceSlug); const { data: space } = useSpaceQuery(spaceSlug);
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]); const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
const { t } = useTranslation();
const [data, setData] = useAtom(treeDataAtom);
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation();
const emit = useQueryEmit();
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({ const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
query: props.query, query: props.query,
@ -45,12 +60,23 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
limit: 10, limit: 10,
}); });
const createPageItem = (label: string) : MentionSuggestionItem => {
return {
id: null,
label: label,
entityType: "page",
entityId: null,
slugId: null,
icon: null,
}
}
useEffect(() => { useEffect(() => {
if (suggestion && !isLoading) { if (suggestion && !isLoading) {
let items: MentionSuggestionItem[] = []; let items: MentionSuggestionItem[] = [];
if (suggestion?.users?.length > 0) { if (suggestion?.users?.length > 0) {
items.push({ entityType: "header", label: "Users" }); items.push({ entityType: "header", label: t("Users") });
items = items.concat( items = items.concat(
suggestion.users.map((user) => ({ suggestion.users.map((user) => ({
@ -64,7 +90,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
} }
if (suggestion?.pages?.length > 0) { if (suggestion?.pages?.length > 0) {
items.push({ entityType: "header", label: "Pages" }); items.push({ entityType: "header", label: t("Pages") });
items = items.concat( items = items.concat(
suggestion.pages.map((page) => ({ suggestion.pages.map((page) => ({
id: uuid7(), id: uuid7(),
@ -76,6 +102,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
})), })),
); );
} }
items.push(createPageItem(props.query));
setRenderItems(items); setRenderItems(items);
// update editor storage // update editor storage
@ -96,7 +123,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
creatorId: currentUser?.user.id, creatorId: currentUser?.user.id,
}); });
} }
if (item.entityType === "page") { if (item.entityType === "page" && item.id!==null) {
props.command({ props.command({
id: item.id, id: item.id,
label: item.label || "Untitled", label: item.label || "Untitled",
@ -106,6 +133,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
creatorId: currentUser?.user.id, creatorId: currentUser?.user.id,
}); });
} }
if (item.entityType === "page" && item.id===null) {
createPage(item.label);
}
} }
}, },
[renderItems], [renderItems],
@ -167,6 +197,58 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
}, },
})); }));
const createPage = async (title: string) => {
const payload: { spaceId: string; parentPageId?: string; title: string } = {
spaceId: space.id,
parentPageId: page.id || null,
title: title
};
let createdPage: IPage;
try {
createdPage = await createPageMutation.mutateAsync(payload);
const parentId = page.id || null;
const data = {
id: createdPage.id,
slugId: createdPage.slugId,
name: createdPage.title,
position: createdPage.position,
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
children: [],
} as any;
const lastIndex = tree.data.length;
tree.create({ parentId, index: lastIndex, data });
setData(tree.data);
props.command({
id: uuid7(),
label: createdPage.title || "Untitled",
entityType: "page",
entityId: createdPage.id,
slugId: createdPage.slugId,
creatorId: currentUser?.user.id,
});
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: space.id,
payload: {
parentId,
index: lastIndex,
data,
},
});
}, 50);
} catch (err) {
throw new Error("Failed to create page");
}
}
// if no results and enter what to do? // if no results and enter what to do?
useEffect(() => { useEffect(() => {
@ -178,7 +260,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
if (renderItems.length === 0) { if (renderItems.length === 0) {
return ( return (
<Paper shadow="md" p="xs" withBorder> <Paper shadow="md" p="xs" withBorder>
No results { t("No results") }
</Paper> </Paper>
); );
} }
@ -248,14 +330,14 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
color="gray" color="gray"
size={18} size={18}
> >
<IconFileDescription size={18} /> { (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
</ActionIcon> </ActionIcon>
)} )}
</ActionIcon> </ActionIcon>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
{item.label} { (item.id) ? item.label : t("Create page") + ': ' + item.label }
</Text> </Text>
</div> </div>
</Group> </Group>

View File

@ -219,9 +219,12 @@ export default function PageEditor({
setActiveCommentId(commentId); setActiveCommentId(commentId);
setAsideState({ tab: "comments", isAsideOpen: true }); setAsideState({ tab: "comments", isAsideOpen: true });
const selector = `div[data-comment-id="${commentId}"]`; //wait if aside is closed
const commentElement = document.querySelector(selector); setTimeout(() => {
commentElement?.scrollIntoView(); const selector = `div[data-comment-id="${commentId}"]`;
const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 400);
}; };
useEffect(() => { useEffect(() => {

View File

@ -144,6 +144,19 @@
border-bottom: 2px solid rgb(166, 158, 12); border-bottom: 2px solid rgb(166, 158, 12);
} }
.comment-highlight {
animation: flash-highlight 3s ease-out;
}
@keyframes flash-highlight {
0% {
background-color: #ff4d4d;
}
100% {
background-color: rgba(255, 215, 0, 0.14);
}
}
.resize-cursor { .resize-cursor {
cursor: ew-resize; cursor: ew-resize;
cursor: col-resize; cursor: col-resize;

View File

@ -47,7 +47,7 @@
.column-resize-handle { .column-resize-handle {
background-color: #adf; background-color: #adf;
bottom: -2px; bottom: -1px;
position: absolute; position: absolute;
right: -2px; right: -2px;
pointer-events: none; pointer-events: none;

View File

@ -10,7 +10,7 @@ import {
pageEditorAtom, pageEditorAtom,
titleEditorAtom, titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms"; } from "@/features/editor/atoms/editor-atoms";
import { useUpdatePageMutation } from "@/features/page/queries/page-query"; import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
import { useDebouncedCallback } from "@mantine/hooks"; import { useDebouncedCallback } from "@mantine/hooks";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
@ -38,7 +38,7 @@ export function TitleEditor({
editable, editable,
}: TitleEditorProps) { }: TitleEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { mutateAsync: updatePageMutationAsync } = useUpdatePageMutation(); const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
const pageEditor = useAtomValue(pageEditorAtom); const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom);
const emit = useQueryEmit(); const emit = useQueryEmit();
@ -94,7 +94,7 @@ export function TitleEditor({
return; return;
} }
updatePageMutationAsync({ updateTitlePageMutationAsync({
pageId: pageId, pageId: pageId,
title: titleEditor.getText(), title: titleEditor.getText(),
}).then((page) => { }).then((page) => {
@ -106,6 +106,10 @@ export function TitleEditor({
payload: { title: page.title, slugId: page.slugId }, payload: { title: page.title, slugId: page.slugId },
}; };
if (page.title !== titleEditor.getText()) return;
updatePageData(page);
localEmitter.emit("message", event); localEmitter.emit("message", event);
emit(event); emit(event);
}); });

View File

@ -63,28 +63,36 @@ export function useCreatePageMutation() {
}); });
} }
export function useUpdatePageMutation() { export function updatePageData(data: IPage) {
const queryClient = useQueryClient(); const pageBySlug = queryClient.getQueryData<IPage>([
"pages",
data.slugId,
]);
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
if (pageBySlug) {
queryClient.setQueryData(["pages", data.slugId], {
...pageBySlug,
...data,
});
}
if (pageById) {
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
}
}
export function useUpdateTitlePageMutation() {
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => updatePage(data),
});
}
export function useUpdatePageMutation() {
return useMutation<IPage, Error, Partial<IPageInput>>({ return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => updatePage(data), mutationFn: (data) => updatePage(data),
onSuccess: (data) => { onSuccess: (data) => {
const pageBySlug = queryClient.getQueryData<IPage>([ updatePage(data);
"pages",
data.slugId,
]);
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
if (pageBySlug) {
queryClient.setQueryData(["pages", data.slugId], {
...pageBySlug,
...data,
});
}
if (pageById) {
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
}
}, },
}); });
} }

View File

@ -81,7 +81,7 @@ export function SpaceSelect({
nothingFoundMessage={t("No space found")} nothingFoundMessage={t("No space found")}
limit={50} limit={50}
checkIconPosition="right" checkIconPosition="right"
comboboxProps={{ width, withinPortal: false }} comboboxProps={{ width, withinPortal: true, position: "bottom" }}
dropdownOpened={opened} dropdownOpened={opened}
/> />
); );

View File

@ -7,6 +7,11 @@ export type InvalidateEvent = {
id?: string; id?: string;
}; };
export type InvalidateCommentsEvent = {
operation: "invalidateComment";
pageId: string;
};
export type UpdateEvent = { export type UpdateEvent = {
operation: "updateOne"; operation: "updateOne";
spaceId: string; spaceId: string;
@ -52,4 +57,4 @@ export type DeleteTreeNodeEvent = {
} }
}; };
export type WebSocketEvent = InvalidateEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent; export type WebSocketEvent = InvalidateEvent | InvalidateCommentsEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent;

View File

@ -3,6 +3,7 @@ import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { WebSocketEvent } from "@/features/websocket/types"; import { WebSocketEvent } from "@/features/websocket/types";
import { RQ_KEY } from "../comment/queries/comment-query";
export const useQuerySubscription = () => { export const useQuerySubscription = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -21,6 +22,11 @@ export const useQuerySubscription = () => {
queryKey: [...data.entity, data.id].filter(Boolean), queryKey: [...data.entity, data.id].filter(Boolean),
}); });
break; break;
case "invalidateComment":
queryClient.invalidateQueries({
queryKey: RQ_KEY(data.pageId),
});
break;
case "updateOne": case "updateOne":
entity = data.entity[0]; entity = data.entity[0];
if (entity === "pages") { if (entity === "pages") {

View File

@ -46,7 +46,7 @@ export const tiptapExtensions = [
codeBlock: false, codeBlock: false,
}), }),
Comment, Comment,
TextAlign, TextAlign.configure({ types: ["heading", "paragraph"] }),
TaskList, TaskList,
TaskItem, TaskItem,
Underline, Underline,