Compare commits

...

37 Commits

Author SHA1 Message Date
Philipinho 290b7d9d94 v0.6.2 2024-12-14 20:39:19 +00:00
Philip Okugbe 2503bfd3a2 fix: prevent CDNs from caching attachments (#562) 2024-12-14 19:55:49 +00:00
Philipinho f48d6dd60b fix: don't throw error while parsing auth tokens 2024-12-12 14:29:25 +00:00
Philipinho 1302b1b602 v0.6.1 2024-12-11 14:55:06 +00:00
Philipinho 89a3f4cfc2 v0.6.1 2024-12-11 14:54:19 +00:00
Philip Okugbe e48b1c0dae fix: markdown math import (#529)
* fix: markdown math block import

* fix: block and inline math import

* cleanup
2024-12-09 15:08:25 +00:00
Philip Okugbe 4a2a5a7a4d fix: postgres and redis url validation (#548) 2024-12-09 14:56:15 +00:00
Philip Okugbe 532001fd82 chore: fix linting (#544)
* fix: eslint (server)

* fix: eslint (client)

* commit package lock file

* fix linting
2024-12-09 14:51:31 +00:00
Philip Okugbe e6bf4cdd6c fix: fix markdown import file button (#542) 2024-12-06 12:26:55 +00:00
Philipinho a9a4a26db5 fix export controller reference in module 2024-11-30 20:42:31 +00:00
Philipinho ede5633415 fix export fileName 2024-11-30 20:40:53 +00:00
Philipinho a25cf84671 fix: add spaceId 2024-11-30 20:29:13 +00:00
Philipinho a37d558bac v0.6.0 2024-11-30 20:06:44 +00:00
Philip Okugbe ddb0f9225f fix: uuid7 for commentId (#524) 2024-11-30 20:04:50 +00:00
Philip Okugbe c717847ca8 chore: update packages (#507) 2024-11-30 19:54:04 +00:00
Philip Okugbe fe83557767 feat: space export (#506)
* wip

* Space export
* option to export pages with children
* include attachments in exports
* unified export UI

* cleanup

* fix: change export icon

* add export button to space settings

* cleanups

* export name
2024-11-30 19:47:22 +00:00
Philip Okugbe 9fa432dba9 feat: support tab key in code block (#523) 2024-11-30 14:40:05 +00:00
Philip Okugbe c6aaefecbd fix: clear local cache on logout (#519) 2024-11-28 20:35:53 +00:00
Philip Okugbe 311d81bc71 fix wrong tree sync bug (#514) 2024-11-28 19:39:38 +00:00
Philip Okugbe f178e6654f fix: properly support redis db (#517) 2024-11-28 18:54:28 +00:00
Philip Okugbe ca186f3c0e fix: return direct embed urls if present (#516) 2024-11-28 18:53:49 +00:00
Philip Okugbe a16d5d1bf4 feat: websocket rooms (#515) 2024-11-28 18:53:29 +00:00
Philip Okugbe d97baf5824 add env variable (#513) 2024-11-28 18:48:25 +00:00
Philip Okugbe 8349d8271c fix: allow space in inline math (#508) 2024-11-28 18:48:08 +00:00
Philip Okugbe 2e6d16dbc3 fix: full width bug on smaller screens (#518) 2024-11-28 18:44:42 +00:00
Philipinho 4107793e73 fix: disable user-select 2024-11-28 15:55:10 +00:00
Philip Okugbe a1b6ac7f3e fix: close space selection popover onClickOutside (#485) 2024-11-27 02:32:12 +00:00
Philip Okugbe dd0319a14d fix: index imported content (#495) 2024-11-20 13:36:36 +00:00
Philip Okugbe 8194c7d42d fix: focus editor on bottom click (#484) 2024-11-13 20:00:25 +00:00
Philipinho d01ced078b * Reduce code block font-size
* Make inline code more distinctive
2024-11-13 11:36:55 -08:00
ftibi93 da9c971050 fix breadcrumb clipping (#457) 2024-11-13 19:15:37 +00:00
Philipinho 4e7af507c6 fix tree dnd 2024-11-06 19:29:12 -08:00
Philipinho f7426a0b45 fix: use clsx 2024-11-01 10:09:52 +00:00
Philip Okugbe b85b34d6b1 feat: resizable sidebar (#452)
* feat: resizable sidebar

* only expand space sidebar
2024-11-01 10:05:03 +00:00
ftibi93 e064e58f79 Fix sidebar responsivity (#453)
* navbar height fix. has to be cleaned up
* use parent height for tree
* cleanups
2024-11-01 09:41:23 +00:00
Philip Okugbe 4f1a97ceb9 Revert "fix: prevent default browser save behavior (#450)" (#451)
This reverts commit d07338861b.
2024-10-30 12:23:31 +00:00
Philip Okugbe d07338861b fix: prevent default browser save behavior (#450) 2024-10-30 11:41:23 +00:00
78 changed files with 5869 additions and 3230 deletions
+2
View File
@@ -40,3 +40,5 @@ SMTP_IGNORETLS=false
# Postmark driver config # Postmark driver config
POSTMARK_TOKEN= POSTMARK_TOKEN=
# for custom drawio server
DRAWIO_URL=
-22
View File
@@ -1,22 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'plugin:@tanstack/eslint-plugin-query/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
}
+36
View File
@@ -0,0 +1,36 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import pluginQuery from "@tanstack/eslint-plugin-query";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
"@tanstack/query": pluginQuery,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-unused-vars": "off",
"react-hooks/exhaustive-deps": "off",
"@typescript-eslint/no-unused-expressions": "off",
"no-useless-escape": "off",
},
},
);
+40 -37
View File
@@ -1,73 +1,76 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.5.0", "version": "0.6.2",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
}, },
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.1", "@casl/ability": "^6.7.2",
"@casl/react": "^4.0.0", "@casl/react": "^4.0.0",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "^0.17.6", "@excalidraw/excalidraw": "^0.17.6",
"@mantine/core": "^7.12.2", "@mantine/core": "^7.14.2",
"@mantine/form": "^7.12.2", "@mantine/form": "^7.14.2",
"@mantine/hooks": "^7.12.2", "@mantine/hooks": "^7.14.2",
"@mantine/modals": "^7.12.2", "@mantine/modals": "^7.14.2",
"@mantine/notifications": "^7.12.2", "@mantine/notifications": "^7.14.2",
"@mantine/spotlight": "^7.12.2", "@mantine/spotlight": "^7.14.2",
"@tabler/icons-react": "^3.14.0", "@tabler/icons-react": "^3.22.0",
"@tanstack/react-query": "^5.53.2", "@tanstack/react-query": "^5.61.4",
"axios": "^1.7.7", "axios": "^1.7.8",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^4.1.0",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jotai": "^2.9.3", "jotai": "^2.10.3",
"jotai-optics": "^0.4.0", "jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"katex": "^0.16.11", "katex": "^0.16.11",
"lowlight": "^3.1.0", "lowlight": "^3.2.0",
"mermaid": "^11.0.2", "mermaid": "^11.4.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "^3.4.0", "react-arborist": "^3.4.0",
"react-clear-modal": "^2.0.9", "react-clear-modal": "^2.0.11",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-drawio": "^0.2.0", "react-drawio": "^1.0.1",
"react-error-boundary": "^4.0.13", "react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-router-dom": "^6.26.1", "react-router-dom": "^7.0.1",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.8.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.12", "tiptap-extension-global-drag-handle": "^0.1.16",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/eslint-plugin-query": "^5.53.0", "@eslint/js": "^9.16.0",
"@tanstack/eslint-plugin-query": "^5.62.1",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
"@types/node": "22.5.2", "@types/node": "22.10.0",
"@types/react": "^18.3.5", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.3.0", "@vitejs/plugin-react": "^4.3.4",
"@typescript-eslint/parser": "^8.3.0", "eslint": "^9.15.0",
"@vitejs/plugin-react": "^4.3.1", "eslint-plugin-react": "^7.37.2",
"eslint": "^9.9.1", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.16",
"eslint-plugin-react-refresh": "^0.4.11", "globals": "^15.13.0",
"optics-ts": "^2.4.1", "optics-ts": "^2.4.1",
"postcss": "^8.4.43", "postcss": "^8.4.49",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prettier": "^3.3.3", "prettier": "^3.4.1",
"typescript": "^5.5.4", "typescript": "^5.7.2",
"vite": "^5.4.8" "typescript-eslint": "^8.17.0",
"vite": "^6.0.0"
} }
} }
@@ -0,0 +1,149 @@
import {
Modal,
Button,
Group,
Text,
Select,
Switch,
Divider,
} from "@mantine/core";
import { exportPage } from "@/features/page/services/page-service.ts";
import { useState } from "react";
import { ExportFormat } from "@/features/page/types/page.types.ts";
import { notifications } from "@mantine/notifications";
import { exportSpace } from "@/features/space/services/space-service";
interface ExportModalProps {
id: string;
type: "space" | "page";
open: boolean;
onClose: () => void;
}
export default function ExportModal({
id,
type,
open,
onClose,
}: ExportModalProps) {
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true);
const handleExport = async () => {
try {
if (type === "page") {
await exportPage({ pageId: id, format, includeChildren });
}
if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments });
}
setIncludeChildren(false);
setIncludeAttachments(true);
onClose();
} catch (err) {
notifications.show({
message: "Export failed:" + err.response?.data.message,
color: "red",
});
console.error("export error", err);
}
};
const handleChange = (format: ExportFormat) => {
setFormat(format);
};
return (
<Modal.Root
opened={open}
onClose={onClose}
size={500}
padding="xl"
yOffset="10vh"
xOffset={0}
mah={400}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>Export {type}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Format</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
</Group>
{type === "page" && (
<>
<Divider my="sm" />
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Include subpages</Text>
</div>
<Switch
onChange={(event) =>
setIncludeChildren(event.currentTarget.checked)
}
checked={includeChildren}
/>
</Group>
</>
)}
{type === "space" && (
<>
<Divider my="sm" />
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Include attachments</Text>
</div>
<Switch
onChange={(event) =>
setIncludeAttachments(event.currentTarget.checked)
}
checked={includeAttachments}
/>
</Group>
</>
)}
<Group justify="center" mt="md">
<Button onClick={onClose} variant="default">
Cancel
</Button>
<Button onClick={handleExport}>Export</Button>
</Group>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
interface ExportFormatSelection {
format: ExportFormat;
onChange: (value: string) => void;
}
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
return (
<Select
data={[
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
]}
defaultValue={format}
onChange={onChange}
styles={{ wrapper: { maxWidth: 120 } }}
comboboxProps={{ width: "120" }}
allowDeselect={false}
withCheckIcon={false}
aria-label="Select export format"
/>
);
}
@@ -14,3 +14,18 @@
} }
} }
.resizeHandle {
width: 3px;
cursor: col-resize;
position: absolute;
right: 0;
top: 0;
bottom: 0;
&:hover, &:active {
width: 5px;
background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5))
}
}
@@ -1,12 +1,12 @@
import { AppShell, Container } from "@mantine/core"; import { AppShell, Container } from "@mantine/core";
import React from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx"; import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { import {
asideStateAtom, asideStateAtom,
desktopSidebarAtom, desktopSidebarAtom,
mobileSidebarAtom, mobileSidebarAtom, sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx"; import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
import { AppHeader } from "@/components/layouts/global/app-header.tsx"; import { AppHeader } from "@/components/layouts/global/app-header.tsx";
@@ -21,6 +21,46 @@ export default function GlobalAppShell({
const [mobileOpened] = useAtom(mobileSidebarAtom); const [mobileOpened] = useAtom(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom); const [desktopOpened] = useAtom(desktopSidebarAtom);
const [{ isAsideOpen }] = useAtom(asideStateAtom); const [{ isAsideOpen }] = useAtom(asideStateAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef(null);
const startResizing = React.useCallback((mouseDownEvent) => {
mouseDownEvent.preventDefault();
setIsResizing(true);
}, []);
const stopResizing = React.useCallback(() => {
setIsResizing(false);
}, []);
const resize = React.useCallback(
(mouseMoveEvent) => {
if (isResizing) {
const newWidth = mouseMoveEvent.clientX - sidebarRef.current.getBoundingClientRect().left;
if (newWidth < 220) {
setSidebarWidth(220);
return;
}
if (newWidth > 600) {
setSidebarWidth(600);
return;
}
setSidebarWidth(newWidth);
}
},
[isResizing]
);
useEffect(() => {
//https://codesandbox.io/p/sandbox/kz9de
window.addEventListener("mousemove", resize);
window.addEventListener("mouseup", stopResizing);
return () => {
window.removeEventListener("mousemove", resize);
window.removeEventListener("mouseup", stopResizing);
};
}, [resize, stopResizing]);
const location = useLocation(); const location = useLocation();
const isSettingsRoute = location.pathname.startsWith("/settings"); const isSettingsRoute = location.pathname.startsWith("/settings");
@@ -33,7 +73,7 @@ export default function GlobalAppShell({
header={{ height: 45 }} header={{ height: 45 }}
navbar={ navbar={
!isHomeRoute && { !isHomeRoute && {
width: 300, width: isSpaceRoute ? sidebarWidth : 300,
breakpoint: "sm", breakpoint: "sm",
collapsed: { collapsed: {
mobile: !mobileOpened, mobile: !mobileOpened,
@@ -54,7 +94,8 @@ export default function GlobalAppShell({
<AppHeader /> <AppHeader />
</AppShell.Header> </AppShell.Header>
{!isHomeRoute && ( {!isHomeRoute && (
<AppShell.Navbar className={classes.navbar} withBorder={false}> <AppShell.Navbar className={classes.navbar} withBorder={false} ref={sidebarRef}>
<div className={classes.resizeHandle} onMouseDown={startResizing} />
{isSpaceRoute && <SpaceSidebar />} {isSpaceRoute && <SpaceSidebar />}
{isSettingsRoute && <SettingsSidebar />} {isSettingsRoute && <SettingsSidebar />}
</AppShell.Navbar> </AppShell.Navbar>
@@ -19,3 +19,5 @@ export const asideStateAtom = atom<AsideStateType>({
tab: "", tab: "",
isAsideOpen: false, isAsideOpen: false,
}); });
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
@@ -23,6 +23,7 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import APP_ROUTE from "@/lib/app-route.ts"; import APP_ROUTE from "@/lib/app-route.ts";
import { useQueryClient } from "@tanstack/react-query";
export default function useAuth() { export default function useAuth() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -30,6 +31,7 @@ export default function useAuth() {
const [, setCurrentUser] = useAtom(currentUserAtom); const [, setCurrentUser] = useAtom(currentUserAtom);
const [authToken, setAuthToken] = useAtom(authTokensAtom); const [authToken, setAuthToken] = useAtom(authTokensAtom);
const queryClient = useQueryClient();
const handleSignIn = async (data: ILogin) => { const handleSignIn = async (data: ILogin) => {
setIsLoading(true); setIsLoading(true);
@@ -136,7 +138,8 @@ export default function useAuth() {
setAuthToken(null); setAuthToken(null);
setCurrentUser(null); setCurrentUser(null);
Cookies.remove("authTokens"); Cookies.remove("authTokens");
navigate(APP_ROUTE.AUTH.LOGIN); queryClient.clear();
window.location.replace(APP_ROUTE.AUTH.LOGIN);;
}; };
const handleForgotPassword = async (data: IForgotPassword) => { const handleForgotPassword = async (data: IForgotPassword) => {
@@ -25,7 +25,6 @@ export function useCommentsQuery(
params: ICommentParams, params: ICommentParams,
): UseQueryResult<IPagination<IComment>, Error> { ): UseQueryResult<IPagination<IComment>, Error> {
return useQuery({ return useQuery({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: RQ_KEY(params.pageId), queryKey: RQ_KEY(params.pageId),
queryFn: () => getPageComments(params), queryFn: () => getPageComments(params),
enabled: !!params.pageId, enabled: !!params.pageId,
@@ -23,7 +23,7 @@ import {
showCommentPopupAtom, showCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom"; } from "@/features/comment/atoms/comment-atom";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { v4 as uuidv4 } from "uuid"; import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext"; import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
@@ -84,7 +84,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
name: "comment", name: "comment",
isActive: () => props.editor.isActive("comment"), isActive: () => props.editor.isActive("comment"),
command: () => { command: () => {
const commentId = uuidv4(); const commentId = uuid7();
props.editor.chain().focus().setCommentDecoration().run(); props.editor.chain().focus().setCommentDecoration().run();
setDraftCommentId(commentId); setDraftCommentId(commentId);
@@ -3,7 +3,7 @@ import { ActionIcon, Card, Image, Modal, Text, useComputedColorScheme } from '@m
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { uploadFile } from '@/features/page/services/page-service.ts'; import { uploadFile } from '@/features/page/services/page-service.ts';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { getFileUrl } from '@/lib/config.ts'; import { getDrawioUrl, getFileUrl } from '@/lib/config.ts';
import { import {
DrawIoEmbed, DrawIoEmbed,
DrawIoEmbedRef, DrawIoEmbedRef,
@@ -40,7 +40,7 @@ export default function DrawioView(props: NodeViewProps) {
const reader = new FileReader(); const reader = new FileReader();
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
reader.onloadend = () => { reader.onloadend = () => {
let base64data = (reader.result || '') as string; const base64data = (reader.result || '') as string;
setInitialXML(base64data); setInitialXML(base64data);
}; };
} }
@@ -87,6 +87,7 @@ export default function DrawioView(props: NodeViewProps) {
<DrawIoEmbed <DrawIoEmbed
ref={drawioRef} ref={drawioRef}
xml={initialXML} xml={initialXML}
baseUrl={getDrawioUrl()}
urlParameters={{ urlParameters={{
ui: computedColorScheme === 'light' ? 'kennedy' : 'dark', ui: computedColorScheme === 'light' ? 'kennedy' : 'dark',
spin: true, spin: true,
@@ -10,7 +10,10 @@ export const embedProviders: IEmbedProvider[] = [
id: 'loom', id: 'loom',
name: 'Loom', name: 'Loom',
regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/, regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/,
getEmbedUrl: (match) => { getEmbedUrl: (match, url) => {
if(url.includes("/embed/")){
return url;
}
return `https://loom.com/embed/${match[1]}`; return `https://loom.com/embed/${match[1]}`;
} }
}, },
@@ -20,6 +23,9 @@ export const embedProviders: IEmbedProvider[] = [
regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/, regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/,
getEmbedUrl: (match, url: string) => { getEmbedUrl: (match, url: string) => {
const path = url.split('airtable.com/'); const path = url.split('airtable.com/');
if(url.includes("/embed/")){
return url;
}
return `https://airtable.com/embed/${path[1]}`; return `https://airtable.com/embed/${path[1]}`;
} }
}, },
@@ -43,7 +49,10 @@ export const embedProviders: IEmbedProvider[] = [
id: 'miro', id: 'miro',
name: 'Miro', name: 'Miro',
regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/, regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/,
getEmbedUrl: (match) => { getEmbedUrl: (match, url) => {
if(url.includes("/live-embed/")){
return url;
}
return `https://miro.com/app/live-embed/${match[2]}?embedMode=view_only_without_ui&autoplay=true&embedSource=docmost`; return `https://miro.com/app/live-embed/${match[2]}?embedMode=view_only_without_ui&autoplay=true&embedSource=docmost`;
} }
}, },
@@ -51,7 +60,10 @@ export const embedProviders: IEmbedProvider[] = [
id: 'youtube', id: 'youtube',
name: 'YouTube', name: 'YouTube',
regex: /^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/, regex: /^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
getEmbedUrl: (match) => { getEmbedUrl: (match, url) => {
if (url.includes("/embed/")){
return url;
}
return `https://www.youtube-nocookie.com/embed/${match[5]}`; return `https://www.youtube-nocookie.com/embed/${match[5]}`;
} }
}, },
@@ -38,7 +38,7 @@ export default function MathInlineView(props: NodeViewProps) {
renderMath(preview || "", mathPreviewContainer.current); renderMath(preview || "", mathPreviewContainer.current);
} else if (preview !== null) { } else if (preview !== null) {
queueMicrotask(() => { queueMicrotask(() => {
updateAttributes({ text: preview }); updateAttributes({ text: preview.trim() });
}); });
} }
}, [preview, isEditing]); }, [preview, isEditing]);
@@ -97,7 +97,7 @@ export default function MathInlineView(props: NodeViewProps) {
ref={textAreaRef} ref={textAreaRef}
draggable={false} draggable={false}
classNames={{ input: classes.textInput }} classNames={{ input: classes.textInput }}
value={preview?.trim() ?? ""} value={preview ?? ""}
placeholder={"E = mc^2"} placeholder={"E = mc^2"}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape" || (e.key === "Enter" && !e.shiftKey)) { if (e.key === "Escape" || (e.key === "Enter" && !e.shiftKey)) {
@@ -7,6 +7,7 @@
transition: background-color 0.2s; transition: background-color 0.2s;
padding: 0 0.25rem; padding: 0 0.25rem;
margin: 0 0.1rem; margin: 0 0.1rem;
user-select: none;
&.empty { &.empty {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4)); color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4));
@@ -30,6 +31,7 @@
transition: background-color 0.2s; transition: background-color 0.2s;
margin: 0 0.1rem; margin: 0 0.1rem;
overflow-x: auto; overflow-x: auto;
user-select: none;
.textInput { .textInput {
width: 400px; width: 400px;
@@ -456,7 +456,7 @@ export const getSuggestionItems = ({
const fuzzyMatch = (query: string, target: string) => { const fuzzyMatch = (query: string, target: string) => {
let queryIndex = 0; let queryIndex = 0;
target = target.toLowerCase(); target = target.toLowerCase();
for (let char of target) { for (const char of target) {
if (query[queryIndex] === char) queryIndex++; if (query[queryIndex] === char) queryIndex++;
if (queryIndex === query.length) return true; if (queryIndex === query.length) return true;
} }
@@ -30,8 +30,7 @@ export function FullEditor({
return ( return (
<Container <Container
fluid={fullPageWidth} fluid={fullPageWidth}
{...(fullPageWidth && { mx: 80 })} size={!fullPageWidth && 850}
size={850}
className={classes.editor} className={classes.editor}
> >
<MemoizedTitleEditor <MemoizedTitleEditor
@@ -97,8 +97,8 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
}, [remoteProvider, localProvider]); }, [remoteProvider, localProvider]);
const extensions = [ const extensions = [
...mainExtensions, ... mainExtensions,
...collabExtensions(remoteProvider, currentUser.user), ... collabExtensions(remoteProvider, currentUser.user),
]; ];
const editor = useEditor( const editor = useEditor(
@@ -184,6 +184,7 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
)} )}
</div> </div>
)} )}
<div onClick={() => editor.commands.focus('end')} style={{ paddingBottom: '20vh' }}></div>
</div> </div>
) : ( ) : (
<EditorSkeleton /> <EditorSkeleton />
@@ -25,7 +25,7 @@
color: inherit; color: inherit;
padding: 0; padding: 0;
background: none; background: none;
font-size: inherit; font-size: var(--mantine-font-size-sm);
} }
/* Code styling */ /* Code styling */
@@ -103,12 +103,12 @@
@mixin where-light { @mixin where-light {
background-color: var(--code-bg, var(--mantine-color-gray-1)); background-color: var(--code-bg, var(--mantine-color-gray-1));
color: var(--mantine-color-black); color: var(--mantine-color-pink-7);
} }
@mixin where-dark { @mixin where-dark {
background-color: var(--mantine-color-dark-8); background-color: var(--mantine-color-dark-8);
color: var(--mantine-color-gray-4); color: var(--mantine-color-pink-7);
} }
} }
} }
@@ -10,9 +10,7 @@ import {
pageEditorAtom, pageEditorAtom,
titleEditorAtom, titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms"; } from "@/features/editor/atoms/editor-atoms";
import { import { useUpdatePageMutation } from "@/features/page/queries/page-query";
useUpdatePageMutation,
} from "@/features/page/queries/page-query";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
@@ -39,7 +37,11 @@ export function TitleEditor({
}: TitleEditorProps) { }: TitleEditorProps) {
const [debouncedTitleState, setDebouncedTitleState] = useState(null); const [debouncedTitleState, setDebouncedTitleState] = useState(null);
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500); const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500);
const updatePageMutation = useUpdatePageMutation(); const {
data: updatedPageData,
mutate: updatePageMutation,
status,
} = useUpdatePageMutation();
const pageEditor = useAtomValue(pageEditorAtom); const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom);
const [treeData, setTreeData] = useAtom(treeDataAtom); const [treeData, setTreeData] = useAtom(treeDataAtom);
@@ -47,7 +49,6 @@ export function TitleEditor({
const navigate = useNavigate(); const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId); const [activePageId, setActivePageId] = useState(pageId);
const titleEditor = useEditor({ const titleEditor = useEditor({
extensions: [ extensions: [
Document.extend({ Document.extend({
@@ -87,24 +88,29 @@ export function TitleEditor({
useEffect(() => { useEffect(() => {
if (debouncedTitle !== null && activePageId === pageId) { if (debouncedTitle !== null && activePageId === pageId) {
updatePageMutation.mutate({ updatePageMutation({
pageId: pageId, pageId: pageId,
title: debouncedTitle, title: debouncedTitle,
}); });
}
}, [debouncedTitle]);
useEffect(() => {
if (status === "success" && updatedPageData) {
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
setTreeData(newTreeData);
setTimeout(() => { setTimeout(() => {
emit({ emit({
operation: "updateOne", operation: "updateOne",
spaceId: updatedPageData.spaceId,
entity: ["pages"], entity: ["pages"],
id: pageId, id: pageId,
payload: { title: debouncedTitle, slugId: slugId }, payload: { title: debouncedTitle, slugId: slugId },
}); });
}, 50); }, 50);
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
setTreeData(newTreeData);
} }
}, [debouncedTitle]); }, [updatedPageData, status]);
useEffect(() => { useEffect(() => {
if (titleEditor && title !== titleEditor.getText()) { if (titleEditor && title !== titleEditor.getText()) {
@@ -1,11 +1,11 @@
.breadcrumbs { .breadcrumbs {
flex: 1 1 auto;
display: flex; display: flex;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
a { a {
color: var(--mantine-color-default-color); color: var(--mantine-color-default-color);
line-height: inherit;
} }
.mantine-Breadcrumbs-breadcrumb { .mantine-Breadcrumbs-breadcrumb {
@@ -2,7 +2,7 @@ import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core";
import { import {
IconArrowsHorizontal, IconArrowsHorizontal,
IconDots, IconDots,
IconDownload, IconFileExport,
IconHistory, IconHistory,
IconLink, IconLink,
IconMessage, IconMessage,
@@ -24,6 +24,7 @@ import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx"; import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import PageExportModal from "@/features/page/components/page-export-modal.tsx"; import PageExportModal from "@/features/page/components/page-export-modal.tsx";
import ExportModal from "@/components/common/export-modal";
interface PageHeaderMenuProps { interface PageHeaderMenuProps {
readOnly?: boolean; readOnly?: boolean;
@@ -126,7 +127,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item
leftSection={<IconDownload size={16} />} leftSection={<IconFileExport size={16} />}
onClick={openExportModal} onClick={openExportModal}
> >
Export Export
@@ -154,8 +155,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
<PageExportModal <ExportModal
pageId={page.id} type="page"
id={page.id}
open={exportOpened} open={exportOpened}
onClose={closeExportModal} onClose={closeExportModal}
/> />
@@ -1,4 +1,4 @@
import { Modal, Button, Group, Text, Select } from "@mantine/core"; import { Modal, Button, Group, Text, Select, Switch } from "@mantine/core";
import { exportPage } from "@/features/page/services/page-service.ts"; import { exportPage } from "@/features/page/services/page-service.ts";
import { useState } from "react"; import { useState } from "react";
import * as React from "react"; import * as React from "react";
@@ -57,8 +57,18 @@ export default function PageExportModal({
<Text size="md">Format</Text> <Text size="md">Format</Text>
</div> </div>
<ExportFormatSelection format={format} onChange={handleChange} /> <ExportFormatSelection format={format} onChange={handleChange} />
</Group> </Group>
<Group justify="space-between" wrap="nowrap" pt="md">
<div>
<Text size="md">Include subpages</Text>
</div>
<Switch defaultChecked />
</Group>
<Group justify="center" mt="md"> <Group justify="center" mt="md">
<Button onClick={onClose} variant="default"> <Button onClick={onClose} variant="default">
Cancel Cancel
@@ -119,7 +119,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
return ( return (
<> <>
<SimpleGrid cols={2}> <SimpleGrid cols={2}>
<FileButton onChange={handleFileUpload} accept="text/markdown" multiple> <FileButton onChange={handleFileUpload} accept=".md" multiple>
{(props) => ( {(props) => (
<Button <Button
justify="start" justify="start"
@@ -15,7 +15,7 @@ import {
IconChevronDown, IconChevronDown,
IconChevronRight, IconChevronRight,
IconDotsVertical, IconDotsVertical,
IconFileDescription, IconFileDescription, IconFileExport,
IconLink, IconLink,
IconPlus, IconPlus,
IconPointFilled, IconPointFilled,
@@ -39,7 +39,12 @@ import {
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts"; import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { OpenMap } from "react-arborist/dist/main/state/open-slice"; import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import { useClipboard, useElementSize, useMergedRef } from "@mantine/hooks"; import {
useClipboard,
useDisclosure,
useElementSize,
useMergedRef,
} from "@mantine/hooks";
import { dfs } from "react-arborist/dist/module/utils"; import { dfs } from "react-arborist/dist/module/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -47,6 +52,7 @@ import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts"; import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import ExportModal from "@/components/common/export-modal";
interface SpaceTreeProps { interface SpaceTreeProps {
spaceId: string; spaceId: string;
@@ -133,13 +139,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
flatTreeItems = [ flatTreeItems = [
...flatTreeItems, ...flatTreeItems,
...children.filter( ...children.filter(
(child) => !flatTreeItems.some((item) => item.id === child.id), (child) => !flatTreeItems.some((item) => item.id === child.id)
), ),
]; ];
}; };
const fetchPromises = ancestors.map((ancestor) => const fetchPromises = ancestors.map((ancestor) =>
fetchAndUpdateChildren(ancestor), fetchAndUpdateChildren(ancestor)
); );
// Wait for all fetch operations to complete // Wait for all fetch operations to complete
@@ -153,7 +159,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const updatedTree = appendNodeChildren( const updatedTree = appendNodeChildren(
data, data,
rootChild.id, rootChild.id,
rootChild.children, rootChild.children
); );
setData(updatedTree); setData(updatedTree);
@@ -191,13 +197,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
<div ref={mergedRef} className={classes.treeContainer}> <div ref={mergedRef} className={classes.treeContainer}>
{rootElement.current && ( {rootElement.current && (
<Tree <Tree
data={data} data={data.filter((node) => node?.spaceId === spaceId)}
disableDrag={readOnly} disableDrag={readOnly}
disableDrop={readOnly} disableDrop={readOnly}
disableEdit={readOnly} disableEdit={readOnly}
{...controllers} {...controllers}
width={width} width={width}
height={height} height={rootElement.current.clientHeight}
ref={treeApiRef} ref={treeApiRef}
openByDefault={false} openByDefault={false}
disableMultiSelection={true} disableMultiSelection={true}
@@ -248,7 +254,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const updatedTreeData = appendNodeChildren( const updatedTreeData = appendNodeChildren(
treeData, treeData,
node.data.id, node.data.id,
childrenTree, childrenTree
); );
setTreeData(updatedTreeData); setTreeData(updatedTreeData);
@@ -279,6 +285,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
setTimeout(() => { setTimeout(() => {
emit({ emit({
operation: "updateOne", operation: "updateOne",
spaceId: node.data.spaceId,
entity: ["pages"], entity: ["pages"],
id: node.id, id: node.id,
payload: { icon: emoji.native }, payload: { icon: emoji.native },
@@ -293,6 +300,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
setTimeout(() => { setTimeout(() => {
emit({ emit({
operation: "updateOne", operation: "updateOne",
spaceId: node.data.spaceId,
entity: ["pages"], entity: ["pages"],
id: node.id, id: node.id,
payload: { icon: null }, payload: { icon: null },
@@ -400,6 +408,8 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
const clipboard = useClipboard({ timeout: 500 }); const clipboard = useClipboard({ timeout: 500 });
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { openDeleteModal } = useDeletePageModal(); const { openDeleteModal } = useDeletePageModal();
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const handleCopyLink = () => { const handleCopyLink = () => {
const pageUrl = const pageUrl =
@@ -409,56 +419,76 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
}; };
return ( return (
<Menu shadow="md" width={200}> <>
<Menu.Target> <Menu shadow="md" width={200}>
<ActionIcon <Menu.Target>
variant="transparent" <ActionIcon
c="gray" variant="transparent"
onClick={(e) => { c="gray"
e.preventDefault(); onClick={(e) => {
e.stopPropagation(); e.preventDefault();
}} e.stopPropagation();
> }}
<IconDotsVertical >
style={{ width: rem(20), height: rem(20) }} <IconDotsVertical
stroke={2} style={{ width: rem(20), height: rem(20) }}
/> stroke={2}
</ActionIcon> />
</Menu.Target> </ActionIcon>
</Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />} leftSection={<IconLink size={16} />}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handleCopyLink(); handleCopyLink();
}} }}
> >
Copy link Copy link
</Menu.Item> </Menu.Item>
{!(treeApi.props.disableEdit as boolean) && ( <Menu.Item
<> leftSection={<IconFileExport size={16} />}
<Menu.Divider /> onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openExportModal();
}}
>
Export page
</Menu.Item>
<Menu.Item {!(treeApi.props.disableEdit as boolean) && (
c="red" <>
leftSection={ <Menu.Divider />
<IconTrash style={{ width: rem(14), height: rem(14) }} />
} <Menu.Item
onClick={(e) => { c="red"
e.preventDefault(); leftSection={
e.stopPropagation(); <IconTrash size={16} />
openDeleteModal({ onConfirm: () => treeApi?.delete(node) }); }
}} onClick={(e) => {
> e.preventDefault();
Delete e.stopPropagation();
</Menu.Item> openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
</> }}
)} >
</Menu.Dropdown> Delete
</Menu> </Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<ExportModal
type="page"
id={node.id}
open={exportOpened}
onClose={closeExportModal}
/>
</>
); );
} }
@@ -75,18 +75,19 @@ export function useTreeMutation<T>(spaceId: string) {
setTimeout(() => { setTimeout(() => {
emit({ emit({
operation: "addTreeNode", operation: "addTreeNode",
spaceId: spaceId,
payload: { payload: {
parentId, parentId,
index, index,
data data,
} },
}); });
}, 50); }, 50);
const pageUrl = buildPageUrl( const pageUrl = buildPageUrl(
spaceSlug, spaceSlug,
createdPage.slugId, createdPage.slugId,
createdPage.title, createdPage.title
); );
navigate(pageUrl); navigate(pageUrl);
return data; return data;
@@ -156,18 +157,16 @@ export function useTreeMutation<T>(spaceId: string) {
// check if the previous still has children // check if the previous still has children
// if no children left, change 'hasChildren' to false, to make the page toggle arrows work properly // if no children left, change 'hasChildren' to false, to make the page toggle arrows work properly
const childrenCount = previousParent.children.filter( const childrenCount = previousParent.children.filter(
(child) => child.id !== draggedNodeId, (child) => child.id !== draggedNodeId
).length; ).length;
if (childrenCount === 0) { if (childrenCount === 0) {
tree.update({ tree.update({
id: previousParent.id, id: previousParent.id,
changes: { ... previousParent.data, hasChildren: false } as any, changes: { ...previousParent.data, hasChildren: false } as any,
}); });
} }
} }
//console.log()
setData(tree.data); setData(tree.data);
const payload: IMovePage = { const payload: IMovePage = {
@@ -182,7 +181,13 @@ export function useTreeMutation<T>(spaceId: string) {
setTimeout(() => { setTimeout(() => {
emit({ emit({
operation: "moveTreeNode", operation: "moveTreeNode",
payload: { id: draggedNodeId, parentId: args.parentId, index: args.index, position: newPosition }, spaceId: spaceId,
payload: {
id: draggedNodeId,
parentId: args.parentId,
index: args.index,
position: newPosition,
},
}); });
}, 50); }, 50);
} catch (error) { } catch (error) {
@@ -214,17 +219,17 @@ export function useTreeMutation<T>(spaceId: string) {
setData(tree.data); setData(tree.data);
// navigate only if the current url is same as the deleted page // navigate only if the current url is same as the deleted page
if (pageSlug && node.data.slugId === pageSlug.split('-')[1]) { if (pageSlug && node.data.slugId === pageSlug.split("-")[1]) {
navigate(getSpaceUrl(spaceSlug)); navigate(getSpaceUrl(spaceSlug));
} }
setTimeout(() => { setTimeout(() => {
emit({ emit({
operation: "deleteTreeNode", operation: "deleteTreeNode",
payload: { node: node.data } spaceId: spaceId,
payload: { node: node.data },
}); });
}, 50); }, 50);
} catch (error) { } catch (error) {
console.error("Failed to delete page:", error); console.error("Failed to delete page:", error);
} }
@@ -3,10 +3,12 @@
} }
.treeContainer { .treeContainer {
display: flex; height: 100%;
height: 68vh;
flex: 1;
min-width: 0; min-width: 0;
> div, > div > .tree {
height: 100% !important;
}
} }
.node { .node {
@@ -48,6 +48,7 @@ export interface IPageInput {
export interface IExportPageParams { export interface IExportPageParams {
pageId: string; pageId: string;
format: ExportFormat; format: ExportFormat;
includeChildren?: boolean;
} }
export enum ExportFormat { export enum ExportFormat {
@@ -24,7 +24,7 @@ export default function SpaceSettingsModal({
const {data: space, isLoading} = useSpaceQuery(spaceId); const {data: space, isLoading} = useSpaceQuery(spaceId);
const spaceRules = space?.membership?.permissions; const spaceRules = space?.membership?.permissions;
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]); const spaceAbility = useSpaceAbility(spaceRules);
return ( return (
<> <>
@@ -6,6 +6,7 @@
padding-top: 0; padding-top: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
user-select: none;
} }
.section { .section {
@@ -18,6 +19,16 @@
} }
} }
.sectionPages {
margin-bottom: 0;
overflow-y: hidden;
.pages {
height: 100%;
padding-bottom: 26px;
}
}
.menuItems { .menuItems {
padding-left: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs)); padding-left: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs));
padding-right: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs)); padding-right: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs));
@@ -5,36 +5,38 @@ import {
Text, Text,
Tooltip, Tooltip,
UnstyledButton, UnstyledButton,
} from '@mantine/core'; } from "@mantine/core";
import { spotlight } from '@mantine/spotlight'; import { spotlight } from "@mantine/spotlight";
import { import {
IconArrowDown, IconArrowDown,
IconDots, IconDots,
IconFileExport,
IconHome, IconHome,
IconPlus, IconPlus,
IconSearch, IconSearch,
IconSettings, IconSettings,
} from '@tabler/icons-react'; } from "@tabler/icons-react";
import classes from './space-sidebar.module.css'; import classes from "./space-sidebar.module.css";
import React, { useMemo } from 'react'; import React, { useMemo } from "react";
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import { SearchSpotlight } from '@/features/search/search-spotlight.tsx'; import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom.ts'; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { Link, useLocation, useParams } from 'react-router-dom'; import { Link, useLocation, useParams } from "react-router-dom";
import clsx from 'clsx'; import clsx from "clsx";
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from "@mantine/hooks";
import SpaceSettingsModal from '@/features/space/components/settings-modal.tsx'; import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useGetSpaceBySlugQuery } from '@/features/space/queries/space-query.ts'; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { getSpaceUrl } from '@/lib/config.ts'; import { getSpaceUrl } from "@/lib/config.ts";
import SpaceTree from '@/features/page/tree/components/space-tree.tsx'; import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
import { useSpaceAbility } from '@/features/space/permissions/use-space-ability.ts'; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import { import {
SpaceCaslAction, SpaceCaslAction,
SpaceCaslSubject, SpaceCaslSubject,
} from '@/features/space/permissions/permissions.type.ts'; } from "@/features/space/permissions/permissions.type.ts";
import PageImportModal from '@/features/page/components/page-import-modal.tsx'; import PageImportModal from "@/features/page/components/page-import-modal.tsx";
import { SwitchSpace } from './switch-space'; import { SwitchSpace } from "./switch-space";
import ExportModal from "@/components/common/export-modal";
export function SpaceSidebar() { export function SpaceSidebar() {
const [tree] = useAtom(treeApiAtom); const [tree] = useAtom(treeApiAtom);
@@ -45,14 +47,14 @@ export function SpaceSidebar() {
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug); const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug);
const spaceRules = space?.membership?.permissions; const spaceRules = space?.membership?.permissions;
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]); const spaceAbility = useSpaceAbility(spaceRules);
if (!space) { if (!space) {
return <></>; return <></>;
} }
function handleCreatePage() { function handleCreatePage() {
tree?.create({ parentId: null, type: 'internal', index: 0 }); tree?.create({ parentId: null, type: "internal", index: 0 });
} }
return ( return (
@@ -61,7 +63,7 @@ export function SpaceSidebar() {
<div <div
className={classes.section} className={classes.section}
style={{ style={{
border: 'none', border: "none",
marginTop: 2, marginTop: 2,
marginBottom: 3, marginBottom: 3,
}} }}
@@ -78,7 +80,7 @@ export function SpaceSidebar() {
classes.menu, classes.menu,
location.pathname.toLowerCase() === getSpaceUrl(spaceSlug) location.pathname.toLowerCase() === getSpaceUrl(spaceSlug)
? classes.activeButton ? classes.activeButton
: '' : ""
)} )}
> >
<div className={classes.menuItemInner}> <div className={classes.menuItemInner}>
@@ -134,7 +136,7 @@ export function SpaceSidebar() {
</div> </div>
</div> </div>
<div className={classes.section}> <div className={clsx(classes.section, classes.sectionPages)}>
<Group className={classes.pagesHeader} justify="space-between"> <Group className={classes.pagesHeader} justify="space-between">
<Text size="xs" fw={500} c="dimmed"> <Text size="xs" fw={500} c="dimmed">
Pages Pages
@@ -191,6 +193,8 @@ interface SpaceMenuProps {
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) { function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
const [importOpened, { open: openImportModal, close: closeImportModal }] = const [importOpened, { open: openImportModal, close: closeImportModal }] =
useDisclosure(false); useDisclosure(false);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
return ( return (
<> <>
@@ -215,6 +219,13 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
Import pages Import pages
</Menu.Item> </Menu.Item>
<Menu.Item
onClick={openExportModal}
leftSection={<IconFileExport size={16} />}
>
Export space
</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item
@@ -231,6 +242,13 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
open={importOpened} open={importOpened}
onClose={closeImportModal} onClose={closeImportModal}
/> />
<ExportModal
type="space"
id={spaceId}
open={exportOpened}
onClose={closeExportModal}
/>
</> </>
); );
} }
@@ -12,7 +12,6 @@ interface SwitchSpaceProps {
} }
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) { export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
const [opened, { close, open, toggle }] = useDisclosure(false);
const navigate = useNavigate(); const navigate = useNavigate();
const handleSelect = (value: string) => { const handleSelect = (value: string) => {
@@ -28,7 +27,6 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
position="bottom" position="bottom"
withArrow withArrow
shadow="md" shadow="md"
opened={opened}
> >
<Popover.Target> <Popover.Target>
<Button <Button
@@ -37,7 +35,6 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
justify="space-between" justify="space-between"
rightSection={<IconChevronDown size={18} />} rightSection={<IconChevronDown size={18} />}
color="gray" color="gray"
onClick={toggle}
> >
<Avatar <Avatar
size={20} size={20}
@@ -1,8 +1,10 @@
import React from 'react'; import React from 'react';
import { useSpaceQuery } from '@/features/space/queries/space-query.ts'; import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx'; import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
import { Divider, Group, Text } from '@mantine/core'; import { Button, Divider, Group, Text } from '@mantine/core';
import DeleteSpaceModal from './delete-space-modal'; import DeleteSpaceModal from './delete-space-modal';
import { useDisclosure } from "@mantine/hooks";
import ExportModal from "@/components/common/export-modal.tsx";
interface SpaceDetailsProps { interface SpaceDetailsProps {
spaceId: string; spaceId: string;
@@ -10,6 +12,8 @@ interface SpaceDetailsProps {
} }
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { data: space, isLoading } = useSpaceQuery(spaceId); const { data: space, isLoading } = useSpaceQuery(spaceId);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
return ( return (
<> <>
@@ -22,6 +26,22 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
{!readOnly && ( {!readOnly && (
<> <>
<Divider my="lg" />
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Export space</Text>
<Text size="sm" c="dimmed">
Export all pages and attachments in this space
</Text>
</div>
<Button onClick={openExportModal}>
Export
</Button>
</Group>
<Divider my="lg" /> <Divider my="lg" />
<Group justify="space-between" wrap="nowrap" gap="xl"> <Group justify="space-between" wrap="nowrap" gap="xl">
@@ -34,6 +54,13 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<DeleteSpaceModal space={space} /> <DeleteSpaceModal space={space} />
</Group> </Group>
<ExportModal
type="space"
id={space.id}
open={exportOpened}
onClose={closeExportModal}
/>
</> </>
)} )}
</div> </div>
@@ -1,56 +1,72 @@
import api from '@/lib/api-client'; import api from "@/lib/api-client";
import { import {
IAddSpaceMember, IAddSpaceMember,
IChangeSpaceMemberRole, IChangeSpaceMemberRole,
IExportSpaceParams,
IRemoveSpaceMember, IRemoveSpaceMember,
ISpace, ISpace,
} from "@/features/space/types/space.types"; } from "@/features/space/types/space.types";
import { IPagination, QueryParams } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
import { IUser } from "@/features/user/types/user.types.ts"; import { IUser } from "@/features/user/types/user.types.ts";
import { saveAs } from "file-saver";
export async function getSpaces(params?: QueryParams): Promise<IPagination<ISpace>> { export async function getSpaces(
params?: QueryParams
): Promise<IPagination<ISpace>> {
const req = await api.post("/spaces", params); const req = await api.post("/spaces", params);
return req.data; return req.data;
} }
export async function getSpaceById(spaceId: string): Promise<ISpace> { export async function getSpaceById(spaceId: string): Promise<ISpace> {
const req = await api.post<ISpace>('/spaces/info', { spaceId }); const req = await api.post<ISpace>("/spaces/info", { spaceId });
return req.data; return req.data;
} }
export async function createSpace(data: Partial<ISpace>): Promise<ISpace> { export async function createSpace(data: Partial<ISpace>): Promise<ISpace> {
const req = await api.post<ISpace>('/spaces/create', data); const req = await api.post<ISpace>("/spaces/create", data);
return req.data; return req.data;
} }
export async function updateSpace(data: Partial<ISpace>): Promise<ISpace> { export async function updateSpace(data: Partial<ISpace>): Promise<ISpace> {
const req = await api.post<ISpace>('/spaces/update', data); const req = await api.post<ISpace>("/spaces/update", data);
return req.data; return req.data;
} }
export async function deleteSpace(spaceId: string): Promise<void> { export async function deleteSpace(spaceId: string): Promise<void> {
await api.post<void>('/spaces/delete', { spaceId }); await api.post<void>("/spaces/delete", { spaceId });
} }
export async function getSpaceMembers( export async function getSpaceMembers(
spaceId: string spaceId: string
): Promise<IPagination<IUser>> { ): Promise<IPagination<IUser>> {
const req = await api.post<any>('/spaces/members', { spaceId }); const req = await api.post<any>("/spaces/members", { spaceId });
return req.data; return req.data;
} }
export async function addSpaceMember(data: IAddSpaceMember): Promise<void> { export async function addSpaceMember(data: IAddSpaceMember): Promise<void> {
await api.post('/spaces/members/add', data); await api.post("/spaces/members/add", data);
} }
export async function removeSpaceMember( export async function removeSpaceMember(
data: IRemoveSpaceMember data: IRemoveSpaceMember
): Promise<void> { ): Promise<void> {
await api.post('/spaces/members/remove', data); await api.post("/spaces/members/remove", data);
} }
export async function changeMemberRole( export async function changeMemberRole(
data: IChangeSpaceMemberRole data: IChangeSpaceMemberRole
): Promise<void> { ): Promise<void> {
await api.post('/spaces/members/change-role', data); await api.post("/spaces/members/change-role", data);
}
export async function exportSpace(data: IExportSpaceParams): Promise<void> {
const req = await api.post("/spaces/export", data, {
responseType: "blob",
});
const fileName = req?.headers["content-disposition"]
.split("filename=")[1]
.replace(/"/g, "");
saveAs(req.data, decodeURIComponent(fileName));
} }
@@ -3,6 +3,7 @@ import {
SpaceCaslAction, SpaceCaslAction,
SpaceCaslSubject, SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts"; } from "@/features/space/permissions/permissions.type.ts";
import { ExportFormat } from "@/features/page/types/page.types.ts";
export interface ISpace { export interface ISpace {
id: string; id: string;
@@ -68,3 +69,9 @@ export interface SpaceGroupInfo {
} }
export type ISpaceMember = { role: string } & (SpaceUserInfo | SpaceGroupInfo); export type ISpaceMember = { role: string } & (SpaceUserInfo | SpaceGroupInfo);
export interface IExportSpaceParams {
spaceId: string;
format: ExportFormat;
includeAttachments?: boolean;
}
@@ -2,12 +2,14 @@ import { SpaceTreeNode } from "@/features/page/tree/types.ts";
export type InvalidateEvent = { export type InvalidateEvent = {
operation: "invalidate"; operation: "invalidate";
spaceId: string;
entity: Array<string>; entity: Array<string>;
id?: string; id?: string;
}; };
export type UpdateEvent = { export type UpdateEvent = {
operation: "updateOne"; operation: "updateOne";
spaceId: string;
entity: Array<string>; entity: Array<string>;
id: string; id: string;
payload: Partial<any>; payload: Partial<any>;
@@ -15,6 +17,7 @@ export type UpdateEvent = {
export type DeleteEvent = { export type DeleteEvent = {
operation: "deleteOne"; operation: "deleteOne";
spaceId: string;
entity: Array<string>; entity: Array<string>;
id: string; id: string;
payload?: Partial<any>; payload?: Partial<any>;
@@ -22,6 +25,7 @@ export type DeleteEvent = {
export type AddTreeNodeEvent = { export type AddTreeNodeEvent = {
operation: "addTreeNode"; operation: "addTreeNode";
spaceId: string;
payload: { payload: {
parentId: string; parentId: string;
index: number; index: number;
@@ -31,6 +35,7 @@ export type AddTreeNodeEvent = {
export type MoveTreeNodeEvent = { export type MoveTreeNodeEvent = {
operation: "moveTreeNode"; operation: "moveTreeNode";
spaceId: string;
payload: { payload: {
id: string; id: string;
parentId: string; parentId: string;
@@ -41,6 +46,7 @@ export type MoveTreeNodeEvent = {
export type DeleteTreeNodeEvent = { export type DeleteTreeNodeEvent = {
operation: "deleteTreeNode"; operation: "deleteTreeNode";
spaceId: string;
payload: { payload: {
node: SpaceTreeNode node: SpaceTreeNode
} }
@@ -46,30 +46,34 @@ export const useTreeSocket = () => {
break; break;
case 'moveTreeNode': case 'moveTreeNode':
// move node // move node
treeApi.move({ if (treeApi.find(event.payload.id)) {
id: event.payload.id, treeApi.move({
parentId: event.payload.parentId, id: event.payload.id,
index: event.payload.index parentId: event.payload.parentId,
}); index: event.payload.index
});
// update node position // update node position
treeApi.update({ treeApi.update({
id: event.payload.id, id: event.payload.id,
changes: { changes: {
position: event.payload.position, position: event.payload.position,
} }
}); });
setTreeData(treeApi.data); setTreeData(treeApi.data);
}
break; break;
case "deleteTreeNode": case "deleteTreeNode":
treeApi.drop({ id: event.payload.node.id }); if (treeApi.find(event.payload.node.id)){
setTreeData(treeApi.data); treeApi.drop({ id: event.payload.node.id });
setTreeData(treeApi.data);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['pages', event.payload.node.slugId].filter(Boolean), queryKey: ['pages', event.payload.node.slugId].filter(Boolean),
}); });
}
break; break;
} }
}); });
@@ -141,7 +141,6 @@ export function useGetInvitationQuery(
invitationId: string, invitationId: string,
): UseQueryResult<any, Error> { ): UseQueryResult<any, Error> {
return useQuery({ return useQuery({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: ["invitations", invitationId], queryKey: ["invitations", invitationId],
queryFn: () => getInvitationById({ invitationId }), queryFn: () => getInvitationById({ invitationId }),
enabled: !!invitationId, enabled: !!invitationId,
+9 -5
View File
@@ -26,14 +26,18 @@ api.interceptors.request.use(
}, },
(error) => { (error) => {
return Promise.reject(error); return Promise.reject(error);
}, }
); );
api.interceptors.response.use( api.interceptors.response.use(
(response) => { (response) => {
// we need the response headers // we need the response headers for these endpoints
if (response.request.responseURL.includes("/api/pages/export")) { const exemptEndpoints = ["/api/pages/export", "/api/spaces/export"];
return response; if (response.request.responseURL) {
const path = new URL(response.request.responseURL)?.pathname;
if (path && exemptEndpoints.includes(path)) {
return response;
}
} }
return response.data; return response.data;
@@ -72,7 +76,7 @@ api.interceptors.response.use(
} }
} }
return Promise.reject(error); return Promise.reject(error);
}, }
); );
function redirectToLogin() { function redirectToLogin() {
+9 -1
View File
@@ -57,6 +57,14 @@ export function getFileUrl(src: string) {
} }
export function getFileUploadSizeLimit() { export function getFileUploadSizeLimit() {
const limit = window.CONFIG?.FILE_UPLOAD_SIZE_LIMIT || process?.env.FILE_UPLOAD_SIZE_LIMIT || '50mb'; const limit =getConfigValue("FILE_UPLOAD_SIZE_LIMIT", "50mb");
return bytes(limit); return bytes(limit);
}
export function getDrawioUrl() {
return getConfigValue("DRAWIO_URL", "https://embed.diagrams.net");
}
function getConfigValue(key: string, defaultValue: string = undefined) {
return window.CONFIG?.[key] || process?.env?.[key] || defaultValue;
} }
+2 -2
View File
@@ -2,9 +2,9 @@ import { atom } from "jotai";
export function atomWithWebStorage<Value>(key: string, initialValue: Value, storage = localStorage) { export function atomWithWebStorage<Value>(key: string, initialValue: Value, storage = localStorage) {
const storedValue = localStorage.getItem(key); const storedValue = localStorage.getItem(key);
const isString = typeof initialValue === "string"; const isStringOrInt = typeof initialValue === "string" || typeof initialValue === "number";
const storageValue = storedValue ? isString ? storedValue : storedValue === "true" : undefined; const storageValue = storedValue ? isStringOrInt ? storedValue : storedValue === "true" : undefined;
const baseAtom = atom(storageValue ?? initialValue); const baseAtom = atom(storageValue ?? initialValue);
return atom( return atom(
+1 -1
View File
@@ -74,4 +74,4 @@ export function decodeBase64ToSvgString(base64Data: string): string {
export function capitalizeFirstChar(string: string) { export function capitalizeFirstChar(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1); return string.charAt(0).toUpperCase() + string.slice(1);
} }
+1 -1
View File
@@ -23,7 +23,7 @@ export default function Page() {
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const spaceRules = space?.membership?.permissions; const spaceRules = space?.membership?.permissions;
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]); const spaceAbility = useSpaceAbility(spaceRules);
if (isLoading) { if (isLoading) {
return <></>; return <></>;
+3 -2
View File
@@ -5,13 +5,14 @@ import * as path from "path";
export const envPath = path.resolve(process.cwd(), "..", ".."); export const envPath = path.resolve(process.cwd(), "..", "..");
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const { APP_URL, FILE_UPLOAD_SIZE_LIMIT } = loadEnv(mode, envPath, ""); const { APP_URL, FILE_UPLOAD_SIZE_LIMIT, DRAWIO_URL } = loadEnv(mode, envPath, "");
return { return {
define: { define: {
"process.env": { "process.env": {
APP_URL, APP_URL,
FILE_UPLOAD_SIZE_LIMIT FILE_UPLOAD_SIZE_LIMIT,
DRAWIO_URL
}, },
'APP_VERSION': JSON.stringify(process.env.npm_package_version), 'APP_VERSION': JSON.stringify(process.env.npm_package_version),
}, },
-25
View File
@@ -1,25 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};
+34
View File
@@ -0,0 +1,34 @@
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';
/** @type {import('eslint').Linter.Config[]} */
export default [
js.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
{
ignores: ['eslint.config.mjs'],
},
{
languageOptions: {
globals: { ...globals.node, ...globals.jest },
sourceType: 'module',
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'prefer-rest-params': 'off',
'no-useless-catch': 'off',
'no-useless-escape': 'off',
},
},
];
+37 -37
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.5.0", "version": "0.6.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -28,43 +28,43 @@
"test:e2e": "jest --config test/jest-e2e.json" "test:e2e": "jest --config test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.637.0", "@aws-sdk/client-s3": "^3.701.0",
"@aws-sdk/s3-request-presigner": "^3.637.0", "@aws-sdk/s3-request-presigner": "^3.701.0",
"@casl/ability": "^6.7.1", "@casl/ability": "^6.7.2",
"@fastify/cookie": "^9.4.0", "@fastify/cookie": "^9.4.0",
"@fastify/multipart": "^8.3.0", "@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
"@nestjs/bullmq": "^10.2.1", "@nestjs/bullmq": "^10.2.2",
"@nestjs/common": "^10.4.1", "@nestjs/common": "^10.4.9",
"@nestjs/config": "^3.2.3", "@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.1", "@nestjs/core": "^10.4.9",
"@nestjs/event-emitter": "^2.0.4", "@nestjs/event-emitter": "^2.1.1",
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "^2.0.5", "@nestjs/mapped-types": "^2.0.6",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-fastify": "^10.4.1", "@nestjs/platform-fastify": "^10.4.9",
"@nestjs/platform-socket.io": "^10.4.1", "@nestjs/platform-socket.io": "^10.4.9",
"@nestjs/terminus": "^10.2.3", "@nestjs/terminus": "^10.2.3",
"@nestjs/websockets": "^10.4.1", "@nestjs/websockets": "^10.4.9",
"@react-email/components": "0.0.24", "@react-email/components": "0.0.28",
"@react-email/render": "^1.0.1", "@react-email/render": "^1.0.2",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bullmq": "^5.12.12", "bullmq": "^5.29.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"fix-esm": "^1.0.1", "fix-esm": "^1.0.1",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"happy-dom": "^15.7.3", "happy-dom": "^15.11.6",
"kysely": "^0.27.4", "kysely": "^0.27.4",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
"marked": "^13.0.3", "marked": "^13.0.3",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"nanoid": "^5.0.7", "nanoid": "^5.0.9",
"nestjs-kysely": "^1.0.0", "nestjs-kysely": "^1.0.0",
"nodemailer": "^6.9.14", "nodemailer": "^6.9.16",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.12.0", "pg": "^8.13.1",
"pg-tsquery": "^8.4.2", "pg-tsquery": "^8.4.2",
"postmark": "^4.0.5", "postmark": "^4.0.5",
"react": "^18.3.1", "react": "^18.3.1",
@@ -72,40 +72,40 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sanitize-filename-ts": "^1.0.2", "sanitize-filename-ts": "^1.0.2",
"socket.io": "^4.7.5", "socket.io": "^4.8.1",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.4.5", "@eslint/js": "^9.16.0",
"@nestjs/schematics": "^10.1.4", "@nestjs/cli": "^10.4.8",
"@nestjs/testing": "^10.4.1", "@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.9",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/debounce": "^1.2.4", "@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.14",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/node": "^22.5.2", "@types/node": "^22.10.0",
"@types/nodemailer": "^6.4.15", "@types/nodemailer": "^6.4.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.11.8", "@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/ws": "^8.5.12", "@types/ws": "^8.5.13",
"@typescript-eslint/eslint-plugin": "^8.3.0", "eslint": "^9.15.0",
"@typescript-eslint/parser": "^8.3.0",
"eslint": "^9.9.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1", "globals": "^15.13.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"kysely-codegen": "^0.16.3", "kysely-codegen": "^0.17.0",
"prettier": "^3.3.3", "prettier": "^3.4.1",
"react-email": "^3.0.1", "react-email": "^3.0.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4" "typescript": "^5.7.2",
"typescript-eslint": "^8.17.0"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
@@ -36,6 +36,7 @@ export class CollaborationGateway {
port: this.redisConfig.port, port: this.redisConfig.port,
options: { options: {
password: this.redisConfig.password, password: this.redisConfig.password,
db: this.redisConfig.db,
retryStrategy: createRetryStrategy(), retryStrategy: createRetryStrategy(),
}, },
}), }),
@@ -1,15 +1,15 @@
import {StarterKit} from '@tiptap/starter-kit'; import { StarterKit } from '@tiptap/starter-kit';
import {TextAlign} from '@tiptap/extension-text-align'; import { TextAlign } from '@tiptap/extension-text-align';
import {TaskList} from '@tiptap/extension-task-list'; import { TaskList } from '@tiptap/extension-task-list';
import {TaskItem} from '@tiptap/extension-task-item'; import { TaskItem } from '@tiptap/extension-task-item';
import {Underline} from '@tiptap/extension-underline'; import { Underline } from '@tiptap/extension-underline';
import {Superscript} from '@tiptap/extension-superscript'; import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript'; import SubScript from '@tiptap/extension-subscript';
import {Highlight} from '@tiptap/extension-highlight'; import { Highlight } from '@tiptap/extension-highlight';
import {Typography} from '@tiptap/extension-typography'; import { Typography } from '@tiptap/extension-typography';
import {TextStyle} from '@tiptap/extension-text-style'; import { TextStyle } from '@tiptap/extension-text-style';
import {Color} from '@tiptap/extension-color'; import { Color } from '@tiptap/extension-color';
import {Youtube} from '@tiptap/extension-youtube'; import { Youtube } from '@tiptap/extension-youtube';
import Table from '@tiptap/extension-table'; import Table from '@tiptap/extension-table';
import TableHeader from '@tiptap/extension-table-header'; import TableHeader from '@tiptap/extension-table-header';
import { import {
@@ -30,14 +30,15 @@ import {
Attachment, Attachment,
Drawio, Drawio,
Excalidraw, Excalidraw,
Embed Embed,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import {generateText, JSONContent} from '@tiptap/core'; import { generateText, getSchema, JSONContent } from '@tiptap/core';
import {generateHTML} from '../common/helpers/prosemirror/html'; import { generateHTML } from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not HTML // @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352 // see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089 // see:https://github.com/ueberdosis/tiptap/issues/4089
import {generateJSON} from '@tiptap/html'; import { generateJSON } from '@tiptap/html';
import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit.configure({ StarterKit.configure({
@@ -73,7 +74,7 @@ export const tiptapExtensions = [
CustomCodeBlock, CustomCodeBlock,
Drawio, Drawio,
Excalidraw, Excalidraw,
Embed Embed,
] as any; ] as any;
export function jsonToHtml(tiptapJson: any) { export function jsonToHtml(tiptapJson: any) {
@@ -88,6 +89,10 @@ export function jsonToText(tiptapJson: JSONContent) {
return generateText(tiptapJson, tiptapExtensions); return generateText(tiptapJson, tiptapExtensions);
} }
export function jsonToNode(tiptapJson: JSONContent) {
return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson);
}
export function getPageId(documentName: string) { export function getPageId(documentName: string) {
return documentName.split('.')[1]; return documentName.split('.')[1];
} }
@@ -1,4 +1,4 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-require-imports
const { customAlphabet } = require('fix-esm').require('nanoid'); const { customAlphabet } = require('fix-esm').require('nanoid');
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
+12 -2
View File
@@ -18,15 +18,25 @@ export async function comparePasswordHash(
export type RedisConfig = { export type RedisConfig = {
host: string; host: string;
port: number; port: number;
db: number;
password?: string; password?: string;
}; };
export function parseRedisUrl(redisUrl: string): RedisConfig { export function parseRedisUrl(redisUrl: string): RedisConfig {
// format - redis[s]://[[username][:password]@][host][:port][/db-number] // format - redis[s]://[[username][:password]@][host][:port][/db-number]
const { hostname, port, password } = new URL(redisUrl); const { hostname, port, password, pathname } = new URL(redisUrl);
const portInt = parseInt(port, 10); const portInt = parseInt(port, 10);
return { host: hostname, port: portInt, password }; let db: number = 0;
// extract db value if present
if (pathname.length > 1) {
const value = pathname.slice(1);
if (!isNaN(parseInt(value))){
db = parseInt(value, 10);
}
}
return { host: hostname, port: portInt, password, db };
} }
export function createRetryStrategy() { export function createRetryStrategy() {
@@ -178,7 +178,7 @@ export class AttachmentController {
const fileStream = await this.storageService.read(attachment.filePath); const fileStream = await this.storageService.read(attachment.filePath);
res.headers({ res.headers({
'Content-Type': attachment.mimeType, 'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600', 'Cache-Control': 'private, max-age=3600',
}); });
if (!inlineFileExtensions.includes(attachment.fileExt)) { if (!inlineFileExtensions.includes(attachment.fileExt)) {
@@ -299,7 +299,7 @@ export class AttachmentController {
const fileStream = await this.storageService.read(filePath); const fileStream = await this.storageService.read(filePath);
res.headers({ res.headers({
'Content-Type': getMimeType(filePath), 'Content-Type': getMimeType(filePath),
'Cache-Control': 'public, max-age=86400', 'Cache-Control': 'private, max-age=86400',
}); });
return res.send(fileStream); return res.send(fileStream);
} catch (err) { } catch (err) {
@@ -52,7 +52,7 @@ export class AttachmentService {
// passing attachmentId to allow for updating diagrams // passing attachmentId to allow for updating diagrams
// instead of creating new files for each save // instead of creating new files for each save
if (opts?.attachmentId) { if (opts?.attachmentId) {
let existingAttachment = await this.attachmentRepo.findById( const existingAttachment = await this.attachmentRepo.findById(
opts.attachmentId, opts.attachmentId,
); );
if (!existingAttachment) { if (!existingAttachment) {
@@ -1,6 +1,7 @@
import { import {
BadRequestException, BadRequestException,
Injectable, Injectable,
Logger,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
@@ -13,6 +14,8 @@ import { FastifyRequest } from 'fastify';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
private logger = new Logger('JwtStrategy');
constructor( constructor(
private userRepo: UserRepo, private userRepo: UserRepo,
private workspaceRepo: WorkspaceRepo, private workspaceRepo: WorkspaceRepo,
@@ -24,7 +27,9 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
try { try {
accessToken = JSON.parse(req.cookies?.authTokens)?.accessToken; accessToken = JSON.parse(req.cookies?.authTokens)?.accessToken;
} catch {} } catch {
this.logger.debug('Failed to parse access token');
}
return accessToken || this.extractTokenFromHeader(req); return accessToken || this.extractTokenFromHeader(req);
}, },
@@ -5,7 +5,8 @@ import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely'; import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
// eslint-disable-next-line @typescript-eslint/no-var-requires
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')(); const tsquery = require('pg-tsquery')();
@Injectable() @Injectable()
@@ -33,13 +33,13 @@ export async function executeWithPagination<O, DB, TB extends keyof DB>(
.select((eb) => eb.ref(deferredJoinPrimaryKey).as('primaryKey')) .select((eb) => eb.ref(deferredJoinPrimaryKey).as('primaryKey'))
.execute() .execute()
// @ts-expect-error TODO: Fix the type here later // @ts-expect-error TODO: Fix the type here later
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
.then((rows) => rows.map((row) => row.primaryKey)); .then((rows) => rows.map((row) => row.primaryKey));
qb = qb qb = qb
.where((eb) => .where((eb) =>
primaryKeys.length > 0 primaryKeys.length > 0
? // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any ?
eb(deferredJoinPrimaryKey, 'in', primaryKeys as any) eb(deferredJoinPrimaryKey, 'in', primaryKeys as any)
: eb(sql`1`, '=', 0), : eb(sql`1`, '=', 0),
) )
@@ -160,4 +160,31 @@ export class PageRepo {
.whereRef('spaces.id', '=', 'pages.spaceId'), .whereRef('spaces.id', '=', 'pages.spaceId'),
).as('space'); ).as('space');
} }
async getPageAndDescendants(parentPageId: string) {
return this.db
.withRecursive('page_hierarchy', (db) =>
db
.selectFrom('pages')
.select(['id', 'slugId', 'title', 'icon', 'content', 'parentPageId', 'spaceId'])
.where('id', '=', parentPageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.content',
'p.parentPageId',
'p.spaceId',
])
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
),
)
.selectFrom('page_hierarchy')
.selectAll()
.execute();
}
} }
@@ -122,6 +122,10 @@ export class EnvironmentService {
return this.configService.get<string>('POSTMARK_TOKEN'); return this.configService.get<string>('POSTMARK_TOKEN');
} }
getDrawioUrl(): string {
return this.configService.get<string>('DRAWIO_URL');
}
isCloud(): boolean { isCloud(): boolean {
const cloudConfig = this.configService const cloudConfig = this.configService
.get<string>('CLOUD', 'false') .get<string>('CLOUD', 'false')
@@ -11,14 +11,22 @@ import { plainToInstance } from 'class-transformer';
export class EnvironmentVariables { export class EnvironmentVariables {
@IsNotEmpty() @IsNotEmpty()
@IsUrl( @IsUrl(
{ protocols: ['postgres', 'postgresql'], require_tld: false }, {
protocols: ['postgres', 'postgresql'],
require_tld: false,
allow_underscores: true,
},
{ message: 'DATABASE_URL must be a valid postgres connection string' }, { message: 'DATABASE_URL must be a valid postgres connection string' },
) )
DATABASE_URL: string; DATABASE_URL: string;
@IsNotEmpty() @IsNotEmpty()
@IsUrl( @IsUrl(
{ protocols: ['redis', 'rediss'], require_tld: false }, {
protocols: ['redis', 'rediss'],
require_tld: false,
allow_underscores: true,
},
{ message: 'REDIS_URL must be a valid redis connection string' }, { message: 'REDIS_URL must be a valid redis connection string' },
) )
REDIS_URL: string; REDIS_URL: string;
@@ -22,5 +22,19 @@ export class ExportPageDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
includeFiles?: boolean; includeChildren?: boolean;
} }
export class ExportSpaceDto {
@IsString()
@IsNotEmpty()
spaceId: string;
@IsString()
@IsIn(['html', 'markdown'])
format: ExportFormat;
@IsOptional()
@IsBoolean()
includeAttachments?: boolean;
}
@@ -10,7 +10,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import { ExportPageDto } from './dto/export-dto'; import { ExportPageDto, ExportSpaceDto } from './dto/export-dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { User } from '@docmost/db/types/entity.types'; import { User } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory'; import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
@@ -24,9 +24,10 @@ import { FastifyReply } from 'fastify';
import { sanitize } from 'sanitize-filename-ts'; import { sanitize } from 'sanitize-filename-ts';
import { getExportExtension } from './utils'; import { getExportExtension } from './utils';
import { getMimeType } from '../../common/helpers'; import { getMimeType } from '../../common/helpers';
import * as path from 'path';
@Controller() @Controller()
export class ImportController { export class ExportController {
constructor( constructor(
private readonly exportService: ExportService, private readonly exportService: ExportService,
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
@@ -54,10 +55,28 @@ export class ImportController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
const rawContent = await this.exportService.exportPage(dto.format, page);
const fileExt = getExportExtension(dto.format); const fileExt = getExportExtension(dto.format);
const fileName = sanitize(page.title || 'Untitled') + fileExt; const fileName = sanitize(page.title || 'untitled') + fileExt;
if (dto.includeChildren) {
const zipFileBuffer = await this.exportService.exportPageWithChildren(
dto.pageId,
dto.format,
);
const newName = path.parse(fileName).name + '.zip';
res.headers({
'Content-Type': 'application/zip',
'Content-Disposition':
'attachment; filename="' + encodeURIComponent(newName) + '"',
});
res.send(zipFileBuffer);
return;
}
const rawContent = await this.exportService.exportPage(dto.format, page);
res.headers({ res.headers({
'Content-Type': getMimeType(fileExt), 'Content-Type': getMimeType(fileExt),
@@ -67,4 +86,34 @@ export class ImportController {
res.send(rawContent); res.send(rawContent);
} }
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('spaces/export')
async exportSpace(
@Body() dto: ExportSpaceDto,
@AuthUser() user: User,
@Res() res: FastifyReply,
) {
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const exportFile = await this.exportService.exportSpace(
dto.spaceId,
dto.format,
dto.includeAttachments,
);
res.headers({
'Content-Type': 'application/zip',
'Content-Disposition':
'attachment; filename="' +
encodeURIComponent(sanitize(exportFile.fileName)) +
'"',
});
res.send(exportFile.fileBuffer);
}
} }
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import { ImportController } from './export.controller'; import { ExportController } from './export.controller';
import { StorageModule } from '../storage/storage.module';
@Module({ @Module({
imports: [StorageModule],
providers: [ExportService], providers: [ExportService],
controllers: [ImportController], controllers: [ExportController],
}) })
export class ExportModule {} export class ExportModule {}
@@ -1,19 +1,48 @@
import { Injectable } from '@nestjs/common'; import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { jsonToHtml } from '../../collaboration/collaboration.util'; import { jsonToHtml } from '../../collaboration/collaboration.util';
import { turndown } from './turndown-utils'; import { turndown } from './turndown-utils';
import { ExportFormat } from './dto/export-dto'; import { ExportFormat } from './dto/export-dto';
import { Page } from '@docmost/db/types/entity.types'; import { Page } from '@docmost/db/types/entity.types';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import * as JSZip from 'jszip';
import { StorageService } from '../storage/storage.service';
import {
buildTree,
computeLocalPath,
getAttachmentIds,
getExportExtension,
getPageTitle,
getProsemirrorContent,
PageExportTree,
replaceInternalLinks,
updateAttachmentUrls,
} from './utils';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
@Injectable() @Injectable()
export class ExportService { export class ExportService {
private readonly logger = new Logger(ExportService.name);
constructor(
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
) {}
async exportPage(format: string, page: Page) { async exportPage(format: string, page: Page) {
const titleNode = { const titleNode = {
type: 'heading', type: 'heading',
attrs: { level: 1 }, attrs: { level: 1 },
content: [{ type: 'text', text: page.title }], content: [{ type: 'text', text: getPageTitle(page.title) }],
}; };
let prosemirrorJson: any = page.content || { type: 'doc', content: [] }; const prosemirrorJson: any = getProsemirrorContent(page.content);
if (page.title) { if (page.title) {
prosemirrorJson.content.unshift(titleNode); prosemirrorJson.content.unshift(titleNode);
@@ -22,7 +51,13 @@ export class ExportService {
const pageHtml = jsonToHtml(prosemirrorJson); const pageHtml = jsonToHtml(prosemirrorJson);
if (format === ExportFormat.HTML) { if (format === ExportFormat.HTML) {
return `<!DOCTYPE html><html><head><title>${page.title}</title></head><body>${pageHtml}</body></html>`; return `<!DOCTYPE html>
<html>
<head>
<title>${getPageTitle(page.title)}</title>
</head>
<body>${pageHtml}</body>
</html>`;
} }
if (format === ExportFormat.Markdown) { if (format === ExportFormat.Markdown) {
@@ -31,4 +66,157 @@ export class ExportService {
return; return;
} }
async exportPageWithChildren(pageId: string, format: string) {
const pages = await this.pageRepo.getPageAndDescendants(pageId);
if (!pages || pages.length === 0) {
throw new BadRequestException('No pages to export');
}
const parentPageIndex = pages.findIndex((obj) => obj.id === pageId);
// set to null to make export of pages with parentId work
pages[parentPageIndex].parentPageId = null;
const tree = buildTree(pages as Page[]);
const zip = new JSZip();
await this.zipPages(tree, format, zip);
const zipFile = zip.generateNodeStream({
type: 'nodebuffer',
streamFiles: true,
compression: 'DEFLATE',
});
return zipFile;
}
async exportSpace(
spaceId: string,
format: string,
includeAttachments: boolean,
) {
const space = await this.db
.selectFrom('spaces')
.selectAll()
.where('id', '=', spaceId)
.executeTakeFirst();
if (!space) {
throw new NotFoundException('Space not found');
}
const pages = await this.db
.selectFrom('pages')
.select([
'pages.id',
'pages.slugId',
'pages.title',
'pages.content',
'pages.parentPageId',
'pages.spaceId'
])
.where('spaceId', '=', spaceId)
.execute();
const tree = buildTree(pages as Page[]);
const zip = new JSZip();
await this.zipPages(tree, format, zip, includeAttachments);
const zipFile = zip.generateNodeStream({
type: 'nodebuffer',
streamFiles: true,
compression: 'DEFLATE',
});
const fileName = `${space.name}-space-export.zip`;
return {
fileBuffer: zipFile,
fileName,
};
}
async zipPages(
tree: PageExportTree,
format: string,
zip: JSZip,
includeAttachments = true,
): Promise<void> {
const slugIdToPath: Record<string, string> = {};
computeLocalPath(tree, format, null, '', slugIdToPath);
const stack: { folder: JSZip; parentPageId: string }[] = [
{ folder: zip, parentPageId: null },
];
while (stack.length > 0) {
const { folder, parentPageId } = stack.pop();
const children = tree[parentPageId] || [];
for (const page of children) {
const childPages = tree[page.id] || [];
const prosemirrorJson = getProsemirrorContent(page.content);
const currentPagePath = slugIdToPath[page.slugId];
let updatedJsonContent = replaceInternalLinks(
prosemirrorJson,
slugIdToPath,
currentPagePath,
);
if (includeAttachments) {
await this.zipAttachments(updatedJsonContent, page.spaceId, folder);
updatedJsonContent = updateAttachmentUrls(updatedJsonContent);
}
const pageTitle = getPageTitle(page.title);
const pageExportContent = await this.exportPage(format, {
...page,
content: updatedJsonContent,
});
folder.file(
`${pageTitle}${getExportExtension(format)}`,
pageExportContent,
);
if (childPages.length > 0) {
const pageFolder = folder.folder(pageTitle);
stack.push({ folder: pageFolder, parentPageId: page.id });
}
}
}
}
async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) {
const attachmentIds = getAttachmentIds(prosemirrorJson);
if (attachmentIds.length > 0) {
const attachments = await this.db
.selectFrom('attachments')
.selectAll()
.where('id', 'in', attachmentIds)
.where('spaceId', '=', spaceId)
.execute();
await Promise.all(
attachments.map(async (attachment) => {
try {
const fileBuffer = await this.storageService.read(
attachment.filePath,
);
const filePath = `/files/${attachment.id}/${attachment.fileName}`;
zip.file(filePath, fileBuffer);
} catch (err) {
this.logger.debug(`Attachment export error ${attachment.id}`, err);
}
}),
);
}
}
} }
@@ -117,7 +117,7 @@ function mathBlock(turndownService: TurndownService) {
); );
}, },
replacement: function (content: any, node: HTMLInputElement) { replacement: function (content: any, node: HTMLInputElement) {
return `\n$$${content}$$\n`; return `\n$$\n${content}\n$$\n`;
}, },
}); });
} }
@@ -1,4 +1,11 @@
import { jsonToNode } from 'src/collaboration/collaboration.util';
import { ExportFormat } from './dto/export-dto'; import { ExportFormat } from './dto/export-dto';
import { Node } from '@tiptap/pm/model';
import { validate as isValidUUID } from 'uuid';
import * as path from 'path';
import { Page } from '@docmost/db/types/entity.types';
export type PageExportTree = Record<string, Page[]>;
export function getExportExtension(format: string) { export function getExportExtension(format: string) {
if (format === ExportFormat.HTML) { if (format === ExportFormat.HTML) {
@@ -10,3 +17,171 @@ export function getExportExtension(format: string) {
} }
return; return;
} }
export function getPageTitle(title: string) {
return title ? title : 'untitled';
}
export function getProsemirrorContent(content: any) {
return (
content ?? {
type: 'doc',
content: [{ type: 'paragraph', attrs: { textAlign: 'left' } }],
}
);
}
export function getAttachmentIds(prosemirrorJson: any) {
const doc = jsonToNode(prosemirrorJson);
const attachmentIds = [];
doc?.descendants((node: Node) => {
if (isAttachmentNode(node.type.name)) {
if (node.attrs.attachmentId && isValidUUID(node.attrs.attachmentId)) {
if (!attachmentIds.includes(node.attrs.attachmentId)) {
attachmentIds.push(node.attrs.attachmentId);
}
}
}
});
return attachmentIds;
}
export function isAttachmentNode(nodeType: string) {
const attachmentNodeTypes = [
'attachment',
'image',
'video',
'excalidraw',
'drawio',
];
return attachmentNodeTypes.includes(nodeType);
}
export function updateAttachmentUrls(prosemirrorJson: any) {
const doc = jsonToNode(prosemirrorJson);
doc?.descendants((node: Node) => {
if (isAttachmentNode(node.type.name)) {
if (node.attrs.src && node.attrs.src.startsWith('/files')) {
//@ts-expect-error
node.attrs.src = node.attrs.src.replace('/files', 'files');
} else if (node.attrs.url && node.attrs.url.startsWith('/files')) {
//@ts-expect-error
node.attrs.url = node.attrs.url.replace('/files', 'files');
}
}
});
return doc.toJSON();
}
export function replaceInternalLinks(
prosemirrorJson: any,
slugIdToPath: Record<string, string>,
currentPagePath: string,
) {
const doc = jsonToNode(prosemirrorJson);
const internalLinkRegex =
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
doc.descendants((node: Node) => {
for (const mark of node.marks) {
if (mark.type.name === 'link' && mark.attrs.href) {
const match = mark.attrs.href.match(internalLinkRegex);
if (match) {
const markLink = mark.attrs.href;
const slugId = extractPageSlugId(match[5]);
const localPath = slugIdToPath[slugId];
if (!localPath) {
continue;
}
const relativePath = computeRelativePath(currentPagePath, localPath);
//@ts-expect-error
mark.attrs.href = relativePath;
//@ts-expect-error
mark.attrs.target = '_self';
if (node.isText) {
// if link and text are same, use page title
if (markLink === node.text) {
//@ts-expect-error
node.text = getInternalLinkPageName(relativePath);
}
}
}
}
}
});
return doc.toJSON();
}
export function getInternalLinkPageName(path: string): string {
return decodeURIComponent(
path?.split('/').pop().split('.').slice(0, -1).join('.'),
);
}
export function extractPageSlugId(input: string): string {
if (!input) {
return undefined;
}
const parts = input.split('-');
return parts.length > 1 ? parts[parts.length - 1] : input;
}
export function buildTree(pages: Page[]): PageExportTree {
const tree: PageExportTree = {};
const titleCount: Record<string, Record<string, number>> = {};
for (const page of pages) {
const parentPageId = page.parentPageId;
if (!titleCount[parentPageId]) {
titleCount[parentPageId] = {};
}
let title = getPageTitle(page.title);
if (titleCount[parentPageId][title]) {
title = `${title} (${titleCount[parentPageId][title]})`;
titleCount[parentPageId][getPageTitle(page.title)] += 1;
} else {
titleCount[parentPageId][title] = 1;
}
page.title = title;
if (!tree[parentPageId]) {
tree[parentPageId] = [];
}
tree[parentPageId].push(page);
}
return tree;
}
export function computeLocalPath(
tree: PageExportTree,
format: string,
parentPageId: string | null,
currentPath: string,
slugIdToPath: Record<string, string>,
) {
const children = tree[parentPageId] || [];
for (const page of children) {
const title = encodeURIComponent(getPageTitle(page.title));
const localPath = `${currentPath}${title}`;
slugIdToPath[page.slugId] = `${localPath}${getExportExtension(format)}`;
computeLocalPath(tree, format, page.id, `${localPath}/`, slugIdToPath);
}
}
function computeRelativePath(from: string, to: string) {
return path.relative(path.dirname(from), to);
}
@@ -4,7 +4,7 @@ import { MultipartFile } from '@fastify/multipart';
import { sanitize } from 'sanitize-filename-ts'; import { sanitize } from 'sanitize-filename-ts';
import * as path from 'path'; import * as path from 'path';
import { import {
htmlToJson, htmlToJson, jsonToText,
tiptapExtensions, tiptapExtensions,
} from '../../collaboration/collaboration.util'; } from '../../collaboration/collaboration.util';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
@@ -72,6 +72,7 @@ export class ImportService {
slugId: generateSlugId(), slugId: generateSlugId(),
title: pageTitle, title: pageTitle,
content: prosemirrorJson, content: prosemirrorJson,
textContent: jsonToText(prosemirrorJson),
ydoc: await this.createYdoc(prosemirrorJson), ydoc: await this.createYdoc(prosemirrorJson),
position: pagePosition, position: pagePosition,
spaceId: spaceId, spaceId: spaceId,
@@ -1,5 +1,7 @@
import { marked } from 'marked'; import { marked } from 'marked';
import { calloutExtension } from './callout.marked'; import { calloutExtension } from './callout.marked';
import { mathBlockExtension } from './math-block.marked';
import { mathInlineExtension } from "./math-inline.marked";
marked.use({ marked.use({
renderer: { renderer: {
@@ -26,7 +28,7 @@ marked.use({
}, },
}); });
marked.use({ extensions: [calloutExtension] }); marked.use({ extensions: [calloutExtension, mathBlockExtension, mathInlineExtension] });
export async function markdownToHtml(markdownInput: string): Promise<string> { export async function markdownToHtml(markdownInput: string): Promise<string> {
const YAML_FONT_MATTER_REGEX = /^\s*---[\s\S]*?---\s*/; const YAML_FONT_MATTER_REGEX = /^\s*---[\s\S]*?---\s*/;
@@ -0,0 +1,37 @@
import { Token, marked } from 'marked';
interface MathBlockToken {
type: 'mathBlock';
text: string;
raw: string;
}
export const mathBlockExtension = {
name: 'mathBlock',
level: 'block',
start(src: string) {
return src.match(/\$\$/)?.index ?? -1;
},
tokenizer(src: string): MathBlockToken | undefined {
const rule = /^\$\$(?!(\$))([\s\S]+?)\$\$/;
const match = rule.exec(src);
if (match) {
return {
type: 'mathBlock',
raw: match[0],
text: match[2]?.trim(),
};
}
},
renderer(token: Token) {
const mathBlockToken = token as MathBlockToken;
// parse to prevent escaping slashes
const latex = marked
.parse(mathBlockToken.text)
.toString()
.replace(/<(\/)?p>/g, '');
return `<div data-type="${mathBlockToken.type}" data-katex="true">${latex}</div>`;
},
};
@@ -0,0 +1,55 @@
import { Token, marked } from 'marked';
interface MathInlineToken {
type: 'mathInline';
text: string;
raw: string;
}
const inlineMathRegex = /^\$(?!\s)(.+?)(?<!\s)\$(?!\d)/;
export const mathInlineExtension = {
name: 'mathInline',
level: 'inline',
start(src: string) {
let index: number;
let indexSrc = src;
while (indexSrc) {
index = indexSrc.indexOf('$');
if (index === -1) {
return;
}
const f = index === 0 || indexSrc.charAt(index - 1) === ' ';
if (f) {
const possibleKatex = indexSrc.substring(index);
if (possibleKatex.match(inlineMathRegex)) {
return index;
}
}
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, '');
}
},
tokenizer(src: string): MathInlineToken | undefined {
const match = inlineMathRegex.exec(src);
if (match) {
return {
type: 'mathInline',
raw: match[0],
text: match[1]?.trim(),
};
}
},
renderer(token: Token) {
const mathInlineToken = token as MathInlineToken;
// parse to prevent escaping slashes
const latex = marked
.parse(mathInlineToken.text)
.toString()
.replace(/<(\/)?p>/g, '');
return `<span data-type="${mathInlineToken.type}" data-katex="true">${latex}</span>`;
},
};
@@ -25,7 +25,7 @@ export const mailDriverConfigProvider = {
const driver = environmentService.getMailDriver().toLocaleLowerCase(); const driver = environmentService.getMailDriver().toLocaleLowerCase();
switch (driver) { switch (driver) {
case MailOption.SMTP: case MailOption.SMTP: {
let auth = undefined; let auth = undefined;
if ( if (
environmentService.getSmtpUsername() && environmentService.getSmtpUsername() &&
@@ -44,9 +44,10 @@ export const mailDriverConfigProvider = {
connectionTimeout: 30 * 1000, // 30 seconds connectionTimeout: 30 * 1000, // 30 seconds
auth, auth,
secure: environmentService.getSmtpSecure(), secure: environmentService.getSmtpSecure(),
ignoreTLS: environmentService.getSmtpIgnoreTLS() ignoreTLS: environmentService.getSmtpIgnoreTLS(),
} as SMTPTransport.Options, } as SMTPTransport.Options,
}; };
}
case MailOption.Postmark: case MailOption.Postmark:
return { return {
@@ -15,6 +15,7 @@ import { QueueName } from './constants';
host: redisConfig.host, host: redisConfig.host,
port: redisConfig.port, port: redisConfig.port,
password: redisConfig.password, password: redisConfig.password,
db: redisConfig.db,
retryStrategy: createRetryStrategy(), retryStrategy: createRetryStrategy(),
}, },
defaultJobOptions: { defaultJobOptions: {
@@ -33,7 +33,8 @@ export class StaticModule implements OnModuleInit {
ENV: this.environmentService.getNodeEnv(), ENV: this.environmentService.getNodeEnv(),
APP_URL: this.environmentService.getAppUrl(), APP_URL: this.environmentService.getAppUrl(),
IS_CLOUD: this.environmentService.isCloud(), IS_CLOUD: this.environmentService.isCloud(),
FILE_UPLOAD_SIZE_LIMIT: this.environmentService.getFileUploadSizeLimit() FILE_UPLOAD_SIZE_LIMIT: this.environmentService.getFileUploadSizeLimit(),
DRAWIO_URL: this.environmentService.getDrawioUrl()
}; };
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`; const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
@@ -41,7 +41,7 @@ export const storageDriverConfigProvider = {
}; };
case StorageOption.S3: case StorageOption.S3:
const s3Config = { { const s3Config = {
driver, driver,
config: { config: {
region: environmentService.getAwsS3Region(), region: environmentService.getAwsS3Region(),
@@ -68,7 +68,7 @@ export const storageDriverConfigProvider = {
}; };
} }
return s3Config; return s3Config; }
default: default:
throw new Error(`Unknown storage driver: ${driver}`); throw new Error(`Unknown storage driver: ${driver}`);
+35 -8
View File
@@ -9,6 +9,7 @@ import { Server, Socket } from 'socket.io';
import { TokenService } from '../core/auth/services/token.service'; import { TokenService } from '../core/auth/services/token.service';
import { JwtType } from '../core/auth/dto/jwt-payload'; import { JwtType } from '../core/auth/dto/jwt-payload';
import { OnModuleDestroy } from '@nestjs/common'; import { OnModuleDestroy } from '@nestjs/common';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@WebSocketGateway({ @WebSocketGateway({
cors: { origin: '*' }, cors: { origin: '*' },
@@ -17,7 +18,10 @@ import { OnModuleDestroy } from '@nestjs/common';
export class WsGateway implements OnGatewayConnection, OnModuleDestroy { export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
@WebSocketServer() @WebSocketServer()
server: Server; server: Server;
constructor(private tokenService: TokenService) {} constructor(
private tokenService: TokenService,
private spaceMemberRepo: SpaceMemberRepo,
) {}
async handleConnection(client: Socket, ...args: any[]): Promise<void> { async handleConnection(client: Socket, ...args: any[]): Promise<void> {
try { try {
@@ -27,24 +31,43 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
if (token.type !== JwtType.ACCESS) { if (token.type !== JwtType.ACCESS) {
client.disconnect(); client.disconnect();
} }
const userId = token.sub;
const workspaceId = token.workspaceId;
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const workspaceRoom = `workspace-${workspaceId}`;
const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
client.join([workspaceRoom, ...spaceRooms]);
} catch (err) { } catch (err) {
client.disconnect(); client.disconnect();
} }
} }
@SubscribeMessage('message') @SubscribeMessage('message')
handleMessage(client: Socket, data: string): void { handleMessage(client: Socket, data: any): void {
client.broadcast.emit('message', data); const spaceEvents = [
} 'updateOne',
'addTreeNode',
'moveTreeNode',
'deleteTreeNode',
];
@SubscribeMessage('messageToRoom') if (spaceEvents.includes(data?.operation) && data?.spaceId) {
handleSendMessageToRoom(@MessageBody() message: any) { const room = this.getSpaceRoomName(data.spaceId);
this.server.to(message?.roomId).emit('messageToRoom', message); client.broadcast.to(room).emit('message', data);
return;
}
client.broadcast.emit('message', data);
} }
@SubscribeMessage('join-room') @SubscribeMessage('join-room')
handleJoinRoom(client: Socket, @MessageBody() roomName: string): void { handleJoinRoom(client: Socket, @MessageBody() roomName: string): void {
client.join(roomName); // if room is a space, check if user has permissions
//client.join(roomName);
} }
@SubscribeMessage('leave-room') @SubscribeMessage('leave-room')
@@ -57,4 +80,8 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
this.server.close(); this.server.close();
} }
} }
getSpaceRoomName(spaceId: string): string {
return `space-${spaceId}`;
}
} }
+48 -46
View File
@@ -1,7 +1,7 @@
{ {
"name": "docmost", "name": "docmost",
"homepage": "https://docmost.com", "homepage": "https://docmost.com",
"version": "0.5.0", "version": "0.6.2",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nx run-many -t build", "build": "nx run-many -t build",
@@ -17,63 +17,65 @@
}, },
"dependencies": { "dependencies": {
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@hocuspocus/extension-redis": "^2.13.5", "@hocuspocus/extension-redis": "^2.14.0",
"@hocuspocus/provider": "^2.13.5", "@hocuspocus/provider": "^2.14.0",
"@hocuspocus/server": "^2.13.5", "@hocuspocus/server": "^2.14.0",
"@hocuspocus/transformer": "^2.13.5", "@hocuspocus/transformer": "^2.14.0",
"@joplin/turndown": "^4.0.74", "@joplin/turndown": "^4.0.74",
"@joplin/turndown-plugin-gfm": "^1.0.56", "@joplin/turndown-plugin-gfm": "^1.0.56",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"@tiptap/core": "^2.6.6", "@tiptap/core": "^2.10.3",
"@tiptap/extension-code-block": "^2.6.6", "@tiptap/extension-code-block": "^2.10.3",
"@tiptap/extension-code-block-lowlight": "^2.6.6", "@tiptap/extension-code-block-lowlight": "^2.10.3",
"@tiptap/extension-collaboration": "^2.6.6", "@tiptap/extension-collaboration": "^2.10.3",
"@tiptap/extension-collaboration-cursor": "^2.6.6", "@tiptap/extension-collaboration-cursor": "^2.10.3",
"@tiptap/extension-color": "^2.6.6", "@tiptap/extension-color": "^2.10.3",
"@tiptap/extension-document": "^2.6.6", "@tiptap/extension-document": "^2.10.3",
"@tiptap/extension-heading": "^2.6.6", "@tiptap/extension-heading": "^2.10.3",
"@tiptap/extension-highlight": "^2.6.6", "@tiptap/extension-highlight": "^2.10.3",
"@tiptap/extension-history": "^2.6.6", "@tiptap/extension-history": "^2.10.3",
"@tiptap/extension-image": "^2.6.6", "@tiptap/extension-image": "^2.10.3",
"@tiptap/extension-link": "^2.6.6", "@tiptap/extension-link": "^2.10.3",
"@tiptap/extension-list-item": "^2.6.6", "@tiptap/extension-list-item": "^2.10.3",
"@tiptap/extension-list-keymap": "^2.6.6", "@tiptap/extension-list-keymap": "^2.10.3",
"@tiptap/extension-mention": "^2.6.6", "@tiptap/extension-mention": "^2.10.3",
"@tiptap/extension-placeholder": "^2.6.6", "@tiptap/extension-placeholder": "^2.10.3",
"@tiptap/extension-subscript": "^2.6.6", "@tiptap/extension-subscript": "^2.10.3",
"@tiptap/extension-superscript": "^2.6.6", "@tiptap/extension-superscript": "^2.10.3",
"@tiptap/extension-table": "^2.6.6", "@tiptap/extension-table": "^2.10.3",
"@tiptap/extension-table-cell": "^2.6.6", "@tiptap/extension-table-cell": "^2.10.3",
"@tiptap/extension-table-header": "^2.6.6", "@tiptap/extension-table-header": "^2.10.3",
"@tiptap/extension-table-row": "^2.6.6", "@tiptap/extension-table-row": "^2.10.3",
"@tiptap/extension-task-item": "^2.6.6", "@tiptap/extension-task-item": "^2.10.3",
"@tiptap/extension-task-list": "^2.6.6", "@tiptap/extension-task-list": "^2.10.3",
"@tiptap/extension-text": "^2.6.6", "@tiptap/extension-text": "^2.10.3",
"@tiptap/extension-text-align": "^2.6.6", "@tiptap/extension-text-align": "^2.10.3",
"@tiptap/extension-text-style": "^2.6.6", "@tiptap/extension-text-style": "^2.10.3",
"@tiptap/extension-typography": "^2.6.6", "@tiptap/extension-typography": "^2.10.3",
"@tiptap/extension-underline": "^2.6.6", "@tiptap/extension-underline": "^2.10.3",
"@tiptap/extension-youtube": "^2.6.6", "@tiptap/extension-youtube": "^2.10.3",
"@tiptap/html": "^2.6.6", "@tiptap/html": "^2.10.3",
"@tiptap/pm": "^2.6.6", "@tiptap/pm": "^2.10.3",
"@tiptap/react": "^2.6.6", "@tiptap/react": "^2.10.3",
"@tiptap/starter-kit": "^2.6.6", "@tiptap/starter-kit": "^2.10.3",
"@tiptap/suggestion": "^2.6.6", "@tiptap/suggestion": "^2.10.3",
"bytes": "^3.1.2", "bytes": "^3.1.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dompurify": "^3.2.1",
"fractional-indexing-jittered": "^0.9.1", "fractional-indexing-jittered": "^0.9.1",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"uuid": "^10.0.0", "jszip": "^3.10.1",
"uuid": "^11.0.3",
"y-indexeddb": "^9.0.12", "y-indexeddb": "^9.0.12",
"yjs": "^13.6.18" "yjs": "^13.6.20"
}, },
"devDependencies": { "devDependencies": {
"@nx/js": "19.6.3", "@nx/js": "20.1.3",
"@types/bytes": "^3.1.4", "@types/bytes": "^3.1.4",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"concurrently": "^8.2.2", "concurrently": "^9.1.0",
"nx": "19.6.3", "nx": "20.1.3",
"tsx": "^4.19.0" "tsx": "^4.19.2"
}, },
"workspaces": { "workspaces": {
"packages": [ "packages": [
@@ -7,6 +7,8 @@ export interface CustomCodeBlockOptions extends CodeBlockLowlightOptions {
view: any; view: any;
} }
const TAB_CHAR = "\u00A0\u00A0";
export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>( export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
{ {
selectable: true, selectable: true,
@@ -18,8 +20,26 @@ export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
}; };
}, },
addKeyboardShortcuts() {
return {
...this.parent?.(),
Tab: () => {
if (this.editor.isActive("codeBlock")) {
this.editor
.chain()
.command(({ tr }) => {
tr.insertText(TAB_CHAR);
return true;
})
.run();
return true;
}
},
};
},
addNodeView() { addNodeView() {
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
}, }
); );
+19
View File
@@ -1,3 +1,4 @@
import { mergeAttributes } from "@tiptap/core";
import TiptapLink from "@tiptap/extension-link"; import TiptapLink from "@tiptap/extension-link";
import { Plugin } from "@tiptap/pm/state"; import { Plugin } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view"; import { EditorView } from "@tiptap/pm/view";
@@ -5,6 +6,24 @@ import { EditorView } from "@tiptap/pm/view";
export const LinkExtension = TiptapLink.extend({ export const LinkExtension = TiptapLink.extend({
inclusive: false, inclusive: false,
parseHTML() {
return [
{
tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])',
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"a",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: "link",
}),
0,
];
},
addProseMirrorPlugins() { addProseMirrorPlugins() {
const { editor } = this; const { editor } = this;
+4405 -2827
View File
File diff suppressed because it is too large Load Diff