Compare commits

..

15 Commits

Author SHA1 Message Date
Philipinho e62d97a455 fix mantine avatar color regression 2026-06-20 17:56:01 +01:00
Philipinho 02901ef2be fix(theme): size badges to their content 2026-06-20 17:28:11 +01:00
Philipinho e848c2a5b8 update list 2026-06-20 17:23:27 +01:00
Philipinho 9044e464b0 fix 2026-06-20 17:22:35 +01:00
Philipinho dd1aaa082f fix editor crash 2026-06-20 17:21:35 +01:00
Philipinho e55a42fa90 update tiptap 2026-06-20 14:55:25 +01:00
Philipinho 51f4931dd5 package updates 2026-06-20 14:52:51 +01:00
Philipinho d7fdcdec80 sync 2026-06-20 14:34:12 +01:00
Abhigyan Sinha 2dc35fa100 fix: resolve draw.io save failure on copied blocks (#2288) (#2297) 2026-06-20 14:31:16 +01:00
Philip Okugbe 4f2fbf22d4 chore: migrate to Mantine 9 and React 19 (#2293) 2026-06-20 14:28:31 +01:00
Philip Okugbe d68e241f45 feat(ee): personal spaces (#2298)
* feat(ee): personal spaces

* pref

* feat: on-demand only

* error notification
2026-06-20 14:27:41 +01:00
Philip Okugbe 510199cf04 feat(ee): docx word export (#2294)
* vendorize  prosemirror-docx - wip
* feat(ee): docx word export
* sync
2026-06-20 14:23:56 +01:00
Philip Okugbe aa5d52ad3e feat(editor): add /time slash command to insert current time (#2290) 2026-06-20 14:22:54 +01:00
Mayank-2-16 1867aa8bf6 fix: pdf table header alignment issue (#2259)
* fix: pdf table header alignment issue
* cleanup

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-06-17 14:32:48 +01:00
Felix 2ff8720832 fix: slash-menu suggestion search localization (#2280) 2026-06-17 14:17:13 +01:00
63 changed files with 2350 additions and 2161 deletions
+14 -14
View File
@@ -20,13 +20,13 @@
"@casl/react": "5.0.1",
"@docmost/editor-ext": "workspace:*",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "8.3.18",
"@mantine/dates": "8.3.18",
"@mantine/form": "8.3.18",
"@mantine/hooks": "8.3.18",
"@mantine/modals": "8.3.18",
"@mantine/notifications": "8.3.18",
"@mantine/spotlight": "8.3.18",
"@mantine/core": "9.3.2",
"@mantine/dates": "9.3.2",
"@mantine/form": "9.3.2",
"@mantine/hooks": "9.3.2",
"@mantine/modals": "9.3.2",
"@mantine/notifications": "9.3.2",
"@mantine/spotlight": "9.3.2",
"@slidoapp/emoji-mart": "5.8.7",
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
@@ -41,7 +41,7 @@
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"jotai": "2.18.1",
"jotai": "2.20.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.7",
"jwt-decode": "4.0.0",
@@ -50,15 +50,15 @@
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0",
"mitt": "3.0.1",
"posthog-js": "1.372.2",
"react": "18.3.1",
"posthog-js": "1.391.2",
"react": "19.2.7",
"react-clear-modal": "^2.0.18",
"react-dom": "^18.3.1",
"react-dom": "19.2.7",
"react-drawio": "1.0.7",
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-i18next": "16.5.8",
"react-router-dom": "7.13.1",
"react-router-dom": "7.18.0",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"zod": "4.3.6"
@@ -73,8 +73,8 @@
"@types/js-cookie": "3.0.6",
"@types/katex": "0.16.8",
"@types/node": "22.19.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.1",
"eslint": "9.28.0",
"eslint-plugin-react": "7.37.5",
@@ -398,6 +398,8 @@
"Insert mermaid diagram": "Insert mermaid diagram",
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams",
"Insert current date": "Insert current date",
"Time": "Time",
"Insert current time": "Insert current time",
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
"Multiple": "Multiple",
"Turn into": "Turn into",
@@ -1085,5 +1087,11 @@
"Added {{name}} to favorites": "Added {{name}} to favorites",
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
"Page menu for {{name}}": "Page menu for {{name}}",
"Create subpage of {{name}}": "Create subpage of {{name}}"
"Create subpage of {{name}}": "Create subpage of {{name}}",
"Allow personal spaces": "Allow personal spaces",
"Members can create their own personal space.": "Members can create their own personal space.",
"Toggle allow personal spaces": "Toggle allow personal spaces",
"Create personal space": "Create personal space",
"Personal space": "Personal space",
"{{name}}'s space": "{{name}}'s space"
}
@@ -15,9 +15,16 @@ import {
IconMoon,
IconSettings,
IconSun,
IconUser,
IconUserCircle,
IconUsers,
} from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { getSpaceUrl } from "@/lib/config.ts";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { usePersonalSpaceQuery } from "@/ee/personal-space/queries/personal-space-query";
import CreatePersonalSpaceModal from "@/ee/personal-space/components/create-personal-space-modal";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { Link } from "react-router-dom";
@@ -36,11 +43,20 @@ export default function TopMenu() {
const user = currentUser?.user;
const workspace = currentUser?.workspace;
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
const settingEnabled = workspace?.settings?.spaces?.allowPersonal === true;
const { data: personalSpace } = usePersonalSpaceQuery(hasPersonalSpaces);
const [
createOpened,
{ open: openCreate, close: closeCreate },
] = useDisclosure(false);
if (!user || !workspace) {
return <></>;
}
return (
<>
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
<Menu.Target>
<UnstyledButton>
@@ -115,6 +131,26 @@ export default function TopMenu() {
{t("My preferences")}
</Menu.Item>
{personalSpace ? (
<Menu.Item
component={Link}
to={getSpaceUrl(personalSpace.slug)}
leftSection={<IconUser size={16} />}
>
{t("Personal space")}
</Menu.Item>
) : (
hasPersonalSpaces &&
settingEnabled && (
<Menu.Item
onClick={openCreate}
leftSection={<IconUser size={16} />}
>
{t("Create personal space")}
</Menu.Item>
)
)}
<Menu.Sub>
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
@@ -160,5 +196,8 @@ export default function TopMenu() {
</Menu.Item>
</Menu.Dropdown>
</Menu>
<CreatePersonalSpaceModal opened={createOpened} onClose={closeCreate} />
</>
);
}
@@ -54,14 +54,17 @@ export const CustomAvatar = React.forwardRef<
>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl, type);
const isInitials = !color || color === "initials";
const resolvedColor = isInitials ? pickInitialsColor(name ?? "") : color;
const pickedColor = isInitials ? pickInitialsColor(name ?? "") : color;
const hue = pickedColor.split(".")[0];
const initialsSource = sanitizeInitialsSource(name ?? "");
const resolvedColor = variant === "filled" ? pickedColor : hue;
const placeholderStyles =
isInitials && variant !== "filled"
? {
placeholder: {
color: `var(--mantine-color-${resolvedColor.split(".")[0]}-9)`,
color: `var(--mantine-color-${hue}-9)`,
},
}
: undefined;
@@ -177,7 +177,7 @@ export default function ChatInput({
}, []);
const handleSubmit = useCallback(() => {
if (!editor || isStreaming) return;
if (!editor || editor.isDestroyed || isStreaming) return;
const json = editor.getJSON();
const text = editorJsonToText(json).trim();
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
@@ -264,7 +264,7 @@ export default function ChatInput({
});
useEffect(() => {
if (editor && autofocus) {
if (editor && !editor.isDestroyed && autofocus) {
editor.commands.focus();
}
}, [editor]);
@@ -1,7 +1,14 @@
import { Editor } from "@tiptap/react";
import { ActionIcon, TextInput } from "@mantine/core";
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type JSX,
} from "react";
import { createPortal } from "react-dom";
import { useAtom } from "jotai";
import { IconArrowUp } from "@tabler/icons-react";
@@ -14,7 +21,7 @@ import { ResultPreview } from "./result-preview.tsx";
import classes from "./ai-menu.module.css";
import { marked } from "marked";
import { DOMSerializer } from "@tiptap/pm/model";
import { copyToClipboard, htmlToMarkdown } from "@docmost/editor-ext";
import { copyToClipboard, htmlToMarkdown, isEditorReady } from "@docmost/editor-ext";
import { useLocation } from "react-router-dom";
interface EditorAiMenuProps {
@@ -49,7 +56,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
});
}, [prompt, output, activeCommandSet]);
const updateMenuPlacement = useCallback(() => {
if (!editor || !showAiMenu) return;
if (!isEditorReady(editor) || !showAiMenu) return;
const { view } = editor;
const { from, to } = editor.state.selection;
@@ -95,7 +102,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
);
const handleGenerate = useCallback(
(item?: CommandItem) => {
if (!editor || isLoading) return;
if (!isEditorReady(editor) || isLoading) return;
let command: CommandItem | null = item || null;
@@ -158,6 +165,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
return setActiveCommandSet("main");
}
if (item.id === "result-replace") {
if (!isEditorReady(editor)) return setShowAiMenu(false);
const chain = editor.chain().focus();
if (lastAction.action === AiAction.CONTINUE_WRITING) {
@@ -183,6 +191,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
return setShowAiMenu(false);
}
if (item.id === "result-insert-below") {
if (!isEditorReady(editor)) return setShowAiMenu(false);
editor
.chain()
.focus()
@@ -246,7 +255,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
);
useEffect(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const handleClose = () => setShowAiMenu(false);
const observer = new ResizeObserver(() => {
@@ -301,7 +301,7 @@ export default function AuditLogsTable({
{expandable && (
<Table.Tr className={classes.detailRow}>
<Table.Td colSpan={4} p={0}>
<Collapse in={isExpanded}>
<Collapse expanded={isExpanded}>
<Box
px="md"
py="sm"
@@ -103,7 +103,7 @@ export default function BillingPlans() {
label="Team size"
description="Select the number of users"
value={selectedTierValue}
onChange={setSelectedTierValue}
onChange={(value) => setSelectedTierValue(value)}
data={selectData}
w={250}
size="md"
@@ -3,6 +3,7 @@ import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/react";
import { isEditorReady } from "@docmost/editor-ext";
interface ResolveCommentProps {
editor: Editor;
@@ -31,7 +32,7 @@ function ResolveComment({
resolved: !isResolved,
});
if (editor) {
if (isEditorReady(editor)) {
editor.commands.setCommentResolved(commentId, !isResolved);
}
+1
View File
@@ -19,5 +19,6 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
} as const;
@@ -18,6 +18,7 @@ const enterpriseFeatures = [
"Confluence Import",
"PDF & DOCX Import",
"Templates",
"Personal Spaces"
];
export default function OssDetails() {
@@ -193,7 +193,7 @@ export function MfaSetupModal({
</Group>
</UnstyledButton>
<Collapse in={manualEntryOpen}>
<Collapse expanded={manualEntryOpen}>
<Alert
icon={<IconAlertCircle size={20} />}
color="gray"
@@ -58,6 +58,7 @@ export default function PdfRenderPage() {
title={data.title}
content={data.content}
pageId={data.pageId}
printMode
/>
</Container>
);
@@ -0,0 +1,78 @@
import { Modal, TextInput, Button, Group, Divider } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useCreatePersonalSpaceMutation } from "@/ee/personal-space/queries/personal-space-query";
import { getSpaceUrl } from "@/lib/config.ts";
import { notifications } from "@mantine/notifications";
const formSchema = z.object({
name: z.string().trim().min(2).max(100),
});
type FormValues = z.infer<typeof formSchema>;
type Props = {
opened: boolean;
onClose: () => void;
};
export default function CreatePersonalSpaceModal({ opened, onClose }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const currentUser = useAtomValue(currentUserAtom);
const createMutation = useCreatePersonalSpaceMutation();
const firstName = (currentUser?.user?.name ?? "").trim().split(/\s+/)[0] || "";
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
name: firstName ? t("{{name}}'s space", { name: firstName }) : "",
},
});
const handleSubmit = async (values: FormValues) => {
try {
const createdSpace = await createMutation.mutateAsync({
name: values.name,
});
onClose();
navigate(getSpaceUrl(createdSpace.slug));
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Create personal space")}
closeButtonProps={{ "aria-label": t("Close") }}
>
<Divider size="xs" mb="md" />
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
withAsterisk
data-autofocus
label={t("Space name")}
variant="filled"
errorProps={{ role: "alert" }}
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button type="submit" loading={createMutation.isPending}>
{t("Create")}
</Button>
</Group>
</form>
</Modal>
);
}
@@ -0,0 +1,64 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function PersonalSpacesSetting() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Allow personal spaces")}</Text>
<Text size="sm" c="dimmed">
{t("Members can create their own personal space.")}
</Text>
</div>
<PersonalSpacesToggle />
</Group>
);
}
function PersonalSpacesToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.spaces?.allowPersonal === true,
);
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({
allowPersonalSpaces: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Tooltip label={upgradeLabel} disabled={hasPersonalSpaces} refProp="rootRef">
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasPersonalSpaces}
aria-label={t("Toggle allow personal spaces")}
/>
</Tooltip>
);
}
@@ -0,0 +1,34 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { ISpace } from "@/features/space/types/space.types";
import {
createPersonalSpace,
getPersonalSpace,
} from "@/ee/personal-space/services/personal-space-service";
export function usePersonalSpaceQuery(
enabled: boolean,
): UseQueryResult<ISpace | null, Error> {
return useQuery({
queryKey: ["personal-space"],
queryFn: () => getPersonalSpace(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useCreatePersonalSpaceMutation() {
const queryClient = useQueryClient();
return useMutation<ISpace, Error, { name?: string }>({
mutationFn: (data) => createPersonalSpace(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["personal-space"] });
queryClient.invalidateQueries({ queryKey: ["spaces"] });
},
});
}
@@ -0,0 +1,14 @@
import api from "@/lib/api-client";
import { ISpace } from "@/features/space/types/space.types";
export async function getPersonalSpace(): Promise<ISpace | null> {
const req = await api.post<ISpace | null>("/personal-space/info", {});
return req.data;
}
export async function createPersonalSpace(data: {
name?: string;
}): Promise<ISpace> {
const req = await api.post<ISpace>("/personal-space/create", data);
return req.data;
}
@@ -105,7 +105,7 @@ export default function TemplateEditor() {
// Load template data into editor
useEffect(() => {
if (existingTemplate && editor) {
if (existingTemplate && editor && !editor.isDestroyed) {
loadedRef.current = false;
setTitle(existingTemplate.title || "");
setIcon(existingTemplate.icon || null);
@@ -383,7 +383,8 @@ export default function TemplateEditor() {
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
editor?.commands.focus("start");
if (editor && !editor.isDestroyed)
editor.commands.focus("start");
}
}}
/>
@@ -15,6 +15,7 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { useCreateCommentMutation } from "@/features/comment/queries/comment-query";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { useEditor } from "@tiptap/react";
import { isEditorReady } from "@docmost/editor-ext";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
@@ -48,11 +49,14 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
setReadOnlyCommentData(null);
} else {
setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run();
if (isEditorReady(editor)) {
editor.chain().focus().unsetCommentDecoration().run();
}
}
};
const getSelectedText = () => {
if (!isEditorReady(editor)) return "";
const { from, to } = editor.state.selection;
return editor.state.doc.textBetween(from, to);
};
@@ -74,24 +78,28 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
const createdComment =
await createCommentMutation.mutateAsync(commentData);
editor
.chain()
.setComment(createdComment.id)
.unsetCommentDecoration()
.run();
if (isEditorReady(editor)) {
editor
.chain()
.setComment(createdComment.id)
.unsetCommentDecoration()
.run();
editor.commands.setTextSelection({
from: editor.view.state.selection.from,
to: editor.view.state.selection.from,
});
}
setActiveCommentId(createdComment.id);
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
setAsideState({ tab: "comments", isAsideOpen: true });
setTimeout(() => {
const selector = `div[data-comment-id="${createdComment.id}"]`;
const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
editor.view.dispatch(
editor.state.tr.scrollIntoView()
);
if (isEditorReady(editor)) {
editor.view.dispatch(editor.state.tr.scrollIntoView());
}
}, 400);
} finally {
@@ -112,22 +112,24 @@ const CommentEditor = forwardRef(
// websocket on another browser). Skip for editable editors to avoid
// resetting the cursor position on every keystroke.
useEffect(() => {
if (!editable && commentEditor && defaultContent) {
if (!editable && commentEditor && !commentEditor.isDestroyed && defaultContent) {
commentEditor.commands.setContent(defaultContent);
}
}, [defaultContent, editable, commentEditor]);
useEffect(() => {
setTimeout(() => {
if (autofocus) {
commentEditor?.commands.focus("end");
if (autofocus && commentEditor && !commentEditor.isDestroyed) {
commentEditor.commands.focus("end");
}
}, 10);
}, [commentEditor, autofocus]);
useImperativeHandle(ref, () => ({
clearContent: () => {
commentEditor.commands.clearContent();
if (commentEditor && !commentEditor.isDestroyed) {
commentEditor.commands.clearContent();
}
},
}));
@@ -5,6 +5,7 @@ import { useAtom, useAtomValue } from "jotai";
import { useTimeAgo } from "@/hooks/use-time-ago";
import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import { isEditorReady } from "@docmost/editor-ext";
import CommentActions from "@/features/comment/components/comment-actions";
import CommentMenu from "@/features/comment/components/comment-menu";
import { useHasFeature } from "@/ee/hooks/use-feature";
@@ -75,7 +76,9 @@ function CommentListItem({
async function handleDeleteComment() {
try {
await deleteCommentMutation.mutateAsync(comment.id);
editor?.commands.unsetComment(comment.id);
if (isEditorReady(editor)) {
editor.commands.unsetComment(comment.id);
}
} catch (error) {
console.error("Failed to delete comment:", error);
}
@@ -93,7 +96,7 @@ function CommentListItem({
resolved: !isResolved,
});
if (editor) {
if (isEditorReady(editor)) {
editor.commands.setCommentResolved(comment.id, !isResolved);
}
} catch (error) {
@@ -11,6 +11,7 @@ import {
} from "@/features/comment/atoms/comment-atom";
import { useTranslation } from "react-i18next";
import { getRelativeSelection, ySyncPluginKey } from "@tiptap/y-tiptap";
import { isEditorReady } from "@docmost/editor-ext";
type ReadonlyBubbleMenuProps = {
editor: Editor;
@@ -29,6 +30,10 @@ export const ReadonlyBubbleMenu: FC<ReadonlyBubbleMenuProps> = ({ editor }) => {
const updateMenuPosition = useCallback(() => {
if (isInteractingRef.current) return;
if (!isEditorReady(editor)) {
setVisible(false);
return;
}
const pmSelection = editor.state.selection;
if (!(pmSelection instanceof TextSelection) || pmSelection.empty) {
@@ -97,7 +102,7 @@ export const ReadonlyBubbleMenu: FC<ReadonlyBubbleMenuProps> = ({ editor }) => {
}, [showReadOnlyCommentPopup]);
const handleCommentClick = () => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const view = editor.view;
const ystate = ySyncPluginKey.getState(view.state);
@@ -69,7 +69,7 @@ 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 copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const nodesWithMenus = [
"callout",
@@ -111,8 +111,9 @@ async function reuploadPastedAttachments(
const match = ATTACHMENT_URL_RE.exec(src);
if (!match) return;
const cleanSrc = src.split("?")[0];
const fileName =
node.attrs.name || src.split("/").pop() || "file";
node.attrs.name || cleanSrc.split("/").pop() || "file";
pastedNodes.push({
pos,
@@ -96,7 +96,7 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
<Menu.Item
leftSection={<IconRotate2 size={16} />}
onClick={() =>
editor.chain().focus().insertTransclusionSource().run()
editor.chain().focus().toggleTransclusionSource().run()
}
>
{t("Synced block")}
@@ -28,7 +28,7 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import { sanitizeUrl, copyToClipboard } from "@docmost/editor-ext";
import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext";
import { normalizeUrl } from "@/lib/utils";
const parseInternalLink = (
@@ -313,7 +313,9 @@ export default function LinkView(props: MarkViewProps) {
);
const handleRemoveLink = useCallback(() => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
if (isEditorReady(editor)) {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
}
setPopoverState("closed");
}, [editor]);
@@ -345,7 +347,7 @@ export default function LinkView(props: MarkViewProps) {
NodeFilter.SHOW_TEXT,
);
const textNode = walker.nextNode();
if (textNode) {
if (textNode && isEditorReady(editor)) {
const view = editor.view as any;
view.domObserver.stop();
textNode.nodeValue = val;
@@ -17,6 +17,7 @@ import {
IconX,
} from "@tabler/icons-react";
import { useEditor } from "@tiptap/react";
import { isEditorReady } from "@docmost/editor-ext";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
import { useAtom } from "jotai";
@@ -64,13 +65,13 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
replaceButtonToggle();
}
// Clear search term in editor
if (editor) {
if (isEditorReady(editor)) {
editor.commands.setSearchTerm("");
}
};
const goToSelection = () => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { results, resultIndex } = editor.storage.searchAndReplace;
//TODO: check type error
@@ -90,27 +91,32 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
};
const next = () => {
if (!isEditorReady(editor)) return;
editor.commands.nextSearchResult();
goToSelection();
};
const previous = () => {
if (!isEditorReady(editor)) return;
editor.commands.previousSearchResult();
goToSelection();
};
const replace = () => {
if (!isEditorReady(editor)) return;
editor.commands.setReplaceTerm(replaceText);
editor.commands.replace();
goToSelection();
};
const replaceAll = () => {
if (!isEditorReady(editor)) return;
editor.commands.setReplaceTerm(replaceText);
editor.commands.replaceAll();
};
useEffect(() => {
if (!isEditorReady(editor)) return;
editor.commands.setSearchTerm(searchText);
editor.commands.resetIndex();
editor.commands.selectCurrentItem();
@@ -118,6 +124,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
const handleOpenEvent = (e) => {
setPageFindState({ isOpen: true });
if (!isEditorReady(editor)) return;
const selectedText = editor.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to,
@@ -149,6 +156,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
}, [pageFindState.isOpen]);
useEffect(() => {
if (!isEditorReady(editor)) return;
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
editor.commands.resetIndex();
goToSelection();
@@ -21,6 +21,7 @@ import {
IconMenu4,
IconPageBreak,
IconCalendar,
IconClock,
IconAppWindow,
IconSitemap,
IconColumns3,
@@ -474,6 +475,25 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run();
},
},
{
title: "Time",
description: "Insert current time",
searchTerms: ["time", "now", "clock"],
icon: IconClock,
command: ({ editor, range }: CommandProps) => {
const currentTime = new Date().toLocaleTimeString(i18n.language, {
hour: "numeric",
minute: "numeric",
});
editor
.chain()
.focus()
.deleteRange(range)
.insertContent(currentTime)
.run();
},
},
{
title: "Status",
description: "Insert inline status badge.",
@@ -767,18 +787,34 @@ export const getSuggestionItems = ({
for (const [group, items] of Object.entries(CommandGroups)) {
const filteredItems = items.filter((item) => {
if (excludeItems?.has(item.title)) return false;
const translatedTitle = i18n.t(item.title);
const translatedDescription = i18n.t(item.description);
return (
fuzzyMatch(search, item.title) ||
fuzzyMatch(search, translatedTitle) ||
item.description.toLowerCase().includes(search) ||
translatedDescription.toLowerCase().includes(search) ||
(item.searchTerms &&
item.searchTerms.some((term: string) => term.includes(search)))
item.searchTerms.some(
(term: string) =>
term.includes(search) ||
i18n.t(term).toLowerCase().includes(search),
))
);
});
if (filteredItems.length) {
filteredGroups[group] = filteredItems.sort((a, b) => {
const aTitle = a.title.toLowerCase().includes(search) ? 0 : 1;
const bTitle = b.title.toLowerCase().includes(search) ? 0 : 1;
const aTitle =
a.title.toLowerCase().includes(search) ||
i18n.t(a.title).toLowerCase().includes(search)
? 0
: 1;
const bTitle =
b.title.toLowerCase().includes(search) ||
i18n.t(b.title).toLowerCase().includes(search)
? 0
: 1;
return aTitle - bTitle;
});
}
@@ -1,7 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { posToDOMRect, findParentNode } from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import React, { useCallback, type JSX } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -50,6 +50,7 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
const headerPaddingRef = useRef<HTMLDivElement | null>(null);
const handleScrollToHeading = (position: number) => {
if (!props.editor || props.editor.isDestroyed) return;
const { view } = props.editor;
const headerOffset = parseInt(
@@ -73,16 +74,21 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
};
const handleUpdate = () => {
const result = recalculateLinks(props.editor?.$nodes("heading"));
if (!props.editor || props.editor.isDestroyed) return;
const result = recalculateLinks(props.editor.$nodes("heading"));
setLinks(result.links);
setHeadingDOMNodes(result.nodes);
};
useEffect(() => {
// "create" repopulates once the editor view mounts after this component
props.editor?.on("create", handleUpdate);
props.editor?.on("update", handleUpdate);
return () => {
props.editor?.off("create", handleUpdate);
props.editor?.off("update", handleUpdate);
};
}, [props.editor]);
@@ -9,7 +9,7 @@ import { Menu, UnstyledButton } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { isCellSelection } from "@docmost/editor-ext";
import { isCellSelection, isEditorReady } from "@docmost/editor-ext";
import { CellChevronMenu } from "./menus/cell-chevron-menu";
import classes from "./handle.module.css";
@@ -27,7 +27,9 @@ export const CellChevron = React.memo(function CellChevron({
tablePos,
}: CellChevronProps) {
const { t } = useTranslation();
const cellDom = editor.view.nodeDOM(cellPos) as HTMLElement | null;
const cellDom = isEditorReady(editor)
? (editor.view.nodeDOM(cellPos) as HTMLElement | null)
: null;
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "top-end",
@@ -61,6 +63,7 @@ export const CellChevron = React.memo(function CellChevron({
});
const onOpen = useCallback(() => {
if (!isEditorReady(editor)) return;
const current = editor.state.selection;
// Preserve an existing multi-cell CellSelection that already covers
@@ -86,6 +89,7 @@ export const CellChevron = React.memo(function CellChevron({
}, [editor, cellPos]);
const onClose = useCallback(() => {
if (!isEditorReady(editor)) return;
editor.commands.unfreezeHandles();
}, [editor]);
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import { buildRowOrColumnSelection, Orientation } from "../lib/select-row-column";
interface Args {
@@ -19,6 +20,7 @@ export function useColumnRowMenuLifecycle({
tablePos,
}: Args) {
const onOpen = useCallback(() => {
if (!isEditorReady(editor)) return;
const selection = buildRowOrColumnSelection(
editor.state,
tableNode,
@@ -33,6 +35,7 @@ export function useColumnRowMenuLifecycle({
}, [editor, orientation, index, tableNode, tablePos]);
const onClose = useCallback(() => {
if (!isEditorReady(editor)) return;
editor.commands.unfreezeHandles();
}, [editor]);
@@ -2,6 +2,7 @@ import { useCallback } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TableMap } from "@tiptap/pm/tables";
import { isEditorReady } from "@docmost/editor-ext";
type Scope =
| { kind: "col"; index: number }
@@ -15,6 +16,7 @@ export function useTableClear(
scope: Scope,
) {
return useCallback(() => {
if (!isEditorReady(editor)) return;
const tr = editor.state.tr;
const tableStart = tablePos + 1;
const map = TableMap.get(tableNode);
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TableMap } from "@tiptap/pm/tables";
import { moveColumn, moveRow } from "@docmost/editor-ext";
import { isEditorReady, moveColumn, moveRow } from "@docmost/editor-ext";
export type MoveDirection = "left" | "right" | "up" | "down";
@@ -25,7 +25,7 @@ export function useTableMoveRowColumn(
const canMove = target >= 0 && target <= maxIndex;
const handleMove = useCallback(() => {
if (!canMove) return;
if (!canMove || !isEditorReady(editor)) return;
const tr = editor.state.tr;
const moved =
orientation === "col"
@@ -4,6 +4,7 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import {
convertArrayOfRowsToTableNode,
convertTableNodeToArrayOfRows,
isEditorReady,
transpose,
} from "@docmost/editor-ext";
import {
@@ -63,7 +64,7 @@ export function useTableSort({
}, [tableNode, orientation, index]);
const handleSort = useCallback(() => {
if (!canSort) return;
if (!canSort || !isEditorReady(editor)) return;
const rows = convertTableNodeToArrayOfRows(tableNode);
const axes = orientation === "col" ? rows : transpose(rows);
@@ -1,4 +1,4 @@
import React, { useCallback } from "react";
import React, { useCallback, type JSX } from "react";
import {
EditorMenuProps,
ShouldShowProps,
@@ -1,6 +1,6 @@
import { posToDOMRect, findParentNode } from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import React, { useCallback, type JSX } from "react";
import {
EditorMenuProps,
ShouldShowProps,
@@ -15,6 +15,7 @@ interface PageEditorProps {
title: string;
content: any;
pageId?: string;
printMode?: boolean;
/**
* When rendering inside a public share, pass the share's id (or key). Lookups
* for transclusion content then resolve against the share graph instead of
@@ -28,6 +29,7 @@ export default function ReadonlyPageEditor({
title,
content,
pageId,
printMode = false,
shareId,
}: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
@@ -48,8 +50,12 @@ export default function ReadonlyPageEditor({
}, []);
const extensions = useMemo(() => {
const excludedExtensions = new Set([
"uniqueID",
...(printMode ? ["tableHeaderPin", "tableReadonlySort"] : []),
]);
const filteredExtensions = mainExtensions.filter(
(ext) => ext.name !== "uniqueID",
(ext) => !excludedExtensions.has(ext.name),
);
return [
@@ -59,7 +65,7 @@ export default function ReadonlyPageEditor({
updateDocument: false,
}),
];
}, []);
}, [printMode]);
const titleExtensions = [
Document.extend({
@@ -163,8 +163,13 @@
@media print {
.tableWrapper.tableHeaderPinned table tr:first-child {
position: static;
transform: none;
position: static !important;
top: auto !important;
transform: none !important;
}
.tableReadonlySortChevron {
display: none !important;
}
}
@@ -268,4 +273,4 @@
.prosemirror-dropcursor-inline {
display: none;
}
}
}
@@ -152,7 +152,11 @@ export function TitleEditor({
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
useEffect(() => {
if (titleEditor && title !== titleEditor.getText()) {
if (
titleEditor &&
!titleEditor.isDestroyed &&
title !== titleEditor.getText()
) {
titleEditor.commands.setContent(title);
}
}, [pageId, title, titleEditor]);
@@ -34,7 +34,7 @@ export function HistoryEditor({
});
useEffect(() => {
if (!editor || !content) return;
if (!editor || editor.isDestroyed || !content) return;
let decorationSet = DecorationSet.empty;
let addedCount = 0;
@@ -64,7 +64,7 @@ export default function ShareShell({
const [fullWidth, setFullWidth] = useAtom(sharedPageFullWidthAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLElement | null>(null);
const sidebarRef = useRef<HTMLDivElement>(null);
const startResizing = useCallback((e: React.MouseEvent) => {
e.preventDefault();
@@ -17,8 +17,8 @@ const formSchema = z.object({
.min(2)
.max(100)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
"Space slug must start with a letter or number and may contain hyphens and underscores",
),
description: z.string().max(500),
});
@@ -15,8 +15,8 @@ const formSchema = z.object({
.min(2)
.max(100)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
"Space slug must start with a letter or number and may contain hyphens and underscores",
),
});
@@ -24,6 +24,7 @@ export interface ISpace {
description: string;
logo?: string;
slug: string;
isPersonal?: boolean;
hostname: string;
creatorId: string;
createdAt: Date;
@@ -80,7 +80,7 @@ export function WorkspaceInviteForm({ onClose }: Props) {
defaultValue={UserRole.MEMBER}
allowDeselect={false}
checkIconPosition="right"
onChange={setRole}
onChange={(value) => setRole(value)}
/>
<MultiGroupSelect
@@ -28,6 +28,7 @@ export interface IWorkspace {
trashRetentionDays?: number;
restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean;
allowPersonalSpaces?: boolean;
isScimEnabled?: boolean;
}
@@ -36,6 +37,7 @@ export interface IWorkspaceSettings {
sharing?: IWorkspaceSharingSettings;
api?: IWorkspaceApiSettings;
templates?: IWorkspaceTemplateSettings;
spaces?: IWorkspaceSpaceSettings;
}
export interface IWorkspaceApiSettings {
@@ -57,6 +59,10 @@ export interface IWorkspaceTemplateSettings {
allowMemberTemplates?: boolean;
}
export interface IWorkspaceSpaceSettings {
allowPersonal?: boolean;
}
export interface ICreateInvite {
role: string;
emails: string[];
@@ -7,6 +7,7 @@ import { Helmet } from "react-helmet-async";
import ManageHostname from "@/ee/components/manage-hostname.tsx";
import { Divider } from "@mantine/core";
import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx";
import PersonalSpacesSetting from "@/ee/personal-space/components/personal-spaces-setting.tsx";
export default function WorkspaceSettings() {
const { t } = useTranslation();
@@ -22,6 +23,9 @@ export default function WorkspaceSettings() {
<Divider my="md" />
<AllowMemberTemplates />
<Divider my="md" />
<PersonalSpacesSetting />
{isCloud() && (
<>
<Divider my="md" />
@@ -25,3 +25,11 @@
.mantine-Input-input[data-variant="default"] {
border-color: var(--mantine-color-gray-6);
}
/* better contrast for disabled inputs */
.mantine-Input-input:disabled,
.mantine-Input-input[data-disabled] {
opacity: 0.7;
color: var(--mantine-color-dimmed);
-webkit-text-fill-color: var(--mantine-color-dimmed);
}
+14
View File
@@ -1,8 +1,10 @@
import {
Badge,
createTheme,
CSSVariablesResolver,
MantineColorsTuple,
Tabs,
v8CssVariablesResolver,
} from "@mantine/core";
const blue: MantineColorsTuple = [
@@ -37,6 +39,15 @@ export const theme = createTheme({
red,
},
components: {
// Size badges to their content; fit-content collapses inside table cells.
Badge: Badge.extend({
styles: (_theme, props) => ({
root:
props.fullWidth || props.circle
? {}
: { width: "max-content", maxWidth: "100%" },
}),
}),
Tabs: Tabs.extend({
vars: (theme, props) => ({
root: {
@@ -68,9 +79,11 @@ export const theme = createTheme({
export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
variables: {
...v8CssVariablesResolver(theme).variables,
"--input-error-size": theme.fontSizes.sm,
},
light: {
...v8CssVariablesResolver(theme).light,
"--mantine-color-dimmed": "#4b5563",
"--mantine-color-dark-light-color": "#4e5359",
"--mantine-color-dark-light-hover": "var(--mantine-color-gray-light-hover)",
@@ -105,6 +118,7 @@ export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
"--mantine-color-green-light-color": "#1B5E20",
},
dark: {
...v8CssVariablesResolver(theme).dark,
"--mantine-color-dark-light-color": "var(--mantine-color-gray-4)",
"--mantine-color-dark-light-hover": "var(--mantine-color-default-hover)",
},
+51 -51
View File
@@ -47,29 +47,29 @@
"@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "1.29.0",
"@nest-lab/throttler-storage-redis": "^1.2.0",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs-labs/nestjs-ioredis": "11.0.4",
"@nestjs/bullmq": "11.0.4",
"@nestjs/cache-manager": "3.1.3",
"@nestjs/common": "11.1.27",
"@nestjs/config": "4.0.4",
"@nestjs/core": "11.1.27",
"@nestjs/event-emitter": "3.1.0",
"@nestjs/jwt": "11.0.2",
"@nestjs/mapped-types": "^2.1.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.19",
"@node-saml/passport-saml": "^5.1.0",
"@socket.io/redis-adapter": "^8.3.0",
"@nestjs/mapped-types": "2.1.1",
"@nestjs/passport": "11.0.5",
"@nestjs/platform-fastify": "11.1.27",
"@nestjs/platform-socket.io": "11.1.27",
"@nestjs/schedule": "6.1.3",
"@nestjs/terminus": "11.1.1",
"@nestjs/throttler": "6.5.0",
"@nestjs/websockets": "11.1.27",
"@node-saml/passport-saml": "5.1.0",
"@socket.io/redis-adapter": "8.3.0",
"ai": "6.0.134",
"ai-sdk-ollama": "3.8.1",
"bcrypt": "6.0.0",
"bowser": "2.14.1",
"bullmq": "5.76.10",
"bullmq": "5.79.0",
"cache-manager": "7.2.8",
"cheerio": "1.2.0",
"class-transformer": "0.5.1",
@@ -79,49 +79,49 @@
"fastify-ip": "2.0.0",
"fs-extra": "11.3.4",
"happy-dom": "20.8.9",
"ioredis": "^5.10.1",
"js-tiktoken": "^1.0.21",
"jsonwebtoken": "^9.0.3",
"kysely": "^0.28.17",
"kysely-migration-cli": "^0.4.2",
"kysely-postgres-js": "^3.0.0",
"ldapts": "^8.1.7",
"lib0": "^0.2.117",
"mammoth": "^1.12.0",
"mime-types": "^3.0.2",
"ioredis": "5.10.1",
"js-tiktoken": "1.0.21",
"jsonwebtoken": "9.0.3",
"kysely": "0.28.17",
"kysely-migration-cli": "0.4.2",
"kysely-postgres-js": "3.0.0",
"ldapts": "8.1.7",
"lib0": "0.2.117",
"mammoth": "1.12.0",
"mime-types": "3.0.2",
"msgpackr": "^1.11.9",
"nanoid": "5.1.7",
"nestjs-cls": "^6.2.0",
"nestjs-kysely": "^3.1.2",
"nestjs-pino": "^4.6.1",
"nodemailer": "^8.0.5",
"openid-client": "^6.8.2",
"otpauth": "^9.5.0",
"p-limit": "^7.3.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"pg-tsquery": "^8.4.2",
"nestjs-cls": "6.2.0",
"nestjs-kysely": "3.1.2",
"nestjs-pino": "4.6.1",
"nodemailer": "9.0.1",
"openid-client": "6.8.2",
"otpauth": "9.5.0",
"p-limit": "7.3.0",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1",
"pg-tsquery": "8.4.2",
"pgvector": "^0.2.1",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"postgres": "^3.4.8",
"postmark": "^4.0.7",
"react": "^18.3.1",
"react-email": "6.0.8",
"reflect-metadata": "^0.2.2",
"pino-http": "11.0.0",
"pino-pretty": "13.1.3",
"postgres": "3.4.8",
"postmark": "4.0.7",
"react": "19.2.7",
"react-email": "6.6.3",
"reflect-metadata": "0.2.2",
"rxjs": "^7.8.2",
"sanitize-filename": "1.6.3",
"scimmy": "1.3.5",
"socket.io": "^4.8.3",
"socket.io": "4.8.3",
"stripe": "^17.7.0",
"tlds": "1.261.0",
"tmp-promise": "3.0.3",
"tseep": "1.3.1",
"typesense": "^3.0.5",
"undici": "7.24.0",
"ws": "^8.20.1",
"yauzl": "^3.2.1",
"zod": "^4.3.6"
"typesense": "3.0.5",
"undici": "7.28.0",
"ws": "8.21.0",
"yauzl": "3.2.1",
"zod": "4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.28.0",
+1
View File
@@ -20,6 +20,7 @@ export const Feature = {
VIEWER_COMMENTS: 'comment:viewer',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
} as const;
@@ -1,7 +1,7 @@
import {
IsAlphanumeric,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
@@ -20,6 +20,9 @@ export class CreateSpaceDto {
@MinLength(2)
@MaxLength(100)
@IsAlphanumeric()
@Matches(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
message:
'Space slug must start with a letter or number and may contain hyphens and underscores',
})
slug: string;
}
@@ -48,6 +48,7 @@ export class SpaceService {
workspaceId: string,
createSpaceDto: CreateSpaceDto,
trx?: KyselyTransaction,
options?: { isPersonal?: boolean },
): Promise<Space> {
let space = null;
@@ -59,6 +60,7 @@ export class SpaceService {
workspaceId,
createSpaceDto,
trx,
options,
);
await this.spaceMemberService.addUserToSpace(
@@ -81,6 +83,7 @@ export class SpaceService {
after: {
name: space.name,
slug: space.slug,
...(space.isPersonal ? { isPersonal: true } : {}),
},
},
});
@@ -93,6 +96,7 @@ export class SpaceService {
workspaceId: string,
createSpaceDto: CreateSpaceDto,
trx?: KyselyTransaction,
options?: { isPersonal?: boolean },
): Promise<Space> {
const slugExists = await this.spaceRepo.slugExists(
createSpaceDto.slug,
@@ -112,6 +116,7 @@ export class SpaceService {
creatorId: userId,
workspaceId: workspaceId,
slug: createSpaceDto.slug,
isPersonal: options?.isPersonal ?? false,
},
trx,
);
@@ -57,4 +57,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
allowMemberTemplates: boolean;
@IsOptional()
@IsBoolean()
allowPersonalSpaces: boolean;
}
@@ -333,7 +333,8 @@ export class WorkspaceService {
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined'
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined'
) {
const ws = await this.db
.selectFrom('workspaces')
@@ -361,6 +362,18 @@ export class WorkspaceService {
}
}
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
if (
!this.licenseCheckService.hasFeature(
ws.licenseKey,
Feature.PERSONAL_SPACES,
ws.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
@@ -500,6 +513,20 @@ export class WorkspaceService {
);
}
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
const prev = settingsBefore?.spaces?.allowPersonal ?? false;
if (prev !== updateWorkspaceDto.allowPersonalSpaces) {
before.allowPersonalSpaces = prev;
after.allowPersonalSpaces = updateWorkspaceDto.allowPersonalSpaces;
}
await this.workspaceRepo.updateSpaceSettings(
workspaceId,
'allowPersonal',
updateWorkspaceDto.allowPersonalSpaces,
trx,
);
}
delete updateWorkspaceDto.restrictApiToAdmins;
delete updateWorkspaceDto.aiSearch;
delete updateWorkspaceDto.generativeAi;
@@ -507,6 +534,7 @@ export class WorkspaceService {
delete updateWorkspaceDto.mcpEnabled;
delete updateWorkspaceDto.allowMemberTemplates;
delete updateWorkspaceDto.aiChat;
delete updateWorkspaceDto.allowPersonalSpaces;
await this.workspaceRepo.updateWorkspace(
updateWorkspaceDto,
@@ -0,0 +1,24 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('spaces')
.addColumn('is_personal', 'boolean', (col) =>
col.notNull().defaultTo(false),
)
.execute();
await sql`
CREATE UNIQUE INDEX spaces_personal_creator_unique
ON spaces (creator_id)
WHERE is_personal = true AND deleted_at IS NULL
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.dropIndex('spaces_personal_creator_unique')
.ifExists()
.execute();
await db.schema.alterTable('spaces').dropColumn('is_personal').execute();
}
@@ -57,6 +57,22 @@ export class SpaceRepo {
.executeTakeFirst();
}
async findPersonalSpace(
userId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<Space | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('spaces')
.selectAll('spaces')
.where('workspaceId', '=', workspaceId)
.where('creatorId', '=', userId)
.where('isPersonal', '=', true)
.where('deletedAt', 'is', null)
.executeTakeFirst();
}
async slugExists(
slug: string,
workspaceId: string,
@@ -251,4 +251,24 @@ export class WorkspaceRepo {
.executeTakeFirst();
}
async updateSpaceSettings(
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('spaces', COALESCE(settings->'spaces', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
}
+1
View File
@@ -322,6 +322,7 @@ export interface Spaces {
deletedAt: Timestamp | null;
description: string | null;
id: Generated<string>;
isPersonal: Generated<boolean>;
logo: string | null;
name: string | null;
settings: Json | null;
+35 -35
View File
@@ -29,41 +29,41 @@
"@joplin/turndown": "4.0.82",
"@joplin/turndown-plugin-gfm": "1.0.64",
"@sindresorhus/slugify": "3.0.0",
"@tiptap/core": "3.20.4",
"@tiptap/extension-audio": "3.20.4",
"@tiptap/extension-code-block": "3.20.4",
"@tiptap/extension-collaboration": "3.20.4",
"@tiptap/extension-collaboration-caret": "3.20.4",
"@tiptap/extension-color": "3.20.4",
"@tiptap/extension-document": "3.20.4",
"@tiptap/extension-heading": "3.20.4",
"@tiptap/extension-highlight": "3.20.4",
"@tiptap/extension-history": "3.20.4",
"@tiptap/extension-image": "3.20.4",
"@tiptap/extension-link": "3.20.4",
"@tiptap/extension-list": "3.20.4",
"@tiptap/extension-placeholder": "3.20.4",
"@tiptap/extension-subscript": "3.20.4",
"@tiptap/extension-superscript": "3.20.4",
"@tiptap/extension-table": "3.20.4",
"@tiptap/extension-text": "3.20.4",
"@tiptap/extension-text-align": "3.20.4",
"@tiptap/extension-text-style": "3.20.4",
"@tiptap/extension-typography": "3.20.4",
"@tiptap/extension-unique-id": "3.20.4",
"@tiptap/extension-youtube": "3.20.4",
"@tiptap/html": "3.20.4",
"@tiptap/pm": "3.20.4",
"@tiptap/react": "3.20.4",
"@tiptap/starter-kit": "3.20.4",
"@tiptap/suggestion": "3.20.4",
"@tiptap/y-tiptap": "3.0.2",
"@tiptap/core": "3.27.1",
"@tiptap/extension-audio": "3.27.1",
"@tiptap/extension-code-block": "3.27.1",
"@tiptap/extension-collaboration": "3.27.1",
"@tiptap/extension-collaboration-caret": "3.27.1",
"@tiptap/extension-color": "3.27.1",
"@tiptap/extension-document": "3.27.1",
"@tiptap/extension-heading": "3.27.1",
"@tiptap/extension-highlight": "3.27.1",
"@tiptap/extension-history": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-link": "3.27.1",
"@tiptap/extension-list": "3.27.1",
"@tiptap/extension-placeholder": "3.27.1",
"@tiptap/extension-subscript": "3.27.1",
"@tiptap/extension-superscript": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/extension-text": "3.27.1",
"@tiptap/extension-text-align": "3.27.1",
"@tiptap/extension-text-style": "3.27.1",
"@tiptap/extension-typography": "3.27.1",
"@tiptap/extension-unique-id": "3.27.1",
"@tiptap/extension-youtube": "3.27.1",
"@tiptap/html": "3.27.1",
"@tiptap/pm": "3.27.1",
"@tiptap/react": "3.27.1",
"@tiptap/starter-kit": "3.27.1",
"@tiptap/suggestion": "3.27.1",
"@tiptap/y-tiptap": "3.0.5",
"bytes": "3.1.2",
"cross-env": "10.1.0",
"date-fns": "4.1.0",
"diff": "8.0.3",
"docx": "9.7.1",
"dompurify": "3.4.1",
"dompurify": "3.4.11",
"fractional-indexing-jittered": "1.0.0",
"highlight.js": "11.11.1",
"image-dimensions": "2.5.0",
@@ -102,10 +102,10 @@
"prosemirror-changeset": "2.4.0",
"y-prosemirror": "1.3.7",
"glob": "13.0.6",
"ws": "8.20.1",
"dompurify": "3.4.1",
"tmp": "0.2.6",
"hono": "4.12.18",
"ws": "8.21.0",
"dompurify": "3.4.11",
"tmp": "0.2.7",
"hono": "4.12.25",
"mermaid": "11.15.0",
"nanoid@^3": "3.3.8",
"socket.io-parser": "4.2.6",
@@ -113,7 +113,7 @@
"lodash-es": "4.18.1",
"lodash": "4.18.1",
"@hono/node-server": "1.19.13",
"undici": "7.24.0",
"undici": "7.28.0",
"ajv@^6": "6.14.0",
"ajv@^8": "8.18.0",
"underscore": "1.13.8",
+1694 -1990
View File
File diff suppressed because it is too large Load Diff