Compare commits

..

18 Commits

Author SHA1 Message Date
Philipinho 55537cd090 fix 2026-05-19 02:40:58 +01:00
Philipinho cc691d1138 fix tree 2026-05-19 02:37:14 +01:00
Philipinho 1997610cb8 Merge branch 'main' into feat/templates 2026-05-19 02:04:30 +01:00
Philipinho bf9ea63247 feat(ee): templates 2026-05-19 02:03:27 +01:00
Philipinho 0d6538ab1a feat: iframe configuration 2026-05-18 22:02:31 +01:00
Philip Okugbe b7b99cb3b2 fix: code splitting and editor fixes (#2211)
* fix table

* fix code splitting

* fix: editor ready check

* fix codeblock/mermaid gap cursor

* fix callout
2026-05-15 02:46:54 +01:00
Philipinho 03c1e8c4ed fix collab module 2026-05-14 15:06:51 +01:00
Philipinho e41518a93d fix type 2026-05-14 14:49:02 +01:00
Peter Tripp 932c1ad5b7 Better trash (#2190)
* Better trash

I recently lost a bunch of time editing and searching for pages that were actually in the Trash. Docmost intentionally tries to not link to Trashed pages, but the url of that Trashed page and any inbound links still work.  This makes it clearer when a page you are interacting with is in the Trash.

- /trash
  - Refactored banner into `trash-banner.tsx`
  - Refactored "Restore" modal into `use-restore-page-modal.tsx`
- Page (when isDeleted)
  - Add: `trash-banner.tsx`
  - Add breadcrumbs: `Parent / Child / Page (Deleted)`
  - Change: Deleted Pages are read-only
  - Replace "Move to Trash" with "Restore" in page menu (invokes `use-restore-page-modal`)

I tried very hard to keep this simple and re-use existing translation strings wherever possible.

* cleanup

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-05-14 14:41:10 +01:00
Julien Fontanet 82d065669d fix: page mode toggle no longer overwrites default preference (#1996)
The header edit/read toggle now controls only the current session's mode
without saving it as the user's preference. The saved preference (set in
profile settings) is applied once on initial load and sticks across page
navigations within the session, so navigating to a new page no longer
resets the mode mid-session.

Fixes #1693
2026-05-14 13:15:03 +01:00
Philip Okugbe f758091b2a perf(permissions): cache space role and page edit lookups (#2208) 2026-05-14 13:11:28 +01:00
Philip Okugbe f4af4c3fc0 feat(editor): add page break node (#2202) 2026-05-14 03:48:13 +01:00
Philipinho 3b983a27f6 sync 2026-05-14 03:01:55 +01:00
Philip Okugbe 299a9ca3c8 fix: bug fixes (#2201)
* fix(editor): hide transclusion borders and reset spacing in read-only mode

* feat(share): add full width toggle for shared pages

* feat(share): support resizing sidebar on shared pages

* fix: auto redirect if there is only one SSO provider.
- fix tighten sso redirect
- fix share tree margin

* sync

* package overrides
2026-05-14 02:54:00 +01:00
Philip Okugbe cea9be7926 feat: table enhancement (#2191) 2026-05-14 00:37:44 +01:00
Philip Okugbe 31ed0df3f7 feat(tree): replace sidebar tree (react-aborist) with custom tree implementation (#2199)
* feat(tree): replace react-arborist with custom tree implementation

* feat(tree): keyboard arrow navigation between rows

* feat(emoji-picker): focus search input on open

* refactor(emoji): switch to @slidoapp/emoji-mart fork for accessibility

* feat(tree): Home/End and typeahead keyboard navigation

* feat(tree): roving tabindex and * to expand sibling subtrees

* feat(tree): Space activation and ARIA refinements

* fix(tree): move treeitem role to focusable row + aria-current
2026-05-13 23:01:04 +01:00
Philip Okugbe a689cca7a0 feat: page labels/tags (#2188)
* feat: labels (WIP)
* full implementation
2026-05-10 18:14:15 +01:00
Philip Okugbe 537e45bc11 feat: page details section and backlinks (#2186)
* feat: page details section and backlinks
2026-05-09 17:03:08 +01:00
188 changed files with 11824 additions and 3721 deletions
+7
View File
@@ -48,6 +48,13 @@ GOTENBERG_URL=
DISABLE_TELEMETRY=false
# Allow other sites to embed Docmost in an iframe.
IFRAME_EMBED_ALLOWED=false
# Only used when IFRAME_EMBED_ALLOWED=true. When empty, any origin is allowed.
# Example: https://intranet.example.com,https://portal.example.com
IFRAME_ALLOWED_ORIGINS=
# Enable debug logging in production (default: false)
DEBUG_MODE=false
+68 -57
View File
@@ -7,78 +7,89 @@
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@casl/react": "^5.0.1",
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
"@casl/react": "5.0.1",
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "^8.3.18",
"@mantine/dates": "^8.3.18",
"@mantine/form": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/modals": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@mantine/spotlight": "^8.3.18",
"@tabler/icons-react": "^3.40.0",
"@mantine/core": "8.3.18",
"@mantine/dates": "8.3.18",
"@mantine/form": "8.3.18",
"@mantine/hooks": "8.3.18",
"@mantine/modals": "8.3.18",
"@mantine/notifications": "8.3.18",
"@mantine/spotlight": "8.3.18",
"@slidoapp/emoji-mart": "5.8.7",
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-query": "5.90.17",
"alfaaz": "^1.1.0",
"@tanstack/react-virtual": "3.13.24",
"alfaaz": "1.1.0",
"axios": "1.16.0",
"blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0",
"blueimp-load-image": "5.16.0",
"clsx": "2.1.1",
"file-saver": "2.0.5",
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"jotai": "^2.18.1",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"jotai": "2.18.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.5",
"jwt-decode": "4.0.0",
"katex": "0.16.40",
"lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.13.0",
"mitt": "^3.0.1",
"lowlight": "3.3.0",
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0",
"mitt": "3.0.1",
"posthog-js": "1.372.2",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react": "18.3.1",
"react-clear-modal": "^2.0.18",
"react-dom": "^18.3.1",
"react-drawio": "^1.0.7",
"react-error-boundary": "^6.1.1",
"react-helmet-async": "^3.0.0",
"react-drawio": "1.0.7",
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-i18next": "16.5.8",
"react-router-dom": "^7.13.1",
"semver": "^7.7.4",
"socket.io-client": "^4.8.3",
"zod": "^4.3.6"
"react-router-dom": "7.13.1",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"zod": "4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.28.0",
"@tanstack/eslint-plugin-query": "^5.94.4",
"@types/blueimp-load-image": "^5.16.6",
"@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.8",
"@eslint/js": "9.28.0",
"@tanstack/eslint-plugin-query": "5.94.4",
"@testing-library/jest-dom": "6.6.0",
"@testing-library/react": "16.1.0",
"@types/blueimp-load-image": "5.16.6",
"@types/file-saver": "2.0.7",
"@types/js-cookie": "3.0.6",
"@types/katex": "0.16.8",
"@types/node": "22.19.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.28.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^15.13.0",
"optics-ts": "^2.4.1",
"postcss": "^8.5.12",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vite": "8.0.5"
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.1",
"eslint": "9.28.0",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "0.5.2",
"globals": "15.13.0",
"jsdom": "25.0.0",
"optics-ts": "2.4.1",
"postcss": "8.5.14",
"postcss-preset-mantine": "1.18.0",
"postcss-simple-vars": "7.0.1",
"prettier": "3.8.1",
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vitest": "4.1.6"
}
}
@@ -71,6 +71,7 @@
"Export": "Export",
"Failed to create page": "Failed to create page",
"Failed to delete page": "Failed to delete page",
"Failed to restore page": "Failed to restore page",
"Failed to fetch recent pages": "Failed to fetch recent pages",
"Failed to import pages": "Failed to import pages",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
@@ -286,6 +287,19 @@
"Add row above": "Add row above",
"Add row below": "Add row below",
"Delete table": "Delete table",
"Add column left": "Add column left",
"Add column right": "Add column right",
"Clear cell": "Clear cell",
"Clear cells": "Clear cells",
"Toggle header cell": "Toggle header cell",
"Toggle header column": "Toggle header column",
"Toggle header row": "Toggle header row",
"Move column left": "Move column left",
"Move column right": "Move column right",
"Move row down": "Move row down",
"Move row up": "Move row up",
"Sort A → Z": "Sort A → Z",
"Sort Z → A": "Sort Z → A",
"Info": "Info",
"Note": "Note",
"Success": "Success",
@@ -348,6 +362,8 @@
"Create block quote.": "Create block quote.",
"Insert code snippet.": "Insert code snippet.",
"Insert horizontal rule divider": "Insert horizontal rule divider",
"Page break": "Page break",
"Insert a page break for printing.": "Insert a page break for printing.",
"Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.",
@@ -566,6 +582,8 @@
"Move to trash": "Move to trash",
"Move this page to trash?": "Move this page to trash?",
"Restore page": "Restore page",
"Permanently delete": "Permanently delete",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> moved this page to Trash {{time}}.",
"Page moved to trash": "Page moved to trash",
"Page restored successfully": "Page restored successfully",
"Deleted by": "Deleted by",
@@ -918,6 +936,35 @@
"Page actions": "Page actions",
"Pick emoji": "Pick emoji",
"Template menu": "Template menu",
"Use": "Use",
"Use template": "Use template",
"Preview template: {{title}}": "Preview template: {{title}}",
"Use a template": "Use a template",
"Search templates...": "Search templates...",
"Search spaces...": "Search spaces...",
"No templates found": "No templates found",
"No spaces found": "No spaces found",
"Browse all templates": "Browse all templates",
"This space": "This space",
"All templates": "All templates",
"Global": "Global",
"New template": "New template",
"Edit template": "Edit template",
"Are you sure you want to delete this template?": "Are you sure you want to delete this template?",
"Template scope updated": "Template scope updated",
"Choose which space this template belongs to": "Choose which space this template belongs to",
"Scope": "Scope",
"Select scope": "Select scope",
"Title": "Title",
"Saving...": "Saving...",
"Saved": "Saved",
"Save failed. Retry": "Save failed. Retry",
"By {{name}}": "By {{name}}",
"Updated {{time}}": "Updated {{time}}",
"Choose destination": "Choose destination",
"Search pages and spaces...": "Search pages and spaces...",
"No results found": "No results found",
"You don't have permission to create pages here": "You don't have permission to create pages here",
"Chat menu": "Chat menu",
"API key menu": "API key menu",
"Jump to comment selection": "Jump to comment selection",
@@ -983,5 +1030,22 @@
"Outgoing links ({{count}})": "Outgoing links ({{count}})",
"No pages link here yet.": "No pages link here yet.",
"This page doesn't link to other pages yet.": "This page doesn't link to other pages yet.",
"Verified until {{date}}": "Verified until {{date}}"
"Verified until {{date}}": "Verified until {{date}}",
"Labels": "Labels",
"Add label": "Add label",
"No labels yet": "No labels yet",
"Already added": "Already added",
"Invalid label name": "Invalid label name",
"No matches": "No matches",
"Search or create…": "Search or create…",
"Remove label {{name}}": "Remove label {{name}}",
"Failed to add label": "Failed to add label",
"Failed to remove label": "Failed to remove label",
"No pages with this label": "No pages with this label",
"Pages tagged with this label will appear here.": "Pages tagged with this label will appear here.",
"No pages match your search.": "No pages match your search.",
"Updated {{date}}": "Updated {{date}}",
"Cell actions": "Cell actions",
"Column actions": "Column actions",
"Row actions": "Row actions"
}
+2
View File
@@ -45,6 +45,7 @@ import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
import LabelPage from "@/pages/label/label-page";
export default function App() {
const { t } = useTranslation();
@@ -92,6 +93,7 @@ export default function App() {
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
<Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/favorites"} element={<FavoritesPage />} />
<Route path={"/labels/:labelName"} element={<LabelPage />} />
<Route path={"/templates"} element={<TemplateList />} />
<Route
path={"/templates/:templateId"}
@@ -1,4 +1,5 @@
import { Box, ScrollArea, Text } from "@mantine/core";
import { ActionIcon, Box, Group, ScrollArea, Text, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
@@ -11,9 +12,10 @@ import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
const [{ tab }, setAsideState] = useAtom(asideStateAtom);
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
let title: string;
let component: ReactNode;
@@ -45,9 +47,19 @@ export default function Aside() {
{component && (
<>
{tab !== "chat" && (
<Text mb="md" fw={500}>
{t(title)}
</Text>
<Group justify="space-between" wrap="nowrap" mb="md">
<Text fw={500}>{t(title)}</Text>
<Tooltip label={t("Close")} withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={closeAside}
aria-label={t("Close")}
>
<IconX size={18} />
</ActionIcon>
</Tooltip>
</Group>
)}
{tab === "comments" || tab === "chat" ? (
@@ -38,6 +38,16 @@
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
}
}
&[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
@mixin hover {
background-color: transparent;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
}
}
.linkIcon {
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { ScrollArea, Text, Divider, Modal, UnstyledButton } from "@mantine/core";
import { ScrollArea, Text, Divider, Modal, UnstyledButton, Tooltip } from "@mantine/core";
import {
IconHome,
IconClock,
@@ -7,6 +7,7 @@ import {
IconLayoutGrid,
IconSettings,
IconUserPlus,
IconTemplate,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./global-sidebar.module.css";
@@ -20,12 +21,9 @@ import { useDisclosure } from "@mantine/hooks";
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
const mainNavItems = [
{ label: "Home", icon: IconHome, path: "/home" },
{ label: "Favorites", icon: IconStar, path: "/favorites" },
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
];
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function GlobalSidebar() {
const { t } = useTranslation();
@@ -33,6 +31,19 @@ export default function GlobalSidebar() {
const [active, setActive] = useState(location.pathname);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const hasTemplates = useHasFeature(Feature.TEMPLATES);
const upgradeLabel = useUpgradeLabel();
const mainNavItems = [
{ label: "Home", icon: IconHome, path: "/home" },
{ label: "Favorites", icon: IconStar, path: "/favorites" },
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
{
label: "Templates",
icon: IconTemplate,
path: "/templates",
disabled: !hasTemplates,
},
];
const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
const sortedFavoriteSpaces = [...favoriteSpaces]
@@ -58,18 +69,37 @@ export default function GlobalSidebar() {
<div className={classes.navbar}>
<ScrollArea w="100%" style={{ flex: 1 }}>
<div className={classes.section}>
{mainNavItems.map((item) => (
<Link
key={item.label}
className={classes.link}
data-active={active === item.path || undefined}
to={item.path}
onClick={handleNavClick}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
))}
{mainNavItems.map((item) =>
item.disabled ? (
<Tooltip
key={item.label}
label={upgradeLabel}
position="right"
withArrow
>
<UnstyledButton
className={classes.link}
data-disabled
aria-disabled="true"
tabIndex={-1}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</UnstyledButton>
</Tooltip>
) : (
<Link
key={item.label}
className={classes.link}
data-active={active === item.path || undefined}
to={item.path}
onClick={handleNavClick}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
),
)}
</div>
<Divider my="xs" />
@@ -16,6 +16,8 @@ export function DestinationPickerModal({
loading,
excludePageId,
pageLimit,
initialSpaceId,
searchSpacesOnly,
}: DestinationPickerModalProps) {
const { t } = useTranslation();
const [selection, setSelection] = useState<DestinationSelection | null>(null);
@@ -46,6 +48,8 @@ export function DestinationPickerModal({
onSelectionChange={setSelection}
excludePageId={excludePageId}
pageLimit={pageLimit}
initialSpaceId={initialSpaceId}
searchSpacesOnly={searchSpacesOnly}
/>
<Group justify="flex-end" mt="md">
@@ -13,6 +13,7 @@
display: flex;
align-items: center;
gap: 8px;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
transition: background-color 150ms ease;
user-select: none;
@@ -22,6 +23,11 @@
var(--mantine-color-dark-6)
);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
}
.selected {
@@ -57,7 +63,7 @@
border-radius: var(--mantine-radius-sm);
flex-shrink: 0;
transition: transform 150ms ease;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
@mixin hover {
background-color: light-dark(
@@ -111,7 +117,7 @@
}
.spaceName {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-xs);
flex-shrink: 0;
}
@@ -1,7 +1,7 @@
import { useState, useCallback } from "react";
import { TextInput, ScrollArea, Loader } from "@mantine/core";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { ActionIcon, TextInput, ScrollArea, Loader } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconSearch, IconFile } from "@tabler/icons-react";
import { IconSearch, IconFileDescription } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
@@ -15,23 +15,29 @@ type DestinationPickerProps = {
onSelectionChange: (selection: DestinationSelection | null) => void;
excludePageId?: string;
pageLimit?: number;
initialSpaceId?: string;
searchSpacesOnly?: boolean;
};
export function DestinationPicker({
onSelectionChange,
excludePageId,
pageLimit = 15,
initialSpaceId,
searchSpacesOnly,
}: DestinationPickerProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [selection, setSelection] = useState<DestinationSelection | null>(null);
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const viewportRef = useRef<HTMLDivElement>(null);
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
limit: 100,
});
const searchEnabled = debouncedQuery && debouncedQuery.length >= 2;
const searchEnabled =
!searchSpacesOnly && debouncedQuery && debouncedQuery.length >= 2;
const { data: searchData, isLoading: searchLoading } =
useSearchSuggestionsQuery({
@@ -42,6 +48,18 @@ export function DestinationPicker({
const isSearching = !!searchEnabled;
const filteredSpaces = useMemo(() => {
const items = spacesData?.items ?? [];
if (!searchSpacesOnly || !debouncedQuery) return items;
const fold = (s: string) =>
s
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLocaleLowerCase();
const term = fold(debouncedQuery);
return items.filter((s) => fold(s.name).includes(term));
}, [spacesData, searchSpacesOnly, debouncedQuery]);
const selectedId =
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
@@ -87,18 +105,48 @@ export function DestinationPicker({
[updateSelection],
);
// Pre-select space when initialSpaceId is set and spaces have loaded.
// Only runs once: skip if user has already made a selection.
useEffect(() => {
if (!initialSpaceId || selection) return;
const match = spacesData?.items?.find((s) => s.id === initialSpaceId);
if (match) {
updateSelection({ type: "space", spaceId: match.id, space: match });
requestAnimationFrame(() => {
const el = viewportRef.current?.querySelector<HTMLElement>(
`[data-space-id="${match.id}"]`,
);
el?.scrollIntoView({ block: "nearest" });
});
}
}, [initialSpaceId, selection, spacesData, updateSelection]);
return (
<>
<TextInput
leftSection={<IconSearch size={16} />}
placeholder={t("Search pages and spaces...")}
placeholder={
searchSpacesOnly
? t("Search spaces...")
: t("Search pages and spaces...")
}
aria-label={
searchSpacesOnly
? t("Search spaces...")
: t("Search pages and spaces...")
}
variant="filled"
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
className={classes.searchInput}
/>
<ScrollArea h="50vh" offsetScrollbars className={classes.scrollArea}>
<ScrollArea
h="50vh"
offsetScrollbars
className={classes.scrollArea}
viewportRef={viewportRef}
>
{isSearching ? (
searchLoading ? (
<div className={classes.emptyState}>
@@ -111,16 +159,28 @@ export function DestinationPicker({
<div
key={page.id}
className={classes.searchResult}
role="button"
tabIndex={0}
onClick={() => handleSearchResultClick(page)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSearchResultClick(page);
}
}}
>
<div className={classes.iconWrapper}>
{page.icon ? (
page.icon
) : (
<IconFile
size={16}
color="var(--mantine-color-gray-5)"
/>
<ActionIcon
component="div"
variant="transparent"
c="gray"
size={22}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
</div>
<div className={classes.pageTitle}>
@@ -141,8 +201,14 @@ export function DestinationPicker({
<div className={classes.emptyState}>
<Loader size="xs" />
</div>
) : filteredSpaces.length === 0 ? (
<div className={classes.emptyState}>
{searchSpacesOnly && debouncedQuery
? t("No spaces found")
: t("No results found")}
</div>
) : (
spacesData?.items?.map((space) => (
filteredSpaces.map((space) => (
<SpaceRow
key={space.id}
space={space}
@@ -20,4 +20,6 @@ export type DestinationPickerModalProps = {
loading?: boolean;
excludePageId?: string;
pageLimit?: number;
initialSpaceId?: string;
searchSpacesOnly?: boolean;
};
@@ -1,5 +1,6 @@
import { useState } from "react";
import { IconChevronRight, IconFile } from "@tabler/icons-react";
import { KeyboardEvent, useState } from "react";
import { ActionIcon } from "@mantine/core";
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IPage } from "@/features/page/types/page.types";
import { PageChildren } from "./page-children";
@@ -36,23 +37,44 @@ export function PageRow({
.filter(Boolean)
.join(" ");
const handleSelect = () => {
if (!isExcluded) onSelect(page);
};
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect();
}
};
return (
<>
<div
className={rowClasses}
style={{ paddingLeft: depth * 20 + 12 }}
onClick={() => !isExcluded && onSelect(page)}
role="button"
tabIndex={isExcluded ? -1 : 0}
aria-disabled={isExcluded || undefined}
onClick={handleSelect}
onKeyDown={handleRowKeyDown}
>
{page.hasChildren ? (
<div
<ActionIcon
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
variant="subtle"
color="gray"
size="sm"
aria-label={expanded ? t("Collapse") : t("Expand")}
aria-expanded={expanded}
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
<IconChevronRight size={14} />
</div>
</ActionIcon>
) : (
<div style={{ width: 20, flexShrink: 0 }} />
)}
@@ -61,10 +83,14 @@ export function PageRow({
{page.icon ? (
page.icon
) : (
<IconFile
size={16}
color="var(--mantine-color-gray-5)"
/>
<ActionIcon
component="div"
variant="transparent"
c="gray"
size={22}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
</div>
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Tooltip } from "@mantine/core";
import { KeyboardEvent, useState } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconChevronRight, IconLock } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types";
@@ -42,21 +42,43 @@ export function SpaceRow({
.filter(Boolean)
.join(" ");
const handleSelect = () => {
if (writable) onSelectSpace(space);
};
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect();
}
};
const rowContent = (
<div
className={rowClasses}
onClick={() => writable && onSelectSpace(space)}
data-space-id={space.id}
role="button"
tabIndex={writable ? 0 : -1}
aria-disabled={!writable || undefined}
onClick={handleSelect}
onKeyDown={handleRowKeyDown}
>
{writable ? (
<div
<ActionIcon
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
variant="subtle"
color="gray"
size="sm"
aria-label={expanded ? t("Collapse") : t("Expand")}
aria-expanded={expanded}
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
<IconChevronRight size={14} />
</div>
</ActionIcon>
) : (
<div style={{ width: 20, flexShrink: 0 }} />
)}
+51 -3
View File
@@ -1,4 +1,4 @@
import React, { ReactNode, useState } from "react";
import React, { ReactNode, useEffect, useState } from "react";
import {
ActionIcon,
Popover,
@@ -7,9 +7,24 @@ import {
} from "@mantine/core";
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
import { Suspense } from "react";
const Picker = React.lazy(() => import("@emoji-mart/react"));
import { useTranslation } from "react-i18next";
// Load the picker module AND the emoji data in parallel inside the lazy
// resolution, then bind the data into the component. React.lazy only finishes
// suspending once both are in memory, so the Suspense boundary hides the
// Remove button until the Picker can render with real content.
const Picker = React.lazy(async () => {
const [pickerModule, dataModule] = await Promise.all([
import("@slidoapp/emoji-mart-react"),
import("@slidoapp/emoji-mart-data"),
]);
const PickerComp = pickerModule.default;
const data = dataModule.default;
return {
default: (props: any) => <PickerComp {...props} data={data} />,
};
});
export interface EmojiPickerInterface {
onEmojiSelect: (emoji: any) => void;
icon: ReactNode;
@@ -19,6 +34,7 @@ export interface EmojiPickerInterface {
size?: string;
variant?: string;
c?: string;
tabIndex?: number;
};
}
@@ -50,6 +66,38 @@ function EmojiPicker({
}
});
// emoji-mart's built-in autoFocus calls .focus() without preventScroll, which
// makes the browser scroll every scrollable ancestor of the search input to
// bring it on screen — including the page editor's scroll container, so the
// page jumps to the top whenever the picker is opened from a scrolled-down
// position. The search input lives inside the <em-emoji-picker> custom
// element's shadow root, so we poll for it after the dropdown mounts and
// focus it ourselves with preventScroll.
useEffect(() => {
if (!opened || !dropdown) return;
let cancelled = false;
let rafId = 0;
const tryFocus = (attempts: number) => {
if (cancelled) return;
const pickerEl = dropdown.querySelector("em-emoji-picker");
const input = pickerEl?.shadowRoot?.querySelector<HTMLInputElement>(
'input[type="search"]',
);
if (input) {
input.focus({ preventScroll: true });
return;
}
if (attempts < 60) {
rafId = requestAnimationFrame(() => tryFocus(attempts + 1));
}
};
rafId = requestAnimationFrame(() => tryFocus(0));
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
};
}, [opened, dropdown]);
const handleEmojiSelect = (emoji) => {
onEmojiSelect(emoji);
handlers.close();
@@ -74,6 +122,7 @@ function EmojiPicker({
c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"}
size={actionIconProps?.size}
tabIndex={actionIconProps?.tabIndex}
onClick={handlers.toggle}
aria-label={t("Pick emoji")}
aria-haspopup="dialog"
@@ -85,7 +134,6 @@ function EmojiPicker({
<Suspense fallback={null}>
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
<Picker
data={async () => (await import("@emoji-mart/data")).default}
onEmojiSelect={handleEmojiSelect}
perLine={8}
skinTonePosition="search"
+64 -5
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core";
import { IconLock, IconServer } from "@tabler/icons-react";
@@ -7,15 +7,37 @@ import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
import { getRedirectParam } from "@/lib/app-route.ts";
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
const SSO_AUTO_ATTEMPT_KEY = "docmost:ssoAutoAttempt";
const SSO_AUTO_ATTEMPT_TTL_MS = 5 * 60_000;
function recentAutoAttempt(): boolean {
try {
const raw = window.sessionStorage.getItem(SSO_AUTO_ATTEMPT_KEY);
if (!raw) return false;
const ts = Number(raw);
return Number.isFinite(ts) && Date.now() - ts < SSO_AUTO_ATTEMPT_TTL_MS;
} catch {
return false;
}
}
function markAutoAttempt(): void {
try {
window.sessionStorage.setItem(SSO_AUTO_ATTEMPT_KEY, String(Date.now()));
} catch {
/* sessionStorage unavailable (private mode, etc.) — best effort */
}
}
export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery();
const { data: currentUser } = useCurrentUser();
const [ldapModalOpened, setLdapModalOpened] = useState(false);
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const autoRedirectedRef = useRef(false);
const handleSsoLogin = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.LDAP) {
@@ -28,10 +50,47 @@ export default function SsoLogin() {
providerId: provider.id,
type: provider.type,
workspaceId: data.id,
redirect: getRedirectParam() ?? undefined,
});
}
};
// Auto-redirect when SSO is enforced and there is exactly one non-LDAP
// provider. The user has no other option, so skip the extra click.
useEffect(() => {
if (autoRedirectedRef.current) return;
if (!data?.enforceSso) return;
if (!data.authProviders || data.authProviders.length !== 1) return;
const onlyProvider = data.authProviders[0];
if (onlyProvider.type === SSO_PROVIDER.LDAP) return;
// Already signed in: let useRedirectIfAuthenticated handle navigation
// instead of racing it through the IdP.
if (currentUser?.user) return;
// Explicit logout: don't immediately bounce them back to the IdP.
const params = new URLSearchParams(window.location.search);
if (params.has("logout")) return;
// Circuit-breaker: if we already auto-redirected within the TTL, the
// user came back (likely from an IdP failure). Show the page so they
// can read errors or pick a different account.
if (recentAutoAttempt()) return;
autoRedirectedRef.current = true;
markAutoAttempt();
window.location.href = buildSsoLoginUrl({
providerId: onlyProvider.id,
type: onlyProvider.type,
workspaceId: data.id,
redirect: getRedirectParam() ?? undefined,
});
}, [data, currentUser]);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const getProviderIcon = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.GOOGLE) {
return <GoogleIcon size={16} />;
@@ -34,7 +34,7 @@ function AllowMemberTemplatesToggle() {
const [checked, setChecked] = useState(
workspace?.settings?.templates?.allowMemberTemplates === true,
);
const hasSecuritySettings = useHasFeature(Feature.SECURITY_SETTINGS);
const hasTemplates = useHasFeature(Feature.TEMPLATES);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -54,15 +54,11 @@ function AllowMemberTemplatesToggle() {
};
return (
<Tooltip
label={upgradeLabel}
disabled={hasSecuritySettings}
refProp="rootRef"
>
<Tooltip label={upgradeLabel} disabled={hasTemplates} refProp="rootRef">
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasSecuritySettings}
disabled={!hasTemplates}
aria-label={t("Toggle allow members to create templates")}
/>
</Tooltip>
@@ -137,7 +137,6 @@ export default function Security() {
{ max: SCIM_TOKEN_LIMIT },
)}
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
refProp="rootRef"
>
<Button
onClick={() => setCreateOpen(true)}
+10 -3
View File
@@ -18,14 +18,21 @@ export function buildSsoLoginUrl(opts: {
providerId: string;
type: SSO_PROVIDER;
workspaceId?: string;
redirect?: string;
}): string {
const { providerId, type, workspaceId } = opts;
const { providerId, type, workspaceId, redirect } = opts;
const domain = getAppUrl();
const params = new URLSearchParams();
if (redirect) params.set("redirect", redirect);
if (type === SSO_PROVIDER.GOOGLE) {
return `${getServerAppUrl()}/api/sso/${type}/login?workspaceId=${workspaceId}`;
if (workspaceId) params.set("workspaceId", workspaceId);
return `${getServerAppUrl()}/api/sso/${type}/login?${params.toString()}`;
}
return `${domain}/api/sso/${type}/${providerId}/login`;
const query = params.toString();
const base = `${domain}/api/sso/${type}/${providerId}/login`;
return query ? `${base}?${query}` : base;
}
export function getGoogleSignupUrl(): string {
@@ -8,6 +8,11 @@
@mixin hover {
transform: scale(1.02);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
}
.cardBody {
@@ -50,18 +55,27 @@
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs);
gap: 6px;
padding-top: var(--mantine-spacing-sm);
margin-top: var(--mantine-spacing-lg);
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.scopeDot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background-color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.menuTarget {
opacity: 0;
transition: opacity 100ms ease;
.card:hover & {
.card:hover &,
.card:focus-within & {
opacity: 1;
}
}
@@ -1,4 +1,4 @@
import { Card, Text, ActionIcon, Menu, Group } from "@mantine/core";
import { Button, Card, Text, ActionIcon, Menu, Group } from "@mantine/core";
import {
IconDots,
IconEdit,
@@ -12,6 +12,7 @@ import classes from "./template-card.module.css";
type TemplateCardProps = {
template: ITemplate;
spaceName?: string;
onPreview: (template: ITemplate) => void;
onUse: (template: ITemplate) => void;
onEdit?: (template: ITemplate) => void;
onDelete?: (template: ITemplate) => void;
@@ -21,6 +22,7 @@ type TemplateCardProps = {
export default function TemplateCard({
template,
spaceName,
onPreview,
onUse,
onEdit,
onDelete,
@@ -34,7 +36,17 @@ export default function TemplateCard({
padding="lg"
className={classes.card}
style={{ cursor: "pointer" }}
onClick={() => onUse(template)}
role="button"
tabIndex={0}
aria-label={t("Preview template: {{title}}", { title: template.title })}
onClick={() => onPreview(template)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPreview(template);
}
}}
>
<div className={classes.cardBody}>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
@@ -47,6 +59,17 @@ export default function TemplateCard({
)}
<Group gap={6} wrap="nowrap">
<Button
size="compact-xs"
variant="filled"
className={classes.menuTarget}
onClick={(e) => {
e.stopPropagation();
onUse(template);
}}
>
{t("Use")}
</Button>
{canManage && (
<Menu width={150} shadow="md" withArrow>
<Menu.Target>
@@ -91,6 +114,7 @@ export default function TemplateCard({
<div className={classes.title}>{template.title}</div>
<div className={classes.footer}>
<span className={classes.scopeDot} aria-hidden="true" />
<Text size="sm" fw={500} c="dimmed">
{template.spaceId ? (spaceName || t("Space")) : t("Global")}
</Text>
@@ -0,0 +1,70 @@
.row {
position: relative;
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border-radius: var(--mantine-radius-sm);
width: 100%;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
}
}
.icon {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 16px;
line-height: 1;
}
.title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--mantine-font-size-sm);
text-align: left;
}
.scope {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-xs);
flex-shrink: 0;
transition: opacity 100ms ease;
.row:hover &,
.row:focus-within & {
opacity: 0;
}
}
.useButton {
position: absolute;
top: 50%;
right: var(--mantine-spacing-sm);
transform: translateY(-50%);
opacity: 0;
transition: opacity 100ms ease;
.row:hover &,
.row:focus-within &,
&:focus-visible {
opacity: 1;
}
}
.empty {
display: flex;
align-items: center;
justify-content: center;
padding: var(--mantine-spacing-xl);
}
@@ -0,0 +1,259 @@
import { useMemo, useState } from "react";
import {
Button,
Modal,
TextInput,
ScrollArea,
Loader,
Text,
UnstyledButton,
Group,
SegmentedControl,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import {
IconArrowRight,
IconSearch,
IconFileText,
} from "@tabler/icons-react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
useGetTemplatesQuery,
useUseTemplateMutation,
} from "@/ee/template/queries/template-query";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { ITemplate } from "@/ee/template/types/template.types";
import UseTemplateModal from "@/ee/template/components/use-template-modal";
import TemplatePreviewModal from "@/ee/template/components/template-preview-modal";
import { buildPageUrl } from "@/features/page/page.utils";
import classes from "./template-picker-modal.module.css";
type TemplatePickerModalProps = {
opened: boolean;
onClose: () => void;
/** Pre-select this space in the destination picker after a template is chosen. */
initialSpaceId?: string;
};
type ScopeFilter = "current" | "all";
export default function TemplatePickerModal({
opened,
onClose,
initialSpaceId,
}: TemplatePickerModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const useTemplateMutation = useUseTemplateMutation();
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 200);
const [scope, setScope] = useState<ScopeFilter>(
initialSpaceId ? "current" : "all",
);
// Two-stage selection: previewing first, then destination-picker.
// `previewTemplate` is set when the user clicks a row in the picker.
// `destinationTemplate` is set when they click "Use template" in the preview.
const [previewTemplate, setPreviewTemplate] = useState<ITemplate | null>(
null,
);
const [destinationTemplate, setDestinationTemplate] =
useState<ITemplate | null>(null);
const { data, isPending } = useGetTemplatesQuery({
spaceId: scope === "current" ? initialSpaceId : undefined,
});
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
const spaceNamesById = useMemo(() => {
const map = new Map<string, string>();
spacesData?.items?.forEach((s) => map.set(s.id, s.name));
return map;
}, [spacesData]);
const filtered = useMemo(() => {
const all = data?.pages.flatMap((p) => p.items) ?? [];
const term = debouncedQuery.trim().toLowerCase();
if (!term) return all;
return all.filter((tpl) => tpl.title.toLowerCase().includes(term));
}, [data, debouncedQuery]);
const createInInitialSpace = async (tpl: ITemplate) => {
if (!initialSpaceId) return;
try {
const page = await useTemplateMutation.mutateAsync({
templateId: tpl.id,
spaceId: initialSpaceId,
});
setPreviewTemplate(null);
onClose();
const space = spacesData?.items?.find((s) => s.id === initialSpaceId);
if (page?.slugId && space?.slug) {
navigate(buildPageUrl(space.slug, page.slugId, page.title));
}
} catch {
// error notification handled by mutation's onError
}
};
const handlePick = (tpl: ITemplate) => {
setPreviewTemplate(tpl);
};
const handleQuickUse = (tpl: ITemplate) => {
if (initialSpaceId) {
createInInitialSpace(tpl);
return;
}
setDestinationTemplate(tpl);
};
const handlePreviewClose = () => {
// Closing preview returns to the picker list (no full unmount).
setPreviewTemplate(null);
};
const handlePreviewUse = () => {
if (initialSpaceId && previewTemplate) {
createInInitialSpace(previewTemplate);
return;
}
// Move from preview into destination-picker stage.
setDestinationTemplate(previewTemplate);
setPreviewTemplate(null);
};
const handleDestinationClose = () => {
setDestinationTemplate(null);
onClose();
};
const handleClose = () => {
setQuery("");
setScope(initialSpaceId ? "current" : "all");
setPreviewTemplate(null);
setDestinationTemplate(null);
onClose();
};
return (
<>
<Modal
opened={opened && !previewTemplate && !destinationTemplate}
onClose={handleClose}
size={550}
padding="lg"
yOffset="10vh"
title={<Text fw={500}>{t("Use a template")}</Text>}
>
<TextInput
leftSection={<IconSearch size={16} />}
placeholder={t("Search templates...")}
variant="filled"
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
mb="xs"
autoFocus
/>
{initialSpaceId && (
<SegmentedControl
fullWidth
size="xs"
mb="sm"
value={scope}
onChange={(v) => setScope(v as ScopeFilter)}
data={[
{ label: t("This space"), value: "current" },
{ label: t("All templates"), value: "all" },
]}
/>
)}
<ScrollArea h="50vh" offsetScrollbars>
{isPending ? (
<div className={classes.empty}>
<Loader size="xs" />
</div>
) : filtered.length === 0 ? (
<div className={classes.empty}>
<Text size="sm" c="dimmed">
{t("No templates found")}
</Text>
</div>
) : (
filtered.map((tpl) => (
<UnstyledButton
key={tpl.id}
className={classes.row}
onClick={() => handlePick(tpl)}
>
<div className={classes.icon}>
{tpl.icon ? (
<span>{tpl.icon}</span>
) : (
<IconFileText
size={16}
color="var(--mantine-color-gray-6)"
/>
)}
</div>
<div className={classes.title}>{tpl.title}</div>
<div className={classes.scope}>
{tpl.spaceId
? spaceNamesById.get(tpl.spaceId) ?? t("Space")
: t("Global")}
</div>
<Button
size="compact-xs"
variant="filled"
className={classes.useButton}
loading={useTemplateMutation.isPending}
disabled={useTemplateMutation.isPending}
onClick={(e) => {
e.stopPropagation();
handleQuickUse(tpl);
}}
>
{t("Use")}
</Button>
</UnstyledButton>
))
)}
</ScrollArea>
<Group justify="flex-end" mt="md">
<Button
component={Link}
to="/templates"
variant="subtle"
size="sm"
rightSection={<IconArrowRight size={16} />}
onClick={handleClose}
>
{t("Browse all templates")}
</Button>
</Group>
</Modal>
{previewTemplate && (
<TemplatePreviewModal
templateId={previewTemplate.id}
opened={true}
onClose={handlePreviewClose}
onUse={handlePreviewUse}
useLoading={useTemplateMutation.isPending}
/>
)}
{destinationTemplate && (
<UseTemplateModal
template={destinationTemplate}
opened={true}
onClose={handleDestinationClose}
initialSpaceId={initialSpaceId}
/>
)}
</>
);
}
@@ -9,6 +9,7 @@ type TemplatePreviewModalProps = {
onClose: () => void;
onUse: () => void;
onEdit?: () => void;
useLoading?: boolean;
};
export default function TemplatePreviewModal({
@@ -17,6 +18,7 @@ export default function TemplatePreviewModal({
onClose,
onUse,
onEdit,
useLoading,
}: TemplatePreviewModalProps) {
const { t } = useTranslation();
const { data: template, isLoading } = useGetTemplateByIdQuery(templateId);
@@ -37,14 +39,19 @@ export default function TemplatePreviewModal({
</Group>
</Modal.Title>
<Group gap="sm">
<Button
size="xs"
onClick={onUse}
loading={useLoading}
disabled={useLoading}
>
{t("Use template")}
</Button>
{onEdit && (
<Button size="xs" variant="default" onClick={onEdit}>
{t("Edit")}
</Button>
)}
<Button size="xs" onClick={onUse}>
{t("Use template")}
</Button>
<Modal.CloseButton />
</Group>
</Modal.Header>
@@ -10,12 +10,14 @@ type UseTemplateModalProps = {
template: ITemplate;
opened: boolean;
onClose: () => void;
initialSpaceId?: string;
};
export default function UseTemplateModal({
template,
opened,
onClose,
initialSpaceId,
}: UseTemplateModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -54,6 +56,8 @@ export default function UseTemplateModal({
actionLabel={t("Create page")}
onSelect={handleSelect}
loading={useTemplateMutation.isPending}
initialSpaceId={initialSpaceId ?? template.spaceId}
searchSpacesOnly
/>
);
}
@@ -75,6 +75,18 @@ export default function TemplateEditor() {
const editor = useEditor({
extensions: templateExtensions,
content: "",
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
return true;
}
}
},
},
},
onUpdate() {
if (loadedRef.current) {
markDirty();
@@ -160,7 +160,8 @@ export default function TemplateList() {
? spaceNameMap.get(template.spaceId)
: undefined
}
onUse={handlePreview}
onPreview={handlePreview}
onUse={handleUse}
onEdit={handleEdit}
onDelete={handleDelete}
canManage={isWorkspaceAdmin}
@@ -6,6 +6,7 @@ import {
UseQueryResult,
InfiniteData,
} from "@tanstack/react-query";
import { useAtom, useStore } from "jotai";
import {
getTemplates,
getTemplateById,
@@ -18,6 +19,12 @@ import { ITemplate } from "@/ee/template/types/template.types";
import { IPagination } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { invalidateOnCreatePage } from "@/features/page/queries/page-query.ts";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
export function useGetTemplatesQuery(params?: { spaceId?: string }) {
const { spaceId } = params ?? {};
@@ -149,13 +156,64 @@ export function useDeleteTemplateMutation() {
export function useUseTemplateMutation() {
const { t } = useTranslation();
const [, setTreeData] = useAtom(treeDataAtom);
const store = useStore();
const emit = useQueryEmit();
return useMutation({
mutationFn: (data: {
templateId: string;
spaceId: string;
parentPageId?: string;
}) => useTemplate(data),
return useMutation<
IPage,
Error,
{ templateId: string; spaceId: string; parentPageId?: string }
>({
mutationFn: (data) => useTemplate(data),
onSuccess: (page) => {
// React Query sidebar-pages cache update (same path useCreatePageMutation takes).
invalidateOnCreatePage(page);
const parentId = page.parentPageId ?? null;
const newNode: SpaceTreeNode = {
id: page.id,
slugId: page.slugId,
name: page.title,
icon: page.icon,
position: page.position,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
hasChildren: false,
children: [],
};
// Only mutate the tree atom and broadcast if it currently represents
// this space. Cross-space template-use (e.g., from the gallery picking
// a different space) lets the target space's clients pick up the new
// page on their next React Query refetch (focus, navigation, etc.).
// Without this guard we'd both pollute the local tree and send a wrong
// `index` to remote clients in the target space.
const current = store.get(treeDataAtom);
const treeIsForThisSpace = current[0]?.spaceId === page.spaceId;
if (!treeIsForThisSpace) return;
const lastIndex =
parentId === null
? current.length
: (treeModel.find(current, parentId)?.children?.length ?? 0);
setTreeData((prev) =>
treeModel.insert(prev, parentId, newNode, lastIndex),
);
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: page.spaceId,
payload: {
parentId,
index: lastIndex,
data: newNode,
},
});
}, 50);
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
@@ -1,5 +1,6 @@
import api from "@/lib/api-client";
import { ITemplate } from "@/ee/template/types/template.types";
import { IPage } from "@/features/page/types/page.types";
import { IPagination } from "@/lib/types.ts";
export async function getTemplates(params?: {
@@ -40,7 +41,7 @@ export async function useTemplate(data: {
templateId: string;
spaceId: string;
parentPageId?: string;
}): Promise<any> {
const req = await api.post("/templates/use", data);
}): Promise<IPage> {
const req = await api.post<IPage>("/templates/use", data);
return req.data;
}
@@ -166,7 +166,7 @@ export default function useAuth() {
const handleLogout = async () => {
setCurrentUser(RESET);
await logout();
window.location.replace(APP_ROUTE.AUTH.LOGIN);
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
};
const handleForgotPassword = async (data: IForgotPassword) => {
@@ -1,5 +1,6 @@
import { atom } from "jotai";
import { Editor } from "@tiptap/core";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export const pageEditorAtom = atom<Editor | null>(null);
@@ -12,3 +13,7 @@ export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
// Current page's edit mode — initialized from the user's saved preference on
// first load, can be toggled locally without persisting to the server.
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -46,7 +47,7 @@ export function AudioMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "audio";
const parent = findParentNode(predicate)(selection);
@@ -16,7 +16,7 @@ import {
IconMoodSmile,
IconNotes,
} from "@tabler/icons-react";
import { CalloutType, isTextSelected } from "@docmost/editor-ext";
import { CalloutType, isEditorReady, isTextSelected } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import classes from "../common/toolbar-menu.module.css";
@@ -55,7 +55,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection);
@@ -19,7 +19,7 @@ import {
IconCopy,
IconTrash,
} from "@tabler/icons-react";
import { isTextSelected } from "@docmost/editor-ext";
import { isEditorReady, isTextSelected } from "@docmost/editor-ext";
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css";
@@ -82,7 +82,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) return false;
if (!state || !isEditorReady(editor)) return false;
if (!editor.isActive("columns")) return false;
if (isTextSelected(editor)) return false;
if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
@@ -121,7 +121,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "columns";
const parent = findParentNode(predicate)(selection);
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -81,7 +82,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection);
@@ -21,7 +21,7 @@ let _emojiIndex: EmojiIndexEntry[] | null = null;
export const buildEmojiIndex = async (): Promise<EmojiIndexEntry[]> => {
if (_emojiIndex) return _emojiIndex;
const { default: data } = await import("@emoji-mart/data");
const { default: data } = await import('@slidoapp/emoji-mart-data');
_emojiIndex = (Object.values((data as any).emojis) as any[])
.filter((e) => e.id && e.name && e.skins?.[0]?.native)
.map((e) => ({
@@ -74,7 +74,7 @@ let _cats: EmojiCategory[] | null = null;
export const getEmojiCategories = async (): Promise<EmojiCategory[]> => {
if (_cats) return _cats;
const [{ default: data }, index] = await Promise.all([
import("@emoji-mart/data"),
import("@slidoapp/emoji-mart-data"),
buildEmojiIndex(),
]);
const byId = new Map(index.map((e) => [e.id, e]));
@@ -0,0 +1,14 @@
import { lazy, Suspense } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
const ExcalidrawMenu = lazy(
() => import("@/features/editor/components/excalidraw/excalidraw-menu.tsx"),
);
export default function ExcalidrawMenuLazy(props: EditorMenuProps) {
return (
<Suspense fallback={null}>
<ExcalidrawMenu {...props} />
</Suspense>
);
}
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -94,7 +95,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection);
@@ -0,0 +1,14 @@
import { lazy, Suspense } from "react";
import { NodeViewProps } from "@tiptap/react";
const ExcalidrawView = lazy(
() => import("@/features/editor/components/excalidraw/excalidraw-view.tsx"),
);
export default function ExcalidrawViewLazy(props: NodeViewProps) {
return (
<Suspense fallback={null}>
<ExcalidrawView {...props} />
</Suspense>
);
}
@@ -3,7 +3,7 @@
top: calc(var(--app-shell-header-offset, 0rem) + 45px);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
z-index: 50;
z-index: 99;
display: flex;
align-items: center;
background: var(--mantine-color-body);
@@ -28,6 +28,7 @@ export const FixedToolbar: FC = () => {
<>
<div
className={classes.fixedToolbar}
data-fixed-toolbar="true"
role="toolbar"
aria-label="Editor toolbar"
onMouseDown={(e) => e.preventDefault()}
@@ -10,6 +10,7 @@ import {
IconH2,
IconH3,
IconMenu4,
IconPageBreak,
IconTypography,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -102,6 +103,12 @@ export const BlockTypeGroup: FC<Props> = ({ editor }) => {
>
{t("Divider")}
</Menu.Item>
<Menu.Item
leftSection={<IconPageBreak size={16} />}
onClick={() => editor.chain().focus().setPageBreak().run()}
>
{t("Page break")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -56,7 +57,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection);
@@ -3,7 +3,6 @@ import React, {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
@@ -36,7 +35,7 @@ import {
usePageQuery,
} from "@/features/page/queries/page-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { SimpleTree } from "react-arborist";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
@@ -53,7 +52,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
const { t } = useTranslation();
const [data, setData] = useAtom(treeDataAtom);
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation();
const emit = useQueryEmit();
const isInCommentContext = props.isInCommentContext ?? false;
@@ -220,20 +218,20 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
try {
createdPage = await createPageMutation.mutateAsync(payload);
const parentId = page.id || null;
const data = {
const newNode: SpaceTreeNode = {
id: createdPage.id,
slugId: createdPage.slugId,
name: createdPage.title,
position: createdPage.position,
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
hasChildren: false,
children: [],
} as any;
};
const lastIndex = tree.data.length;
const lastIndex = data.length;
tree.create({ parentId, index: lastIndex, data });
setData(tree.data);
setData(treeModel.insert(data, parentId, newNode, lastIndex));
props.command({
id: uuid7(),
@@ -251,7 +249,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
payload: {
parentId,
index: lastIndex,
data,
data: newNode,
},
});
}, 50);
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -37,9 +38,8 @@ export function PdfMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state || !editor.isActive("pdf")) {
return false;
}
if (!state || !isEditorReady(editor)) return false;
if (!editor.isActive("pdf")) return false;
const { selection } = state;
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
@@ -51,7 +51,7 @@ export function PdfMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "pdf";
const parent = findParentNode(predicate)(selection);
@@ -19,6 +19,7 @@ import {
IconTable,
IconTypography,
IconMenu4,
IconPageBreak,
IconCalendar,
IconAppWindow,
IconSitemap,
@@ -164,6 +165,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
},
{
title: "Page break",
description: "Insert a page break for printing.",
searchTerms: ["page", "break", "pagebreak", "print"],
icon: IconPageBreak,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setPageBreak().run(),
},
{
title: "Image",
description: "Upload any image from your device.",
@@ -6,6 +6,7 @@ import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { isEditorReady } from "@docmost/editor-ext";
interface SubpagesMenuProps {
editor: Editor;
@@ -33,6 +34,7 @@ export const SubpagesMenu = React.memo(
);
const getReferenceClientRect = useCallback(() => {
if (!isEditorReady(editor)) return new DOMRect();
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "subpages";
const parent = findParentNode(predicate)(selection);
@@ -0,0 +1,126 @@
import React, { useCallback, useEffect } from "react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TextSelection } from "@tiptap/pm/state";
import { columnResizingPluginKey } from "@tiptap/pm/tables";
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
import { Menu, UnstyledButton } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { isCellSelection } from "@docmost/editor-ext";
import { CellChevronMenu } from "./menus/cell-chevron-menu";
import classes from "./handle.module.css";
interface CellChevronProps {
editor: Editor;
cellPos: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const CellChevron = React.memo(function CellChevron({
editor,
cellPos,
tableNode,
tablePos,
}: CellChevronProps) {
const { t } = useTranslation();
const cellDom = editor.view.nodeDOM(cellPos) as HTMLElement | null;
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "top-end",
// crossAxis pulls the chevron INWARD from the cell's right edge. We need
// enough inset that we don't overlap PM-tables' column-resize hot zone
// (~5px wide around the column boundary). Without this, hovering near the
// column edge picks up the chevron's `cursor: pointer` instead of
// `col-resize`, and a drag near the edge clicks the chevron.
middleware: [offset({ mainAxis: -22, crossAxis: -10 }), hide()],
whileElementsMounted: autoUpdate,
strategy: "absolute",
});
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
useEffect(() => {
refs.setReference(cellDom);
}, [cellDom, refs]);
// Hide the chevron while the user is resizing a column. PM-tables sets
// `activeHandle > -1` whenever the mouse is near a column boundary OR
// actively dragging it. Either way we don't want the chevron in the way.
const isResizingColumn = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) return false;
const state = columnResizingPluginKey.getState(ctx.editor.state) as
| { activeHandle: number }
| undefined;
return !!state && state.activeHandle > -1;
},
});
const onOpen = useCallback(() => {
const current = editor.state.selection;
// Preserve an existing multi-cell CellSelection that already covers
// this cell so merge etc. operate on the user's whole range.
let preserveExisting = false;
if (isCellSelection(current)) {
current.forEachCell((_node, pos) => {
if (pos === cellPos) preserveExisting = true;
});
}
if (!preserveExisting) {
// Drop a collapsed cursor inside the cell rather than a single-cell
// CellSelection — PM-tables paints the latter as a text-range
// highlight on the cell content.
try {
const $inside = editor.state.doc.resolve(cellPos + 1);
const sel = TextSelection.near($inside, 1);
editor.view.dispatch(editor.state.tr.setSelection(sel));
} catch {}
}
editor.commands.freezeHandles();
}, [editor, cellPos]);
const onClose = useCallback(() => {
editor.commands.unfreezeHandles();
}, [editor]);
if (!cellDom) return null;
if (isResizingColumn) return null;
return (
<Menu
position="bottom-end"
onOpen={onOpen}
onClose={onClose}
withinPortal
shadow="md"
>
<Menu.Target>
<UnstyledButton
ref={refs.setFloating}
style={{
...floatingStyles,
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
}}
className={clsx(classes.cellChevron)}
aria-label={t("Cell actions")}
>
<IconChevronDown size={14} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<CellChevronMenu
editor={editor}
cellPos={cellPos}
tableNode={tableNode}
tablePos={tablePos}
/>
</Menu.Dropdown>
</Menu>
);
});
@@ -0,0 +1,132 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
import { Menu } from "@mantine/core";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
import { ColumnHandleMenu } from "./menus/column-handle-menu";
import classes from "./handle.module.css";
interface ColumnHandleProps {
editor: Editor;
index: number;
anchorPos: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const ColumnHandle = React.memo(function ColumnHandle({
editor,
index,
anchorPos,
tableNode,
tablePos,
}: ColumnHandleProps) {
const { t } = useTranslation();
// Hold the cell DOM in a ref-backed state so we never unmount the handle
// mid-drag. A remote edit can transiently flip `nodeDOM(anchorPos)` to null
// (the plugin re-emits `hoveringCell` with the mapped pos a tick later);
// unmounting the source element here would make pragmatic-dnd silently
// abort the active drag.
// `nodeDOM` is typed as `Node | null` — when `anchorPos` goes stale (e.g.
// an external drop reflows the doc before the plugin re-emits
// hoveringCell), it can resolve to a Text node, on which `.closest` is
// undefined. Filter to HTMLElement so downstream consumers stay safe.
const lookupDom = editor.view.nodeDOM(anchorPos);
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
useEffect(() => {
if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) {
lastCellDomRef.current = lookupCellDom;
setCellDom(lookupCellDom);
}
}, [lookupCellDom]);
const [handleEl, setHandleEl] = useState<HTMLDivElement | null>(null);
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "top",
middleware: [offset(-4), hide()],
whileElementsMounted: autoUpdate,
});
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
useEffect(() => {
refs.setReference(cellDom);
}, [cellDom, refs]);
// `cellDom` is inside the table, so `closest('.tableWrapper')` finds the
// wrapper for this drag's auto-scroll. The handle itself lives in a
// floating layer outside the editor DOM, so we can't walk up from it.
const wrapper = cellDom?.closest<HTMLElement>(".tableWrapper") ?? null;
const [menuOpened, setMenuOpened] = useState(false);
const closeMenu = useCallback(() => setMenuOpened(false), []);
useTableHandleDrag(editor, "col", handleEl, wrapper, closeMenu);
const { onOpen, onClose } = useColumnRowMenuLifecycle({
editor,
orientation: "col",
index,
tableNode,
tablePos,
});
if (!cellDom) return null;
return (
<Menu
opened={menuOpened}
onChange={setMenuOpened}
position="bottom-start"
onOpen={onOpen}
onClose={onClose}
withinPortal
shadow="md"
>
<Menu.Target>
<div
ref={(node) => {
refs.setFloating(node);
setHandleEl(node);
}}
style={{
...floatingStyles,
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
}}
className={clsx(classes.handle, classes.columnHandle)}
role="button"
tabIndex={0}
aria-label={t("Column actions")}
>
<span style={{ pointerEvents: "none", display: "inline-flex" }}>
<GripIcon />
</span>
</div>
</Menu.Target>
<Menu.Dropdown>
<ColumnHandleMenu
editor={editor}
index={index}
tableNode={tableNode}
tablePos={tablePos}
/>
</Menu.Dropdown>
</Menu>
);
});
function GripIcon() {
return (
<svg viewBox="0 0 10 10" width="14" height="14" aria-hidden>
<path
fill="currentColor"
d="M3,2 A1,1 0 1 1 3,0 A1,1 0 0 1 3,2 Z M3,6 A1,1 0 1 1 3,4 A1,1 0 0 1 3,6 Z M3,10 A1,1 0 1 1 3,8 A1,1 0 0 1 3,10 Z M7,2 A1,1 0 1 1 7,0 A1,1 0 0 1 7,2 Z M7,6 A1,1 0 1 1 7,4 A1,1 0 0 1 7,6 Z M7,10 A1,1 0 1 1 7,8 A1,1 0 0 1 7,10 Z"
/>
</svg>
);
}
@@ -0,0 +1,108 @@
.handle {
position: absolute;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: rgba(55, 53, 47, 0.45);
background: var(--mantine-color-body);
border: 1px solid rgba(55, 53, 47, 0.12);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
cursor: grab;
padding: 0;
transition: background-color 120ms ease, color 120ms ease;
@mixin dark {
color: rgba(255, 255, 255, 0.55);
background: var(--mantine-color-dark-7);
border-color: rgba(255, 255, 255, 0.12);
}
}
.handle:hover {
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
color: light-dark(
var(--mantine-color-gray-7),
var(--mantine-color-dark-0)
);
}
.handle:active {
cursor: grabbing;
}
.columnHandle {
width: 28px;
height: 16px;
}
.columnHandle svg {
transform: rotate(90deg);
}
.rowHandle {
width: 16px;
height: 28px;
}
@media (max-width: 600px) {
.handle {
display: none;
}
}
.cellChevron {
position: absolute;
z-index: 50;
width: 18px;
height: 18px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: light-dark(
var(--mantine-color-gray-7),
var(--mantine-color-dark-1)
);
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
border: 1px solid rgba(55, 53, 47, 0.12);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
cursor: pointer;
padding: 0;
transition: background-color 120ms ease, color 120ms ease;
@mixin dark {
border-color: rgba(255, 255, 255, 0.12);
}
}
.cellChevron:hover {
background: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-4)
);
color: light-dark(
var(--mantine-color-gray-8),
var(--mantine-color-dark-0)
);
}
@media (max-width: 600px) {
.cellChevron {
display: none;
}
}
@media print {
.handle,
.cellChevron {
display: none !important;
}
}
@@ -0,0 +1,40 @@
import { useCallback } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { buildRowOrColumnSelection, Orientation } from "../lib/select-row-column";
interface Args {
editor: Editor;
orientation: Orientation;
index: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export function useColumnRowMenuLifecycle({
editor,
orientation,
index,
tableNode,
tablePos,
}: Args) {
const onOpen = useCallback(() => {
const selection = buildRowOrColumnSelection(
editor.state,
tableNode,
tablePos,
orientation,
index,
);
const tr = editor.state.tr;
if (selection) tr.setSelection(selection);
editor.view.dispatch(tr);
editor.commands.freezeHandles();
}, [editor, orientation, index, tableNode, tablePos]);
const onClose = useCallback(() => {
editor.commands.unfreezeHandles();
}, [editor]);
return { onOpen, onClose };
}
@@ -0,0 +1,54 @@
import { useCallback } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TableMap } from "@tiptap/pm/tables";
type Scope =
| { kind: "col"; index: number }
| { kind: "row"; index: number }
| { kind: "cell"; cellPos: number };
export function useTableClear(
editor: Editor,
tableNode: ProseMirrorNode,
tablePos: number,
scope: Scope,
) {
return useCallback(() => {
const tr = editor.state.tr;
const tableStart = tablePos + 1;
const map = TableMap.get(tableNode);
const paragraph = editor.schema.nodes.paragraph;
if (!paragraph) return;
const cellOffsets: number[] = [];
if (scope.kind === "col") {
for (let row = 0; row < map.height; row++) {
cellOffsets.push(map.map[row * map.width + scope.index]);
}
} else if (scope.kind === "row") {
for (let col = 0; col < map.width; col++) {
cellOffsets.push(map.map[scope.index * map.width + col]);
}
}
const targets =
scope.kind === "cell"
? [scope.cellPos]
: Array.from(new Set(cellOffsets)).map((o) => tableStart + o);
// Process in reverse position order so earlier replacements don't shift later ones.
targets.sort((a, b) => b - a);
for (const cellPos of targets) {
const node = tr.doc.nodeAt(cellPos);
if (!node) continue;
const start = cellPos + 1;
const end = cellPos + node.nodeSize - 1;
tr.replaceWith(start, end, paragraph.create());
}
if (tr.docChanged) editor.view.dispatch(tr);
}, [editor, tableNode, tablePos, scope]);
}
@@ -0,0 +1,79 @@
import { useEffect } from "react";
import type { Editor } from "@tiptap/react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { disableNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview";
import {
autoScrollForElements,
autoScrollWindowForElements,
} from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { getTableHandlePluginSpec } from "@docmost/editor-ext";
// Uses pragmatic-drag-and-drop instead of native HTML5 DnD because the native
// dragstart→dragover→drop lifecycle was being silently cancelled
export function useTableHandleDrag(
editor: Editor,
orientation: "col" | "row",
element: HTMLElement | null,
wrapper: HTMLElement | null,
onDragStart?: () => void,
) {
useEffect(() => {
if (!element) return;
return combine(
draggable({
element,
getInitialData: () => ({ type: `table-${orientation}` }),
onGenerateDragPreview: ({ nativeSetDragImage }) => {
// We render our own floating preview via PreviewController, so hide
// the native drag image entirely.
disableNativeDragPreview({ nativeSetDragImage });
},
onDragStart: ({ location }) => {
// The menu (if open from a prior click on the handle) won't dismiss
// on its own — pragmatic-dnd swallows the events Mantine listens for.
onDragStart?.();
const spec = getTableHandlePluginSpec(editor);
if (!spec) return;
const { clientX, clientY } = location.initial.input;
spec.startDragFromHandle(orientation, clientX, clientY);
},
onDrag: ({ location }) => {
const spec = getTableHandlePluginSpec(editor);
if (!spec) return;
const { clientX, clientY } = location.current.input;
spec.updateDragPosition(clientX, clientY);
},
onDrop: ({ location }) => {
const spec = getTableHandlePluginSpec(editor);
if (!spec) return;
const { clientX, clientY } = location.current.input;
// Make sure the final position is recorded before committing the drop.
spec.updateDragPosition(clientX, clientY);
spec.commitDrop();
spec.endDrag();
},
}),
// Wrapper owns horizontal auto-scroll (it has `overflow-x: auto`);
// window owns vertical. Locking each axis prevents the window's
// horizontal auto-scroll from running when the cursor approaches
// the viewport edge — without the cap, the preview's `left` follows
// the cursor past the viewport, the page widens to contain it, the
// plugin scrolls the now-wider page further, and the loop never
// ends.
// Only the column handle registers wrapper auto-scroll (rows can't
// scroll horizontally) — registering twice on the same wrapper
// triggers a dev-mode warning from pragmatic-dnd-auto-scroll.
orientation === "col" &&
wrapper &&
!wrapper.classList.contains("tableWrapperNoOverflow")
? autoScrollForElements({
element: wrapper,
getAllowedAxis: () => "horizontal",
})
: () => {},
autoScrollWindowForElements({ getAllowedAxis: () => "vertical" }),
);
}, [editor, orientation, element, wrapper, onDragStart]);
}
@@ -0,0 +1,23 @@
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { TableDndKey, TableHandleState } from "@docmost/editor-ext";
const FALLBACK: TableHandleState = {
hoveringCell: null,
tableNode: null,
tablePos: null,
dragging: null,
frozen: false,
};
export function useTableHandleState(editor: Editor | null): TableHandleState {
const state = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) return null;
return TableDndKey.getState(ctx.editor.state) ?? null;
},
});
return state ?? FALLBACK;
}
@@ -0,0 +1,50 @@
import { useCallback, useMemo } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TableMap } from "@tiptap/pm/tables";
import { moveColumn, moveRow } from "@docmost/editor-ext";
export type MoveDirection = "left" | "right" | "up" | "down";
export function useTableMoveRowColumn(
editor: Editor,
orientation: "col" | "row",
index: number,
direction: MoveDirection,
tableNode: ProseMirrorNode,
tablePos: number,
) {
const target =
direction === "left" || direction === "up" ? index - 1 : index + 1;
const maxIndex = useMemo(() => {
const map = TableMap.get(tableNode);
return orientation === "col" ? map.width - 1 : map.height - 1;
}, [tableNode, orientation]);
const canMove = target >= 0 && target <= maxIndex;
const handleMove = useCallback(() => {
if (!canMove) return;
const tr = editor.state.tr;
const moved =
orientation === "col"
? moveColumn({
tr,
originIndex: index,
targetIndex: target,
select: true,
pos: tablePos + 1,
})
: moveRow({
tr,
originIndex: index,
targetIndex: target,
select: true,
pos: tablePos + 1,
});
if (moved) editor.view.dispatch(tr);
}, [editor, orientation, index, target, tablePos, canMove]);
return { canMove, handleMove };
}
@@ -0,0 +1,100 @@
import { useCallback, useMemo } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import {
convertArrayOfRowsToTableNode,
convertTableNodeToArrayOfRows,
transpose,
} from "@docmost/editor-ext";
import {
getCellSortText,
isCellEmpty,
isHeaderCell,
type SortDirection,
type SortableItem,
sortItems,
weaveItems,
} from "../lib/sort-cells";
interface Args {
editor: Editor;
orientation: "col" | "row";
index: number;
tableNode: ProseMirrorNode;
tablePos: number;
direction: SortDirection;
}
function tableHasMergedCells(tableNode: ProseMirrorNode): boolean {
for (let r = 0; r < tableNode.childCount; r++) {
const row = tableNode.child(r);
for (let c = 0; c < row.childCount; c++) {
const { colspan = 1, rowspan = 1 } = row.child(c).attrs;
if (colspan > 1 || rowspan > 1) return true;
}
}
return false;
}
function isAllHeader(cells: (ProseMirrorNode | null)[]): boolean {
return cells.every((c) => c !== null && isHeaderCell(c));
}
export function useTableSort({
editor,
orientation,
index,
tableNode,
tablePos,
direction,
}: Args) {
const canSort = useMemo(() => {
if (tableHasMergedCells(tableNode)) return false;
const rows = convertTableNodeToArrayOfRows(tableNode);
const axes = orientation === "col" ? rows : transpose(rows);
if (axes.length < 2) return false;
return axes.some((cells) => {
if (isAllHeader(cells)) return false;
const sortCell = cells[index];
return !!sortCell && !isCellEmpty(sortCell);
});
}, [tableNode, orientation, index]);
const handleSort = useCallback(() => {
if (!canSort) return;
const rows = convertTableNodeToArrayOfRows(tableNode);
const axes = orientation === "col" ? rows : transpose(rows);
const items: SortableItem<(ProseMirrorNode | null)[]>[] = axes.map(
(cells, originalOrder) => {
const sortCell = cells[index];
return {
payload: cells,
text: sortCell ? getCellSortText(sortCell) : "",
isHeader: isAllHeader(cells),
isEmpty: !sortCell || isCellEmpty(sortCell),
originalOrder,
};
},
);
const dataItems = items.filter((it) => !it.isHeader);
const sortedData = sortItems(dataItems, direction);
const woven = weaveItems(items, sortedData);
const newAxes = woven.map((it) => it.payload);
const newRows = orientation === "col" ? newAxes : transpose(newAxes);
const newTable = convertArrayOfRowsToTableNode(tableNode, newRows);
const tr = editor.state.tr;
tr.replaceWith(tablePos, tablePos + tableNode.nodeSize, newTable);
if (tr.docChanged) editor.view.dispatch(tr);
}, [editor, tableNode, tablePos, orientation, index, direction, canSort]);
return { canSort, handleSort };
}
@@ -0,0 +1,34 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import type { EditorState } from "@tiptap/pm/state";
import { CellSelection, TableMap } from "@tiptap/pm/tables";
export type Orientation = "col" | "row";
export function buildRowOrColumnSelection(
state: EditorState,
tableNode: ProseMirrorNode,
tablePos: number,
orientation: Orientation,
index: number,
): CellSelection | null {
const map = TableMap.get(tableNode);
const tableStart = tablePos + 1;
if (orientation === "col") {
if (index < 0 || index >= map.width) return null;
const firstCellPos = tableStart + map.map[index];
const lastCellPos =
tableStart + map.map[(map.height - 1) * map.width + index];
const $first = state.doc.resolve(firstCellPos);
const $last = state.doc.resolve(lastCellPos);
return CellSelection.colSelection($first, $last);
}
if (index < 0 || index >= map.height) return null;
const firstCellPos = tableStart + map.map[index * map.width];
const lastCellPos =
tableStart + map.map[index * map.width + (map.width - 1)];
const $first = state.doc.resolve(firstCellPos);
const $last = state.doc.resolve(lastCellPos);
return CellSelection.rowSelection($first, $last);
}
@@ -0,0 +1,57 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
export type SortDirection = "asc" | "desc";
export interface SortableItem<T> {
payload: T;
text: string;
isHeader: boolean;
isEmpty: boolean;
originalOrder: number;
}
const HEADER_TYPE_NAMES = new Set(["tableHeader", "table_header"]);
export function isHeaderCell(node: ProseMirrorNode): boolean {
if (HEADER_TYPE_NAMES.has(node.type.name)) return true;
return node.attrs?.header === true;
}
export function getCellSortText(node: ProseMirrorNode): string {
let text = "";
node.descendants((child) => {
if (child.isText) text += child.text ?? "";
return true;
});
return text.trim().toLowerCase();
}
export function isCellEmpty(node: ProseMirrorNode): boolean {
return getCellSortText(node) === "";
}
export const collator = new Intl.Collator(undefined, {
sensitivity: "base",
numeric: true,
});
export function sortItems<T>(
data: SortableItem<T>[],
direction: SortDirection,
): SortableItem<T>[] {
return [...data].sort((a, b) => {
if (a.isEmpty && !b.isEmpty) return 1;
if (!a.isEmpty && b.isEmpty) return -1;
if (a.isEmpty && b.isEmpty) return a.originalOrder - b.originalOrder;
const cmp = collator.compare(a.text, b.text);
return direction === "asc" ? cmp : -cmp;
});
}
export function weaveItems<T>(
all: SortableItem<T>[],
sortedData: SortableItem<T>[],
): SortableItem<T>[] {
const dataQueue = [...sortedData];
return all.map((item) => (item.isHeader ? item : dataQueue.shift()!));
}
@@ -0,0 +1,49 @@
import React from "react";
import type { Editor } from "@tiptap/react";
import { Menu } from "@mantine/core";
import {
IconAlignCenter,
IconAlignLeft,
IconAlignRight,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
interface AlignmentSubmenuProps {
editor: Editor;
}
export const AlignmentSubmenu = React.memo(function AlignmentSubmenu({
editor,
}: AlignmentSubmenuProps) {
const { t } = useTranslation();
return (
<Menu.Sub position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconAlignLeft size={16} />}>
{t("Text alignment")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<Menu.Item
leftSection={<IconAlignLeft size={16} />}
onClick={() => editor.chain().focus().setTextAlign("left").run()}
>
{t("Align left")}
</Menu.Item>
<Menu.Item
leftSection={<IconAlignCenter size={16} />}
onClick={() => editor.chain().focus().setTextAlign("center").run()}
>
{t("Align center")}
</Menu.Item>
<Menu.Item
leftSection={<IconAlignRight size={16} />}
onClick={() => editor.chain().focus().setTextAlign("right").run()}
>
{t("Align right")}
</Menu.Item>
</Menu.Sub.Dropdown>
</Menu.Sub>
);
});
@@ -0,0 +1,154 @@
import React from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { ColorSwatch, Menu } from "@mantine/core";
import {
IconBoxMargin,
IconColumnInsertRight,
IconColumnRemove,
IconEraser,
IconPalette,
IconRowInsertBottom,
IconRowRemove,
IconSquareToggle,
IconTableRow,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useTableClear } from "../hooks/use-table-clear";
import { TABLE_COLORS } from "../../table-background-color";
import { AlignmentSubmenu } from "./alignment-submenu";
interface CellChevronMenuProps {
editor: Editor;
cellPos: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const CellChevronMenu = React.memo(function CellChevronMenu({
editor,
cellPos,
tableNode,
tablePos,
}: CellChevronMenuProps) {
const { t } = useTranslation();
const clearCell = useTableClear(editor, tableNode, tablePos, {
kind: "cell",
cellPos,
});
const setBackground = (color: string, name: string) => {
editor
.chain()
.focus()
.updateAttributes("tableCell", {
backgroundColor: color || null,
backgroundColorName: color ? name : null,
})
.updateAttributes("tableHeader", {
backgroundColor: color || null,
backgroundColorName: color ? name : null,
})
.run();
};
return (
<>
<Menu.Sub position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
{t("Background color")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 8,
padding: 8,
}}
>
{TABLE_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() => setBackground(c.color, c.name)}
aria-label={t(c.name)}
style={{
border: "none",
background: "transparent",
padding: 0,
cursor: "pointer",
}}
>
<ColorSwatch
color={c.color || "#ffffff"}
size={22}
style={{
border: c.color === "" ? "1px solid #e5e7eb" : undefined,
}}
/>
</button>
))}
</div>
</Menu.Sub.Dropdown>
</Menu.Sub>
<AlignmentSubmenu editor={editor} />
<Menu.Item
leftSection={<IconBoxMargin size={16} />}
onClick={() => editor.chain().focus().mergeCells().run()}
disabled={!editor.can().mergeCells()}
>
{t("Merge cells")}
</Menu.Item>
<Menu.Item
leftSection={<IconSquareToggle size={16} />}
onClick={() => editor.chain().focus().splitCell().run()}
disabled={!editor.can().splitCell()}
>
{t("Split cell")}
</Menu.Item>
<Menu.Item
leftSection={<IconTableRow size={16} />}
onClick={() => editor.chain().focus().toggleHeaderCell().run()}
>
{t("Toggle header cell")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconColumnInsertRight size={16} />}
onClick={() => editor.chain().focus().addColumnAfter().run()}
>
{t("Add column right")}
</Menu.Item>
<Menu.Item
leftSection={<IconRowInsertBottom size={16} />}
onClick={() => editor.chain().focus().addRowAfter().run()}
>
{t("Add row below")}
</Menu.Item>
<Menu.Item leftSection={<IconEraser size={16} />} onClick={clearCell}>
{t("Clear cell")}
</Menu.Item>
<Menu.Item
leftSection={<IconColumnRemove size={16} />}
onClick={() => editor.chain().focus().deleteColumn().run()}
>
{t("Delete column")}
</Menu.Item>
<Menu.Item
leftSection={<IconRowRemove size={16} />}
onClick={() => editor.chain().focus().deleteRow().run()}
>
{t("Delete row")}
</Menu.Item>
</>
);
});
@@ -0,0 +1,177 @@
import React from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { ColorSwatch, Menu } from "@mantine/core";
import { TABLE_COLORS } from "../../table-background-color";
import {
IconArrowLeft,
IconArrowRight,
IconColumnInsertLeft,
IconColumnInsertRight,
IconColumnRemove,
IconEraser,
IconPalette,
IconSortAscendingLetters,
IconSortDescendingLetters,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useTableMoveRowColumn } from "../hooks/use-table-move-row-column";
import { useTableClear } from "../hooks/use-table-clear";
import { useTableSort } from "../hooks/use-table-sort";
import { AlignmentSubmenu } from "./alignment-submenu";
interface ColumnHandleMenuProps {
editor: Editor;
index: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const ColumnHandleMenu = React.memo(function ColumnHandleMenu({
editor,
index,
tableNode,
tablePos,
}: ColumnHandleMenuProps) {
const { t } = useTranslation();
const moveLeft = useTableMoveRowColumn(editor, "col", index, "left", tableNode, tablePos);
const moveRight = useTableMoveRowColumn(editor, "col", index, "right", tableNode, tablePos);
const clearCol = useTableClear(editor, tableNode, tablePos, {
kind: "col",
index,
});
const setBackground = (color: string, name: string) => {
editor
.chain()
.focus()
.updateAttributes("tableCell", {
backgroundColor: color || null,
backgroundColorName: color ? name : null,
})
.updateAttributes("tableHeader", {
backgroundColor: color || null,
backgroundColorName: color ? name : null,
})
.run();
};
const sortAsc = useTableSort({
editor,
orientation: "col",
index,
tableNode,
tablePos,
direction: "asc",
});
const sortDesc = useTableSort({
editor,
orientation: "col",
index,
tableNode,
tablePos,
direction: "desc",
});
return (
<>
<Menu.Item
leftSection={<IconSortAscendingLetters size={16} />}
onClick={sortAsc.handleSort}
disabled={!sortAsc.canSort}
>
{t("Sort A → Z")}
</Menu.Item>
<Menu.Item
leftSection={<IconSortDescendingLetters size={16} />}
onClick={sortDesc.handleSort}
disabled={!sortDesc.canSort}
>
{t("Sort Z → A")}
</Menu.Item>
<Menu.Divider />
<Menu.Sub position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
{t("Background color")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, padding: 8 }}>
{TABLE_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() => setBackground(c.color, c.name)}
aria-label={t(c.name)}
style={{
border: "none",
background: "transparent",
padding: 0,
cursor: "pointer",
}}
>
<ColorSwatch
color={c.color || "#ffffff"}
size={22}
style={{ border: c.color === "" ? "1px solid #e5e7eb" : undefined }}
/>
</button>
))}
</div>
</Menu.Sub.Dropdown>
</Menu.Sub>
<AlignmentSubmenu editor={editor} />
<Menu.Divider />
<Menu.Item
leftSection={<IconColumnInsertLeft size={16} />}
onClick={() => editor.chain().focus().addColumnBefore().run()}
>
{t("Add column left")}
</Menu.Item>
<Menu.Item
leftSection={<IconColumnInsertRight size={16} />}
onClick={() => editor.chain().focus().addColumnAfter().run()}
>
{t("Add column right")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconEraser size={16} />}
onClick={clearCol}
>
{t("Clear cells")}
</Menu.Item>
<Menu.Item
leftSection={<IconColumnRemove size={16} />}
onClick={() => editor.chain().focus().deleteColumn().run()}
>
{t("Delete column")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconArrowLeft size={16} />}
onClick={moveLeft.handleMove}
disabled={!moveLeft.canMove}
>
{t("Move column left")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={moveRight.handleMove}
disabled={!moveRight.canMove}
>
{t("Move column right")}
</Menu.Item>
</>
);
});
@@ -0,0 +1,138 @@
import React from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { ColorSwatch, Menu } from "@mantine/core";
import { TABLE_COLORS } from "../../table-background-color";
import {
IconArrowDown,
IconArrowUp,
IconEraser,
IconPalette,
IconRowInsertBottom,
IconRowInsertTop,
IconRowRemove,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useTableMoveRowColumn } from "../hooks/use-table-move-row-column";
import { useTableClear } from "../hooks/use-table-clear";
import { AlignmentSubmenu } from "./alignment-submenu";
interface RowHandleMenuProps {
editor: Editor;
index: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const RowHandleMenu = React.memo(function RowHandleMenu({
editor,
index,
tableNode,
tablePos,
}: RowHandleMenuProps) {
const { t } = useTranslation();
const setBackground = (color: string, name: string) => {
editor
.chain()
.focus()
.updateAttributes("tableCell", {
backgroundColor: color || null,
backgroundColorName: color ? name : null,
})
.updateAttributes("tableHeader", {
backgroundColor: color || null,
backgroundColorName: color ? name : null,
})
.run();
};
const moveUp = useTableMoveRowColumn(editor, "row", index, "up", tableNode, tablePos);
const moveDown = useTableMoveRowColumn(editor, "row", index, "down", tableNode, tablePos);
const clearRow = useTableClear(editor, tableNode, tablePos, {
kind: "row",
index,
});
return (
<>
<Menu.Sub position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
{t("Background color")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, padding: 8 }}>
{TABLE_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() => setBackground(c.color, c.name)}
aria-label={t(c.name)}
style={{
border: "none",
background: "transparent",
padding: 0,
cursor: "pointer",
}}
>
<ColorSwatch
color={c.color || "#ffffff"}
size={22}
style={{ border: c.color === "" ? "1px solid #e5e7eb" : undefined }}
/>
</button>
))}
</div>
</Menu.Sub.Dropdown>
</Menu.Sub>
<AlignmentSubmenu editor={editor} />
<Menu.Divider />
<Menu.Item
leftSection={<IconRowInsertTop size={16} />}
onClick={() => editor.chain().focus().addRowBefore().run()}
>
{t("Add row above")}
</Menu.Item>
<Menu.Item
leftSection={<IconRowInsertBottom size={16} />}
onClick={() => editor.chain().focus().addRowAfter().run()}
>
{t("Add row below")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconEraser size={16} />} onClick={clearRow}>
{t("Clear cells")}
</Menu.Item>
<Menu.Item
leftSection={<IconRowRemove size={16} />}
onClick={() => editor.chain().focus().deleteRow().run()}
>
{t("Delete row")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconArrowUp size={16} />}
onClick={moveUp.handleMove}
disabled={!moveUp.canMove}
>
{t("Move row up")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowDown size={16} />}
onClick={moveDown.handleMove}
disabled={!moveDown.canMove}
>
{t("Move row down")}
</Menu.Item>
</>
);
});
@@ -0,0 +1,127 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
import { Menu } from "@mantine/core";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
import { RowHandleMenu } from "./menus/row-handle-menu";
import classes from "./handle.module.css";
interface RowHandleProps {
editor: Editor;
index: number;
anchorPos: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const RowHandle = React.memo(function RowHandle({
editor,
index,
anchorPos,
tableNode,
tablePos,
}: RowHandleProps) {
const { t } = useTranslation();
// See ColumnHandle for the rationale: keep the last valid cell DOM cached
// so the handle div stays mounted across stale-anchor renders, otherwise
// pragmatic-dnd silently aborts an in-flight drag.
// `nodeDOM` is typed as `Node | null` — when `anchorPos` goes stale (e.g.
// an external drop reflows the doc before the plugin re-emits
// hoveringCell), it can resolve to a Text node, on which `.closest` is
// undefined. Filter to HTMLElement so downstream consumers stay safe.
const lookupDom = editor.view.nodeDOM(anchorPos);
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
useEffect(() => {
if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) {
lastCellDomRef.current = lookupCellDom;
setCellDom(lookupCellDom);
}
}, [lookupCellDom]);
const [handleEl, setHandleEl] = useState<HTMLDivElement | null>(null);
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "left",
middleware: [offset(-4), hide()],
whileElementsMounted: autoUpdate,
});
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
useEffect(() => {
refs.setReference(cellDom);
}, [cellDom, refs]);
const wrapper = cellDom?.closest<HTMLElement>(".tableWrapper") ?? null;
const [menuOpened, setMenuOpened] = useState(false);
const closeMenu = useCallback(() => setMenuOpened(false), []);
useTableHandleDrag(editor, "row", handleEl, wrapper, closeMenu);
const { onOpen, onClose } = useColumnRowMenuLifecycle({
editor,
orientation: "row",
index,
tableNode,
tablePos,
});
if (!cellDom) return null;
return (
<Menu
opened={menuOpened}
onChange={setMenuOpened}
position="right-start"
onOpen={onOpen}
onClose={onClose}
withinPortal
shadow="md"
>
<Menu.Target>
<div
ref={(node) => {
refs.setFloating(node);
setHandleEl(node);
}}
style={{
...floatingStyles,
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
}}
className={clsx(classes.handle, classes.rowHandle)}
role="button"
tabIndex={0}
aria-label={t("Row actions")}
>
<span style={{ pointerEvents: "none", display: "inline-flex" }}>
<GripIcon />
</span>
</div>
</Menu.Target>
<Menu.Dropdown>
<RowHandleMenu
editor={editor}
index={index}
tableNode={tableNode}
tablePos={tablePos}
/>
</Menu.Dropdown>
</Menu>
);
});
function GripIcon() {
return (
<svg viewBox="0 0 10 10" width="14" height="14" aria-hidden>
<path
fill="currentColor"
d="M3,2 A1,1 0 1 1 3,0 A1,1 0 0 1 3,2 Z M3,6 A1,1 0 1 1 3,4 A1,1 0 0 1 3,6 Z M3,10 A1,1 0 1 1 3,8 A1,1 0 0 1 3,10 Z M7,2 A1,1 0 1 1 7,0 A1,1 0 0 1 7,2 Z M7,6 A1,1 0 1 1 7,4 A1,1 0 0 1 7,6 Z M7,10 A1,1 0 1 1 7,8 A1,1 0 0 1 7,10 Z"
/>
</svg>
);
}
@@ -0,0 +1,44 @@
import React from "react";
import type { Editor } from "@tiptap/react";
import { useTableHandleState } from "./hooks/use-table-handle-state";
import { ColumnHandle } from "./column-handle";
import { RowHandle } from "./row-handle";
import { CellChevron } from "./cell-chevron";
interface TableHandlesLayerProps {
editor: Editor | null;
}
export const TableHandlesLayer = React.memo(function TableHandlesLayer({
editor,
}: TableHandlesLayerProps) {
const state = useTableHandleState(editor);
if (!editor || !editor.isEditable) return null;
if (!state.hoveringCell || !state.tableNode || state.tablePos == null) return null;
return (
<>
<ColumnHandle
editor={editor}
index={state.hoveringCell.colIndex}
anchorPos={state.hoveringCell.colFirstCellPos}
tableNode={state.tableNode!}
tablePos={state.tablePos!}
/>
<RowHandle
editor={editor}
index={state.hoveringCell.rowIndex}
anchorPos={state.hoveringCell.rowFirstCellPos}
tableNode={state.tableNode!}
tablePos={state.tablePos!}
/>
<CellChevron
editor={editor}
cellPos={state.hoveringCell.cellPos}
tableNode={state.tableNode!}
tablePos={state.tablePos!}
/>
</>
);
});
@@ -22,7 +22,7 @@ interface TableBackgroundColorProps {
editor: Editor | null;
}
const TABLE_COLORS: TableColorItem[] = [
export const TABLE_COLORS: TableColorItem[] = [
{ name: "Default", color: "" },
{ name: "Blue", color: "#b4d5ff" },
{ name: "Green", color: "#acf5d2" },
@@ -18,7 +18,7 @@ import {
IconTrashX,
} from "@tabler/icons-react";
import { BubbleMenu } from "@tiptap/react/menus";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css";
@@ -38,6 +38,7 @@ export const TableMenu = React.memo(
);
const getReferencedVirtualElement = useCallback(() => {
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "table";
const parent = findParentNode(predicate)(selection);
@@ -104,12 +105,12 @@ export const TableMenu = React.memo(
element.style.zIndex = "99";
}}
options={{
placement: "top",
placement: "bottom",
offset: {
mainAxis: 15,
},
flip: {
fallbackPlacements: ["top", "bottom"],
fallbackPlacements: ["bottom", "top"],
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
boundary: editor.options.element as HTMLElement,
},
@@ -86,11 +86,11 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
transitionProps={{ transition: "pop" }}
>
<Popover.Target>
<Tooltip label={t("Text alignment")} withArrow>
<Tooltip label={t("Text align")} withArrow>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t("Text alignment")}
aria-label={t("Text align")}
onClick={() => setOpened(!opened)}
>
<activeItem.icon size={18} />
@@ -35,6 +35,7 @@ export default function TransclusionReferenceView(props: NodeViewProps) {
return (
<NodeViewWrapper
className={classes.includeWrap}
data-editable={isEditable ? "true" : "false"}
data-focused={isEditable && props.selected ? "true" : "false"}
data-menu-open={openMenus > 0 ? "true" : "false"}
contentEditable={false}
@@ -62,6 +62,7 @@ export default function TransclusionView(props: NodeViewProps) {
return (
<NodeViewWrapper
className={classes.transclusionWrap}
data-editable={isEditable ? "true" : "false"}
data-menu-open={openMenus > 0 ? "true" : "false"}
data-id={transclusionId ?? undefined}
>
@@ -44,8 +44,29 @@
transition: border 0.3s;
}
.transclusionWrap:hover,
.transclusionWrap:focus-within {
.transclusionWrap[data-editable="false"],
.includeWrap[data-editable="false"] {
margin-left: 0;
margin-right: 0;
width: 100%;
padding: 0;
}
/* Cancel the wrapping .react-renderer's vertical spacing in read-only mode
so the synced block sits flush with surrounding paragraphs (whose own
margins already provide the right rhythm). */
:global(.react-renderer.node-transclusionSource):has(
.transclusionWrap[data-editable="false"]
),
:global(.react-renderer.node-transclusionReference):has(
.includeWrap[data-editable="false"]
) {
margin-top: 0;
margin-bottom: 0;
}
.transclusionWrap[data-editable="true"]:hover,
.transclusionWrap[data-editable="true"]:focus-within {
border: 2px solid
light-dark(
var(--mantine-color-orange-2),
@@ -114,9 +135,9 @@
transition: border 0.3s;
}
.includeWrap:hover,
.includeWrap[data-focused="true"],
.includeWrap[data-menu-open="true"] {
.includeWrap[data-editable="true"]:hover,
.includeWrap[data-editable="true"][data-focused="true"],
.includeWrap[data-editable="true"][data-menu-open="true"] {
border: 2px solid
light-dark(
var(--mantine-color-orange-2),
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -53,7 +54,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video";
const parent = findParentNode(predicate)(selection);
@@ -60,6 +60,23 @@ function nodeDOMAtCoords(
options: GlobalDragHandleOptions,
view: EditorView,
) {
// Custom nodes (transclusion, …) render via tiptap's React node-view
// renderer, which emits `class="react-renderer node-${name}"` on the
// live wrapper — the `data-type` attribute is for static HTML
// serialization only. Match both so we cover live and parsed DOM.
// Inside a custom node, also match plain `p` so the first paragraph
// (which doesn't match `:not(:first-child)`) still gets its own
// handle; only hovers on the custom node's padding/border fall
// through to the wrapper.
const customSelectors = options.customNodes.flatMap((node) => [
`[data-type=${node}]`,
`.node-${node}`,
]);
const customParagraphSelectors = options.customNodes.flatMap((node) => [
`[data-type=${node}] p`,
`.node-${node} p`,
]);
const selectors = [
"li",
"p:not(:first-child)",
@@ -71,7 +88,13 @@ function nodeDOMAtCoords(
"h4",
"h5",
"h6",
...options.customNodes.map((node) => `[data-type=${node}]`),
// Tables nested in another block (toggle, transclusion, …) have a
// wrapper that isn't a direct child of .ProseMirror, so the
// parent-check below skips it. Match the wrapper explicitly so the
// handle shows up even with empty cells.
".tableWrapper",
...customParagraphSelectors,
...customSelectors,
].join(", ");
return document
.elementsFromPoint(coords.x, coords.y)
@@ -99,6 +122,22 @@ function nodePosAtDOM(
})?.inside;
}
function isCustomNodeDOM(
elem: Element | null | undefined,
options: GlobalDragHandleOptions,
): boolean {
if (!elem) return false;
for (const name of options.customNodes) {
if (
elem.getAttribute("data-type") === name ||
elem.classList.contains(`node-${name}`)
) {
return true;
}
}
return false;
}
function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos);
if ($pos.depth > 1) return $pos.before($pos.depth);
@@ -137,7 +176,6 @@ export function DragHandlePlugin(
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
else {
const nodeSelection = NodeSelection.create(
@@ -166,14 +204,46 @@ export function DragHandlePlugin(
} else {
selection = NodeSelection.create(view.state.doc, draggedNodePos);
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
// if table row is selected, go to the parent node to select the whole node
if (
(selection as NodeSelection).node.type.isInline ||
(selection as NodeSelection).node.type.name === "tableRow"
) {
let $pos = view.state.doc.resolve(selection.from);
selection = NodeSelection.create(view.state.doc, $pos.before());
const $sel = view.state.doc.resolve(selection.from);
if (isCustomNodeDOM(node, options)) {
// The drag landed on a custom-node container (transclusion etc.).
// Walk up to the matching node so the drag moves the whole
// container, not whatever inner element the click landed on.
const customTypes = new Set(options.customNodes);
for (let d = $sel.depth; d > 0; d--) {
if (customTypes.has($sel.node(d).type.name)) {
selection = NodeSelection.create(
view.state.doc,
$sel.before(d),
);
break;
}
}
} else {
// If the selected node lives inside a table (at any nesting
// depth), promote to the whole table — the global drag handle is
// meant to move the table as a single block, not a row/cell. The
// earlier tableRow-only check only worked when the table sat at
// the doc root; once wrapped in another node (toggle, layout,
// etc.) the selection lands on a cell/paragraph and that check
// never fired.
let tableDepth = -1;
for (let d = $sel.depth; d > 0; d--) {
if ($sel.node(d).type.name === "table") {
tableDepth = d;
break;
}
}
if (tableDepth > 0) {
selection = NodeSelection.create(
view.state.doc,
$sel.before(tableDepth),
);
} else if ((selection as NodeSelection).node.type.isInline) {
// Inline node (e.g. mention): walk up to the parent block.
selection = NodeSelection.create(view.state.doc, $sel.before());
}
}
}
view.dispatch(view.state.tr.setSelection(selection));
@@ -313,6 +383,27 @@ export function DragHandlePlugin(
return;
}
const isCustomNode = isCustomNodeDOM(node, options);
// Custom nodes pin the handle to the inner NodeViewWrapper's top-left:
// the natural anchor sits in transient/empty space outside the visible block.
if (isCustomNode) {
// tiptap React node-views emit an outer `.react-renderer` whose first
// child is the visible NodeViewWrapper; walk to that outer first since
// `node` may be either the outer or an inner element with data-type.
const rendererOuter =
(node.closest(".react-renderer") as HTMLElement | null) ?? node;
const inner =
(rendererOuter.firstElementChild as HTMLElement | null) ??
rendererOuter;
const innerRect = absoluteRect(inner);
if (!dragHandleElement) return;
dragHandleElement.style.left = `${innerRect.left + 4}px`;
dragHandleElement.style.top = `${innerRect.top + 4}px`;
showDragHandle();
return;
}
const compStyle = window.getComputedStyle(node);
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
const lineHeight = isNaN(parsedLineHeight)
@@ -328,6 +419,13 @@ export function DragHandlePlugin(
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= options.dragHandleWidth;
}
// Tables: clear the table's own row-drag handle so the two
// grips don't stack on each other. `nodeDOMAtCoords` returns
// the wrapper for top-level hovers (wrapper is direct child of
// .ProseMirror) and a descendant for deeper hovers — cover both.
if (node.closest(".tableWrapper")) {
rect.left -= options.dragHandleWidth;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
@@ -42,9 +42,13 @@ import {
Excalidraw,
Embed,
TiptapPdf,
PageBreak,
SearchAndReplace,
Mention,
TableDndExtension,
TableHandleCommandsExtension,
TableHeaderPin,
TableReadonlySort,
Subpages,
Heading,
Highlight,
@@ -56,6 +60,7 @@ import {
Status,
TransclusionSource,
TransclusionReference,
TableView,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -80,7 +85,7 @@ import AudioView from "@/features/editor/components/audio/audio-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
@@ -259,11 +264,16 @@ export const mainExtensions = [
resizable: true,
lastColumnResizable: true,
allowTableNodeSelection: true,
cellMinWidth: 49,
View: TableView,
}),
TableRow,
TableCell,
TableHeader,
TableDndExtension,
TableHandleCommandsExtension,
TableHeaderPin,
TableReadonlySort,
MathInline.configure({
view: MathInlineView,
}),
@@ -357,6 +367,7 @@ export const mainExtensions = [
TiptapPdf.configure({
view: PdfView,
}),
PageBreak,
Subpages.configure({
view: SubpagesView,
}),
@@ -405,7 +416,9 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
"Video",
"File attachment",
"Draw.io (diagrams.net)",
"Excalidraw diagram",
"Excalidraw (Whiteboard)",
"Audio",
"Synced block"
]);
const TemplateSlashCommand = Command.configure({
+30 -11
View File
@@ -1,5 +1,5 @@
import classes from "@/features/editor/styles/editor.module.css";
import React from "react";
import React, { useEffect } from "react";
import { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from "@/features/editor/page-editor";
import {
@@ -23,17 +23,25 @@ import { IContributor } from "@/features/page/types/page.types.ts";
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
import clsx from "clsx";
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
const MemoizedFixedToolbar = React.memo(FixedToolbar);
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
type PageCreator = {
type PageUser = {
id: string;
name: string;
avatarUrl: string;
};
// Module-level flag: survives component unmount/remount on page navigation,
// reset only on full page reload (i.e. a new app session).
let defaultEditModeApplied = false;
export interface FullEditorProps {
pageId: string;
slugId: string;
@@ -41,7 +49,7 @@ export interface FullEditorProps {
content: string;
spaceSlug: string;
editable: boolean;
creator?: PageCreator;
creator?: PageUser;
contributors?: IContributor[];
canComment?: boolean;
}
@@ -61,9 +69,21 @@ export function FullEditor({
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false;
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
currentPageEditModeAtom,
);
const userPageEditMode =
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const isEditMode = userPageEditMode === PageEditMode.Edit;
const isEditMode = currentPageEditMode === PageEditMode.Edit;
// Apply the user's saved preference only once on initial load, not on every
// page navigation — so the mode sticks across navigations within a session.
useEffect(() => {
if (!defaultEditModeApplied) {
setCurrentPageEditMode(userPageEditMode as PageEditMode);
defaultEditModeApplied = true;
}
}, [userPageEditMode, setCurrentPageEditMode]);
return (
<Container
@@ -71,7 +91,10 @@ export function FullEditor({
size={!fullPageWidth && 900}
className={classes.editor}
>
{editorToolbarEnabled && editable && isEditMode && <FixedToolbar />}
{editorToolbarEnabled && editable && isEditMode && (
<MemoizedFixedToolbar />
)}
<MemoizedDeletedPageBanner slugId={slugId} />
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
@@ -95,16 +118,12 @@ export function FullEditor({
}
type PageBylineProps = {
creator?: PageCreator;
creator?: PageUser;
contributors?: IContributor[];
readOnly?: boolean;
};
function PageByline({
creator,
contributors,
readOnly,
}: PageBylineProps) {
function PageByline({ creator, contributors, readOnly }: PageBylineProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
@@ -26,10 +26,11 @@ import {
collabExtensions,
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
@@ -44,6 +45,7 @@ import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubbl
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import { TableHandlesLayer } from "@/features/editor/components/table/handle/table-handles-layer";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
@@ -53,7 +55,7 @@ import {
handleFileDrop,
handlePaste,
} from "@/features/editor/components/common/editor-paste-handler.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
@@ -111,8 +113,7 @@ export default function PageEditor({
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
@@ -372,19 +373,9 @@ export default function PageEditor({
return () => clearTimeout(timeout);
}, [yjsConnectionStatus, isSynced]);
useEffect(() => {
// Only honor user default page edit mode preference and permissions
if (editor) {
if (userPageEditMode && editable) {
if (userPageEditMode === PageEditMode.Edit) {
editor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
editor.setEditable(false);
}
} else {
editor.setEditable(false);
}
}
}, [userPageEditMode, editor, editable]);
if (!editor) return;
editor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
}, [currentPageEditMode, editor, editable]);
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
@@ -424,7 +415,7 @@ export default function PageEditor({
<EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
<TableHandlesLayer editor={editor} />
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<PdfMenu editor={editor} />
@@ -203,7 +203,8 @@
}
}
.resize-cursor {
&.resize-cursor,
&.resize-cursor * {
cursor: ew-resize;
cursor: col-resize;
}
@@ -9,6 +9,7 @@
@import "./media.css";
@import "./code.css";
@import "./print.css";
@import "./page-break.css";
@import "./find.css";
@import "./mention.css";
@import "./ordered-list.css";
@@ -0,0 +1,50 @@
.ProseMirror .page-break {
position: relative;
margin: 1.5rem 0;
border-top: 1px dashed var(--mantine-color-default-border);
height: 0;
user-select: none;
}
.ProseMirror[contenteditable="false"] .page-break {
margin: 0;
border: none;
height: 0;
}
.ProseMirror[contenteditable="false"] .page-break::after {
content: none;
}
.ProseMirror .page-break::after {
content: "Page break";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 0 0.5rem;
background: var(--mantine-color-body);
color: var(--mantine-color-dimmed);
font-size: 0.75rem;
line-height: 1;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.ProseMirror .page-break.ProseMirror-selectednode {
border-top-color: var(--mantine-primary-color-filled);
}
@media print {
.ProseMirror .page-break {
break-before: always;
page-break-before: always;
visibility: hidden;
border: none;
margin: 0;
}
.ProseMirror .page-break::after {
content: none;
}
}
@@ -15,7 +15,8 @@
}
.table-dnd-drop-indicator {
background-color: #adf;
background-color: var(--mantine-color-blue-5);
z-index: 3;
}
.ProseMirror {
@@ -57,13 +58,14 @@
}
.column-resize-handle {
background-color: #adf;
background-color: var(--mantine-color-blue-5);
bottom: -1px;
position: absolute;
right: -2px;
right: -1px;
pointer-events: none;
top: 0;
width: 4px;
width: 2px;
z-index: 3;
}
.selectedCell:after {
@@ -129,6 +131,139 @@
}
}
/* Header-row pinning. Two CSS paths, picked by `header-pin/controller.ts`:
- native sticky (preferred): wrapper drops its overflow constraint so
`position: sticky` on the row can resolve against the document scroll.
- transform fallback: wrapper keeps `overflow-x: auto` for horizontal
scrolling; the row is positioned imperatively per scroll frame.
`--editor-pin-offset` is published to :root by `pinOffsetWatcher` in
`header-pin/offset.ts`, measured against the lowest fixed surface above
the editor (app shell header, page header, fixed toolbar). */
.tableWrapper.tableWrapperNoOverflow,
.tableWrapper.tableWrapperNoOverflow table {
overflow: visible;
}
.tableWrapper.tableHeaderPinned table tr:first-child {
z-index: 2;
}
.tableWrapper.tableWrapperNoOverflow.tableHeaderPinned table tr:first-child {
position: sticky;
top: var(--editor-pin-offset, 90px);
}
.tableWrapper.tableHeaderPinned:not(.tableWrapperNoOverflow) table tr:first-child {
position: relative;
transform: translateY(var(--table-pin-offset, 0px));
}
@media print {
.tableWrapper.tableHeaderPinned table tr:first-child {
position: static;
transform: none;
}
}
.tableReadonlySortChevron {
/* Anchor to the cell's right edge, vertically centered with the cell
content. The cell content (a <p>) is block-level so an inline chevron
would wrap to a new line; absolute positioning takes it out of flow. */
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
background: light-dark(
rgba(55, 53, 47, 0.08),
rgba(255, 255, 255, 0.08)
);
color: light-dark(
rgba(55, 53, 47, 0.55),
rgba(255, 255, 255, 0.55)
);
user-select: none;
cursor: pointer;
z-index: 1;
/* Hidden by default; revealed on header-cell hover or when this column is
the active sort (see selectors below). */
opacity: 0;
transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
}
.ProseMirror table th:hover .tableReadonlySortChevron,
.tableReadonlySortChevron[data-sort] {
opacity: 1;
}
.ProseMirror table th:has(.tableReadonlySortChevron) {
padding-right: 30px;
}
.tableReadonlySortChevron:hover {
background: light-dark(
rgba(55, 53, 47, 0.16),
rgba(255, 255, 255, 0.16)
);
}
/* Immediate tooltip on the chevron — same style language as the rest of the
app (small, dark, rounded), unlike the native `title` tooltip which only
appears after a long delay. */
.tableReadonlySortChevron::after {
content: attr(data-tooltip);
position: absolute;
/* Below the chevron — placing it above the cell hits the table's
overflow clipping (the wrapper has `overflow-x: auto` which forces
`overflow-y: auto` per spec). */
top: calc(100% + 6px);
right: 0;
padding: 4px 8px;
border-radius: 4px;
background: var(--mantine-color-dark-7);
color: var(--mantine-color-white);
font-size: 12px;
font-weight: 400;
line-height: 1.4;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
z-index: 10;
}
.tableReadonlySortChevron:hover::after {
opacity: 1;
}
.tableReadonlySortChevron svg {
display: block;
}
.tableReadonlySortChevron[data-sort="asc"],
.tableReadonlySortChevron[data-sort="desc"] {
background: light-dark(
var(--mantine-color-blue-1),
var(--mantine-color-blue-9)
);
color: light-dark(
var(--mantine-color-blue-7),
var(--mantine-color-blue-2)
);
}
.tableReadonlySortChevron[data-sort="asc"] svg {
transform: rotate(180deg);
}
.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
.prosemirror-dropcursor-block {
display: none;
@@ -7,6 +7,7 @@ import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtomValue } from "jotai";
import {
currentPageEditModeAtom,
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
@@ -24,7 +25,6 @@ import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts";
import { platformModifierKey } from "@/lib";
@@ -52,9 +52,7 @@ export function TitleEditor({
const emit = useQueryEmit();
const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const [currentUser] = useAtom(currentUserAtom);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const titleEditor = useEditor({
extensions: [
@@ -172,18 +170,9 @@ export function TitleEditor({
}, [pageId]);
useEffect(() => {
if (titleEditor) {
if (userPageEditMode && editable) {
if (userPageEditMode === PageEditMode.Edit) {
titleEditor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
titleEditor.setEditable(false);
}
} else {
titleEditor.setEditable(false);
}
}
}, [userPageEditMode, titleEditor, editable]);
if (!titleEditor) return;
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
}, [currentPageEditMode, titleEditor, editable]);
const openSearchDialog = () => {
const event = new CustomEvent("openFindDialogFromEditor", {});
@@ -0,0 +1,51 @@
import { Link } from "react-router-dom";
import { useComputedColorScheme } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { ILabel } from "@/features/label/types/label.types.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
import classes from "@/features/label/label.module.css";
type LabelChipProps = {
label: Pick<ILabel, "id" | "name">;
onRemove?: () => void;
asLink?: boolean;
};
export function LabelChip({ label, onRemove, asLink }: LabelChipProps) {
const { t } = useTranslation();
const scheme = useComputedColorScheme("light");
const c = getLabelColor(label.name, scheme);
const nameNode = asLink ? (
<Link
to={`/labels/${encodeURIComponent(label.name)}`}
className={classes.chipLink}
onClick={(e) => e.stopPropagation()}
>
<span className={classes.chipName}>{label.name}</span>
</Link>
) : (
<span className={classes.chipName}>{label.name}</span>
);
return (
<span className={classes.chip} style={{ background: c.bg, color: c.fg }}>
{nameNode}
{onRemove && (
<button
type="button"
className={classes.chipX}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
aria-label={t("Remove label {{name}}", { name: label.name })}
>
<IconX size={12} stroke={2} />
</button>
)}
</span>
);
}
@@ -0,0 +1,29 @@
import { Skeleton } from "@mantine/core";
import classes from "@/features/label/label.module.css";
type LabelPageRowSkeletonProps = {
titleWidth?: number;
metaWidth?: number;
};
export function LabelPageRowSkeleton({
titleWidth = 220,
metaWidth = 180,
}: LabelPageRowSkeletonProps) {
return (
<div className={classes.row} aria-hidden="true">
<div className={classes.rowMain}>
<div className={classes.rowIcon}>
<Skeleton height={18} width={18} radius="sm" />
</div>
<div className={classes.rowBody}>
<Skeleton height={15} width={titleWidth} radius="xs" />
<div className={classes.rowMeta}>
<Skeleton height={18} width={18} radius="sm" />
<Skeleton height={12} width={metaWidth} radius="xs" />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,91 @@
import { Link } from "react-router-dom";
import { ThemeIcon, Tooltip } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { ILabelPageItem } from "@/features/label/types/label.types.ts";
import { LabelChip } from "@/features/label/components/label-chip.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { buildPageUrl } from "@/features/page/page.utils";
import { formatLabelListDate } from "@/features/label/utils/format-label-date.ts";
import classes from "@/features/label/label.module.css";
type LabelPageRowProps = {
page: ILabelPageItem;
currentLabelName: string;
};
const MAX_VISIBLE_CHIPS = 3;
export function LabelPageRow({ page, currentLabelName }: LabelPageRowProps) {
const { t } = useTranslation();
const otherLabels = page.labels.filter((l) => l.name !== currentLabelName);
const visibleLabels = otherLabels.slice(0, MAX_VISIBLE_CHIPS);
const hiddenLabels = otherLabels.slice(MAX_VISIBLE_CHIPS);
return (
<Link
to={buildPageUrl(page.space?.slug, page.slugId, page.title ?? undefined)}
className={classes.row}
>
<div className={classes.rowMain}>
<div className={classes.rowIcon}>
{page.icon ? (
<span style={{ fontSize: 16, lineHeight: 1 }}>{page.icon}</span>
) : (
<ThemeIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
</ThemeIcon>
)}
</div>
<div className={classes.rowBody}>
<div className={classes.rowTitle}>
{page.title || t("Untitled")}
</div>
<div className={classes.rowMeta}>
{page.space && (
<>
<CustomAvatar
name={page.space.name}
avatarUrl={page.space.logo ?? undefined}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={18}
/>
<span>{page.space.name}</span>
<span className={classes.metaDot} aria-hidden="true">
</span>
</>
)}
<span className={classes.rowDate}>
{t("Updated {{date}}", {
date: formatLabelListDate(new Date(page.updatedAt)),
})}
</span>
</div>
{/* {otherLabels.length > 0 && (
<div className={classes.rowChips}>
{visibleLabels.map((label) => (
<LabelChip key={label.id} label={label} asLink />
))}
{hiddenLabels.length > 0 && (
<Tooltip
label={hiddenLabels.map((l) => l.name).join(", ")}
withArrow
openDelay={200}
>
<span className={classes.chipMore}>
+{hiddenLabels.length}
</span>
</Tooltip>
)}
</div>
)} */}
</div>
</div>
</Link>
);
}
@@ -0,0 +1,160 @@
import { useMemo, useRef, useState, KeyboardEvent } from "react";
import clsx from "clsx";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useComputedColorScheme } from "@mantine/core";
import { ILabel } from "@/features/label/types/label.types.ts";
import { useWorkspaceLabelsQuery } from "@/features/label/queries/label-query.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
import classes from "@/features/label/label.module.css";
type LabelPickerProps = {
applied: ILabel[];
enabled: boolean;
onAdd: (name: string) => void;
onClose: () => void;
};
const NAME_PATTERN = /^[a-z0-9_-][a-z0-9_~-]*$/;
const MAX_LABEL_NAME_LENGTH = 100;
function isValidLabelName(name: string): boolean {
return (
name.length > 0 &&
name.length <= MAX_LABEL_NAME_LENGTH &&
NAME_PATTERN.test(name)
);
}
export function LabelPicker({
applied,
enabled,
onAdd,
onClose,
}: LabelPickerProps) {
const { t } = useTranslation();
const scheme = useComputedColorScheme("light");
const [query, setQuery] = useState("");
const [hover, setHover] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const normalized = normalizeLabelName(query);
const { data } = useWorkspaceLabelsQuery(normalized, enabled);
const appliedNames = useMemo(
() => new Set(applied.map((l) => l.name.toLowerCase())),
[applied],
);
const suggestions = useMemo(() => {
const items = data?.items ?? [];
return items.filter((l) => !appliedNames.has(l.name.toLowerCase()));
}, [data, appliedNames]);
const exact = suggestions.find((l) => l.name === normalized);
const canCreate =
!exact && !appliedNames.has(normalized) && isValidLabelName(normalized);
const total = suggestions.length + (canCreate ? 1 : 0);
const select = (idx: number) => {
if (idx < suggestions.length) {
onAdd(suggestions[idx].name);
} else if (canCreate) {
onAdd(normalized);
}
setQuery("");
setHover(0);
inputRef.current?.focus();
};
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setHover((h) => Math.min(Math.max(total - 1, 0), h + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHover((h) => Math.max(0, h - 1));
} else if (e.key === "Enter") {
e.preventDefault();
if (total === 0) return;
select(hover);
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
return (
<div className={classes.popover}>
<div className={classes.popoverSearch}>
<input
ref={inputRef}
type="text"
autoFocus
maxLength={MAX_LABEL_NAME_LENGTH}
placeholder={t("Search or create…")}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setHover(0);
}}
onKeyDown={onKey}
/>
</div>
<div className={classes.popoverList}>
{total === 0 && (
<div className={classes.popoverEmpty}>
{normalized.length === 0
? t("No labels yet")
: appliedNames.has(normalized)
? t("Already added")
: !isValidLabelName(normalized)
? t("Invalid label name")
: t("No matches")}
</div>
)}
{suggestions.map((s, i) => {
const c = getLabelColor(s.name, scheme);
return (
<button
key={s.id}
type="button"
className={clsx(
classes.popoverItem,
hover === i && classes.popoverItemHover,
)}
onMouseEnter={() => setHover(i)}
onClick={() => select(i)}
>
<span
className={classes.popoverItemDot}
style={{ background: c.dot }}
/>
<span className={classes.popoverItemName}>{s.name}</span>
</button>
);
})}
{canCreate && (
<button
type="button"
className={clsx(
classes.popoverItem,
hover === suggestions.length && classes.popoverItemHover,
)}
onMouseEnter={() => setHover(suggestions.length)}
onClick={() => select(suggestions.length)}
>
<span className={classes.popoverCreatePlus}>
<IconPlus size={12} stroke={2} />
</span>
<span className={classes.popoverItemName}>
{t("Create")} <b>"{normalized}"</b>
</span>
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import { useState } from "react";
import clsx from "clsx";
import { Divider, Popover, Stack, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { LabelChip } from "@/features/label/components/label-chip.tsx";
import { LabelPicker } from "@/features/label/components/label-picker.tsx";
import {
useAddLabelsMutation,
usePageLabelsQuery,
useRemoveLabelMutation,
} from "@/features/label/queries/label-query.ts";
import classes from "@/features/label/label.module.css";
type LabelsSectionProps = {
pageId: string;
canEdit: boolean;
};
export function LabelsSection({ pageId, canEdit }: LabelsSectionProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { data } = usePageLabelsQuery(pageId);
const addMutation = useAddLabelsMutation(pageId);
const removeMutation = useRemoveLabelMutation(pageId);
const labels = data?.items ?? [];
if (!canEdit && labels.length === 0) {
return null;
}
const handleAdd = (name: string) => {
addMutation.mutate({ pageId, names: [name] });
};
const handleRemove = (labelId: string) => {
removeMutation.mutate({ pageId, labelId });
};
return (
<>
<Divider />
<Stack gap="xs">
<Text size="xs" fw={500} c="dimmed">
{t("Labels")}
</Text>
<div className={classes.labelsWrap}>
{labels.map((label) => (
<LabelChip
key={label.id}
label={label}
asLink
onRemove={canEdit ? () => handleRemove(label.id) : undefined}
/>
))}
{canEdit && (
<Popover
opened={open}
onChange={setOpen}
position="bottom-end"
shadow="lg"
withinPortal
offset={6}
>
<Popover.Target>
<button
type="button"
className={clsx(classes.addBtn, open && classes.addBtnOpen)}
onClick={() => setOpen((v) => !v)}
>
<IconPlus size={12} stroke={2} />
<span>
{labels.length === 0 ? t("Add label") : t("Add")}
</span>
</button>
</Popover.Target>
<Popover.Dropdown p={0} className={classes.popover}>
<LabelPicker
applied={labels}
enabled={open}
onAdd={(name) => handleAdd(name)}
onClose={() => setOpen(false)}
/>
</Popover.Dropdown>
</Popover>
)}
</div>
</Stack>
</>
);
}
@@ -0,0 +1,325 @@
.labelsWrap {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 4px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 8px;
border-radius: 4px;
font-size: 12.5px;
font-weight: 500;
line-height: 1;
user-select: none;
white-space: nowrap;
}
.chipName {
letter-spacing: 0.005em;
}
.chipX {
appearance: none;
border: 0;
background: transparent;
color: currentColor;
width: 18px;
height: 18px;
border-radius: 4px;
margin-right: -4px;
margin-left: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
opacity: 0.6;
}
.chipX:hover {
opacity: 1;
background: light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.12));
}
.addBtn {
appearance: none;
border: 1px dashed
light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
background: transparent;
color: var(--mantine-color-dimmed);
height: 24px;
padding: 0 8px;
border-radius: 4px;
font: inherit;
font-size: 12.5px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
transition:
background 100ms ease,
border-color 100ms ease,
color 100ms ease;
}
.addBtn:hover {
background: light-dark(rgba(0, 0, 0, 0.03), rgba(255, 255, 255, 0.04));
color: var(--mantine-color-text);
border-color: light-dark(
var(--mantine-color-gray-5),
var(--mantine-color-dark-2)
);
}
.addBtnOpen {
background: var(--mantine-color-body);
border-style: solid;
border-color: light-dark(
var(--mantine-color-gray-5),
var(--mantine-color-dark-2)
);
color: var(--mantine-color-text);
}
.popover {
width: 240px;
padding: 0;
overflow: hidden;
}
.popoverSearch {
padding: 8px 8px 4px;
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.popoverSearch input {
width: 100%;
border: 0;
background: transparent;
font: inherit;
font-size: 13px;
padding: 4px 4px;
color: var(--mantine-color-text);
outline: none;
}
.popoverSearch input::placeholder {
color: var(--mantine-color-placeholder);
}
.popoverList {
max-height: 240px;
overflow-y: auto;
padding: 4px;
}
.popoverEmpty {
padding: 12px 8px;
color: var(--mantine-color-dimmed);
font-size: 12.5px;
text-align: center;
}
.popoverItem {
appearance: none;
width: 100%;
border: 0;
background: transparent;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
font: inherit;
font-size: 13px;
color: var(--mantine-color-text);
cursor: pointer;
text-align: left;
}
.popoverItemHover {
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
}
.popoverItemDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.popoverItemName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.popoverCreatePlus {
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mantine-color-dimmed);
}
.headerChip {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 14px;
border-radius: 8px;
font-size: 22px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.005em;
text-decoration: none;
user-select: none;
transition: filter 100ms ease;
}
.headerChip:hover {
filter: brightness(0.97);
}
.headerDot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 14px 12px;
margin: 0 -12px;
border-radius: 8px;
text-decoration: none;
color: inherit;
cursor: pointer;
transition: background-color 80ms ease;
}
.row + .row {
border-top: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.row:hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
.row:hover + .row,
.row:has(+ .row:hover) {
border-top-color: transparent;
}
.rowMain {
display: flex;
gap: 12px;
min-width: 0;
flex: 1;
}
.rowIcon {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--mantine-color-dimmed);
margin-top: 2px;
}
.rowBody {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.rowTitle {
font-size: 15px;
font-weight: 500;
color: var(--mantine-color-text);
line-height: 1.3;
word-break: break-word;
}
.rowChips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chipMore {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
border-radius: 4px;
font-size: 12.5px;
font-weight: 500;
line-height: 1;
color: var(--mantine-color-dimmed);
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
user-select: none;
white-space: nowrap;
}
.rowMeta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
color: var(--mantine-color-dimmed);
font-size: 13px;
}
.rowDate {
color: var(--mantine-color-dimmed);
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.metaDot {
font-size: 14px;
line-height: 1;
color: var(--mantine-color-dimmed);
}
.chipLink {
text-decoration: none;
color: inherit;
display: inline-flex;
}
.chipLink:hover {
filter: brightness(0.97);
}
@@ -0,0 +1,157 @@
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
addLabelsToPage,
findPagesByLabel,
getLabelInfo,
getPageLabels,
getWorkspaceLabels,
removeLabelFromPage,
} from "@/features/label/services/label-service.ts";
import {
IAddLabels,
ILabel,
IRemoveLabel,
} from "@/features/label/types/label.types.ts";
import { IPagination } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
const PAGE_LABELS_KEY = (pageId: string) => ["page-labels", pageId];
const WORKSPACE_LABELS_KEY = (query?: string) => ["workspace-labels", query ?? ""];
export function usePageLabelsQuery(pageId: string | undefined) {
return useQuery({
queryKey: PAGE_LABELS_KEY(pageId ?? ""),
queryFn: () => getPageLabels({ pageId: pageId as string, limit: 100 }),
enabled: !!pageId,
});
}
export function useWorkspaceLabelsQuery(query: string, enabled: boolean) {
return useQuery({
queryKey: WORKSPACE_LABELS_KEY(query),
queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }),
enabled,
staleTime: 30 * 1000,
});
}
export function useAddLabelsMutation(pageId: string | undefined) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<ILabel[], Error, IAddLabels>({
mutationFn: (data) => addLabelsToPage(data),
onSuccess: (added) => {
queryClient.setQueryData<IPagination<ILabel>>(
PAGE_LABELS_KEY(pageId ?? ""),
(cache) => {
if (!cache) return cache;
const existing = new Set(cache.items.map((l) => l.id));
const additions = added.filter((l) => !existing.has(l.id));
if (additions.length === 0) return cache;
return { ...cache, items: [...cache.items, ...additions] };
},
);
queryClient.setQueriesData<IPagination<ILabel>>(
{ queryKey: ["workspace-labels"] },
(cache) => {
if (!cache) return cache;
const existing = new Set(cache.items.map((l) => l.id));
const additions = added.filter((l) => !existing.has(l.id));
if (additions.length === 0) return cache;
return {
...cache,
items: [...cache.items, ...additions].sort((a, b) =>
a.name.localeCompare(b.name),
),
};
},
);
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
queryClient.invalidateQueries({ queryKey: ["label-info"] });
},
onError: (error: any) => {
notifications.show({
message: error?.response?.data?.message ?? t("Failed to add label"),
color: "red",
});
},
});
}
export function useRemoveLabelMutation(pageId: string | undefined) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IRemoveLabel>({
mutationFn: (data) => removeLabelFromPage(data),
onSuccess: (_data, variables) => {
const cache = queryClient.getQueryData<IPagination<ILabel>>(
PAGE_LABELS_KEY(pageId ?? ""),
);
if (cache) {
queryClient.setQueryData<IPagination<ILabel>>(
PAGE_LABELS_KEY(pageId ?? ""),
{
...cache,
items: cache.items.filter((l) => l.id !== variables.labelId),
},
);
}
queryClient.invalidateQueries({ queryKey: ["workspace-labels"] });
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
queryClient.invalidateQueries({ queryKey: ["label-info"] });
},
onError: () => {
notifications.show({
message: t("Failed to remove label"),
color: "red",
});
},
});
}
export function useLabelInfoQuery(name: string, spaceId?: string) {
return useQuery({
queryKey: ["label-info", name, spaceId ?? ""],
queryFn: () => getLabelInfo({ name, type: "page", spaceId }),
enabled: !!name,
placeholderData: keepPreviousData,
});
}
const LABEL_PAGES_LIMIT = 25;
export function useLabelPagesQuery(
name: string,
query: string,
spaceId?: string,
) {
return useInfiniteQuery({
queryKey: ["label-pages", name, query, spaceId ?? ""],
queryFn: ({ pageParam }) =>
findPagesByLabel({
name,
query,
spaceId,
cursor: pageParam,
limit: LABEL_PAGES_LIMIT,
}),
enabled: !!name,
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage
? (lastPage.meta.nextCursor ?? undefined)
: undefined,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,55 @@
import api from "@/lib/api-client";
import { IPagination } from "@/lib/types.ts";
import {
IAddLabels,
IFindPagesByLabelParams,
ILabel,
ILabelInfo,
ILabelInfoParams,
ILabelPageItem,
IListLabelsParams,
IPageLabelsParams,
IRemoveLabel,
} from "@/features/label/types/label.types.ts";
export async function getPageLabels(
params: IPageLabelsParams,
): Promise<IPagination<ILabel>> {
const req = await api.post<IPagination<ILabel>>("/pages/labels", params);
return req.data;
}
export async function getWorkspaceLabels(
params: IListLabelsParams,
): Promise<IPagination<ILabel>> {
const req = await api.post<IPagination<ILabel>>("/labels", params);
return req.data;
}
export async function addLabelsToPage(
data: IAddLabels,
): Promise<ILabel[]> {
const req = await api.post<ILabel[]>("/pages/labels/add", data);
return req.data;
}
export async function removeLabelFromPage(data: IRemoveLabel): Promise<void> {
await api.post("/pages/labels/remove", data);
}
export async function getLabelInfo(
params: ILabelInfoParams,
): Promise<ILabelInfo> {
const req = await api.post<ILabelInfo>("/labels/info", params);
return req.data;
}
export async function findPagesByLabel(
params: IFindPagesByLabelParams,
): Promise<IPagination<ILabelPageItem>> {
const req = await api.post<IPagination<ILabelPageItem>>(
"/labels/pages",
params,
);
return req.data;
}
@@ -0,0 +1,70 @@
import { QueryParams } from "@/lib/types.ts";
export type LabelType = "page" | "space";
export interface ILabel {
id: string;
name: string;
type: LabelType;
workspaceId: string;
createdAt: string;
updatedAt: string;
}
export interface IAddLabels {
pageId: string;
names: string[];
}
export interface IRemoveLabel {
pageId: string;
labelId: string;
}
export interface IPageLabelsParams {
pageId: string;
cursor?: string;
limit?: number;
}
export interface IListLabelsParams {
type: LabelType;
query?: string;
cursor?: string;
limit?: number;
}
export interface ILabelInfo {
name: string;
usageCount: number;
}
export interface ILabelPageItem {
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
createdAt: string;
updatedAt: string;
space: {
id: string;
name: string;
slug: string;
logo: string | null;
} | null;
creator: { id: string; name: string; avatarUrl: string | null } | null;
labels: { id: string; name: string }[];
}
export interface IFindPagesByLabelParams extends QueryParams {
labelId?: string;
name?: string;
spaceId?: string;
}
export interface ILabelInfoParams {
name: string;
type: LabelType;
spaceId?: string;
}
@@ -0,0 +1,15 @@
import { format, isThisYear, isToday, isYesterday } from "date-fns";
import i18n from "@/i18n.ts";
export function formatLabelListDate(date: Date): string {
if (isToday(date)) {
return i18n.t("Today, {{time}}", { time: format(date, "h:mma") });
}
if (isYesterday(date)) {
return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") });
}
if (isThisYear(date)) {
return format(date, "MMM dd");
}
return format(date, "MMM dd, yyyy");
}
@@ -0,0 +1,55 @@
type LabelColor = {
bg: string;
fg: string;
dot: string;
};
const LABEL_PALETTE: Record<string, LabelColor> = {
slate: { bg: "#eef1f5", fg: "#3b475a", dot: "#6b7a90" },
blue: { bg: "#e6f0ff", fg: "#1e4fbf", dot: "#3b82f6" },
green: { bg: "#e3f5ea", fg: "#1f7a47", dot: "#22a05a" },
amber: { bg: "#fbf0d9", fg: "#8a5a00", dot: "#d99c1f" },
red: { bg: "#fde6e6", fg: "#a02b2b", dot: "#dc4a4a" },
purple: { bg: "#efe9fb", fg: "#5a3aa8", dot: "#8b6bd9" },
pink: { bg: "#fce6ee", fg: "#a8336d", dot: "#dc6699" },
teal: { bg: "#daf1ee", fg: "#1f6f6a", dot: "#2fa39a" },
};
const PALETTE_KEYS = Object.keys(LABEL_PALETTE);
const DARK_PALETTE: Record<string, LabelColor> = {
slate: { bg: "#2a3140", fg: "#c8d3e3", dot: "#7e8da8" },
blue: { bg: "#152a52", fg: "#a9c4ff", dot: "#5b9aff" },
green: { bg: "#143b27", fg: "#9ce3b8", dot: "#3ec97c" },
amber: { bg: "#3d2c0e", fg: "#f5cf85", dot: "#e6b34a" },
red: { bg: "#401a1a", fg: "#f1a8a8", dot: "#e26565" },
purple: { bg: "#2a1f4d", fg: "#c8b4f4", dot: "#a48ce6" },
pink: { bg: "#3c1a2a", fg: "#f3a9c9", dot: "#e07ab0" },
teal: { bg: "#103633", fg: "#92d5cf", dot: "#48b8af" },
};
function hashName(name: string): number {
// Per-char accumulation with 31. Note: 31 ≡ -1 (mod 8), so the low bits of
// this hash are highly correlated across short strings — `% 8` would cluster.
let h = 0;
for (let i = 0; i < name.length; i++) {
h = (Math.imul(h, 31) + name.charCodeAt(i)) | 0;
}
// Murmur3 fmix32 finalizer — avalanches high bits into low bits so the
// subsequent `% palette.length` (small power of two) is well-distributed.
h ^= h >>> 16;
h = Math.imul(h, 0x85ebca6b);
h ^= h >>> 13;
h = Math.imul(h, 0xc2b2ae35);
h ^= h >>> 16;
return h >>> 0;
}
export function getLabelColor(
name: string,
scheme: "light" | "dark" = "light",
): LabelColor {
const key = PALETTE_KEYS[hashName(name) % PALETTE_KEYS.length];
const palette = scheme === "dark" ? DARK_PALETTE : LABEL_PALETTE;
return palette[key];
}
@@ -0,0 +1,3 @@
export function normalizeLabelName(name: string): string {
return name.trim().replace(/\s+/g, "-").toLowerCase();
}
@@ -18,6 +18,7 @@ import { useBacklinksCountQuery } from "@/features/page-details/queries/backlink
import { BacklinksModal } from "./backlinks-modal";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { LabelsSection } from "@/features/label/components/labels-section.tsx";
export function PageDetailsAside() {
const { pageSlug } = useParams();
@@ -61,6 +62,11 @@ export function PageDetailsAside() {
isLoading={countsLoading}
onClick={openModal}
/>
<LabelsSection
pageId={page.id}
canEdit={page.permissions?.canEdit ?? false}
/>
</Stack>
<BacklinksModal
@@ -29,7 +29,7 @@ import { buildPageUrl } from "@/features/page/page.utils.ts";
import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import { Trans, useTranslation } from "react-i18next";
@@ -40,7 +40,7 @@ import {
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { formattedDate } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
@@ -65,6 +65,11 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
const { pageSlug } = useParams();
const { data: page } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
const isDeleted = !!page?.deletedAt;
useHotkeys(
[
@@ -87,11 +92,15 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
[],
);
if (isDeleted) {
return null;
}
return (
<>
<ConnectionWarning />
{!readOnly && <PageStateSegmentedControl size="xs" />}
{!readOnly && <PageEditModeToggle size="xs" />}
<PageShareModal readOnly={readOnly} />
@@ -134,7 +143,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
pageId: extractPageSlugId(pageSlug),
});
const { openDeleteModal } = useDeletePageModal();
const [tree] = useAtom(treeApiAtom);
const { handleDelete } = useTreeMutation(page?.spaceId ?? "");
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [
@@ -183,7 +192,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
};
const handleDeletePage = () => {
openDeleteModal({ onConfirm: () => tree?.delete(page.id) });
openDeleteModal({ onConfirm: () => handleDelete(page.id) });
};
const handleToggleFavorite = () => {
@@ -8,7 +8,7 @@ interface Props {
}
export default function PageHeader({ readOnly }: Props) {
return (
<div className={classes.header}>
<div className={classes.header} data-page-header="true">
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
<Breadcrumb />
@@ -0,0 +1,30 @@
import { modals } from "@mantine/modals";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
type UseRestoreModalProps = {
title?: string | null;
onConfirm: () => void;
};
export function useRestorePageModal() {
const { t } = useTranslation();
const openRestoreModal = ({ title, onConfirm }: UseRestoreModalProps) => {
modals.openConfirmModal({
title: t("Restore page"),
children: (
<Text size="sm">
{t("Restore '{{title}}' and its sub-pages?", {
title: title || t("Untitled"),
})}
</Text>
),
centered: true,
labels: { confirm: t("Restore"), cancel: t("Cancel") },
confirmProps: { color: "blue" },
onConfirm,
});
};
return { openRestoreModal } as const;
}
@@ -37,7 +37,7 @@ import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { SimpleTree } from "react-arborist";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
@@ -117,10 +117,20 @@ export function useUpdatePageMutation() {
}
export function useRemovePageMutation() {
const { t } = useTranslation();
return useMutation({
mutationFn: (pageId: string) => deletePage(pageId, false),
onSuccess: (_, pageId) => {
notifications.show({ message: "Page moved to trash" });
notifications.show({ message: t("Page moved to trash") });
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
if (cached) {
const stamped = { ...cached, deletedAt: new Date() };
queryClient.setQueryData(["pages", cached.id], stamped);
queryClient.setQueryData(["pages", cached.slugId], stamped);
}
invalidateOnDeletePage(pageId);
queryClient.invalidateQueries({
predicate: (item) =>
@@ -128,7 +138,7 @@ export function useRemovePageMutation() {
});
},
onError: (error) => {
notifications.show({ message: "Failed to delete page", color: "red" });
notifications.show({ message: t("Failed to delete page"), color: "red" });
},
});
}
@@ -162,19 +172,17 @@ export function useMovePageMutation() {
}
export function useRestorePageMutation() {
const { t } = useTranslation();
const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
return useMutation({
mutationFn: (pageId: string) => restorePage(pageId),
onSuccess: async (restoredPage) => {
notifications.show({ message: "Page restored successfully" });
// Add the restored page back to the tree
const treeApi = new SimpleTree<SpaceTreeNode>(treeData);
notifications.show({ message: t("Page restored successfully") });
// Check if the page already exists in the tree (it shouldn't)
if (!treeApi.find(restoredPage.id)) {
if (!treeModel.find(treeData, restoredPage.id)) {
// Create the tree node data with hasChildren from backend
const nodeData: SpaceTreeNode = {
id: restoredPage.id,
@@ -193,24 +201,17 @@ export function useRestorePageMutation() {
let index = 0;
if (parentId) {
const parentNode = treeApi.find(parentId);
const parentNode = treeModel.find(treeData, parentId);
if (parentNode) {
index = parentNode.children?.length || 0;
}
} else {
// Root level page
index = treeApi.data.length;
index = treeData.length;
}
// Add the node to the tree
treeApi.create({
parentId,
index,
data: nodeData,
});
// Update the tree data
setTreeData(treeApi.data);
setTreeData(treeModel.insert(treeData, parentId, nodeData, index));
// Emit websocket event to sync with other users
setTimeout(() => {
@@ -232,9 +233,16 @@ export function useRestorePageMutation() {
await queryClient.invalidateQueries({
queryKey: ["trash-list", restoredPage.spaceId],
});
// Merge — restore endpoint returns a skinny page;
// Replace would strip space/permissions/content and break the editor.
const merge = (cached: IPage | undefined) =>
cached ? { ...cached, ...restoredPage } : cached;
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge);
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
},
onError: (error) => {
notifications.show({ message: "Failed to restore page", color: "red" });
notifications.show({ message: t("Failed to restore page"), color: "red" });
},
});
}
@@ -0,0 +1,140 @@
import { ActionIcon, Button, Group, Paper, Text, Tooltip } from "@mantine/core";
import { IconRestore, IconTrash } from "@tabler/icons-react";
import { useNavigate } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import {
useDeletePageMutation,
usePageQuery,
useRestorePageMutation,
} from "@/features/page/queries/page-query.ts";
import { getSpaceUrl } from "@/lib/config.ts";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
type DeletedPageBannerProps = {
slugId: string;
};
export function DeletedPageBanner({ slugId }: DeletedPageBannerProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: page } = usePageQuery({ pageId: slugId });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const deletedTimeAgo = useTimeAgo(page?.deletedAt);
const restorePageMutation = useRestorePageMutation();
const deletePageMutation = useDeletePageMutation();
const { openRestoreModal } = useRestorePageModal();
const { openDeleteModal } = useDeletePageModal();
if (!page?.deletedAt) return null;
const canRestore = spaceAbility.can(
SpaceCaslAction.Edit,
SpaceCaslSubject.Page,
);
const canPermanentlyDelete = spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
);
const actorName = page.deletedBy?.name ?? t("Someone");
const handleRestore = () => {
openRestoreModal({
title: page.title,
onConfirm: () => restorePageMutation.mutate(page.id),
});
};
const handlePermanentDelete = () => {
openDeleteModal({
isPermanent: true,
onConfirm: async () => {
await deletePageMutation.mutateAsync(page.id);
navigate(getSpaceUrl(page.space?.slug));
},
});
};
const hasAnyAction = canRestore || canPermanentlyDelete;
return (
<Paper radius="sm" mb="md" px="md" py="xs" bg="red.0">
<Group justify="space-between" wrap="wrap" gap="sm">
<Text size="sm" style={{ flex: 1, minWidth: 0 }}>
<Trans
i18nKey="<b>{{name}}</b> moved this page to Trash {{time}}."
values={{ name: actorName, time: deletedTimeAgo }}
components={{ b: <Text span fw={600} inherit /> }}
/>
</Text>
{hasAnyAction && (
<>
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
{canRestore && (
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconRestore size={16} />}
onClick={handleRestore}
loading={restorePageMutation.isPending}
>
{t("Restore page")}
</Button>
)}
{canPermanentlyDelete && (
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconTrash size={16} />}
onClick={handlePermanentDelete}
loading={deletePageMutation.isPending}
>
{t("Permanently delete")}
</Button>
)}
</Group>
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
{canRestore && (
<Tooltip label={t("Restore page")} withArrow>
<ActionIcon
size="lg"
variant="default"
onClick={handleRestore}
loading={restorePageMutation.isPending}
aria-label={t("Restore page")}
>
<IconRestore size={18} />
</ActionIcon>
</Tooltip>
)}
{canPermanentlyDelete && (
<Tooltip label={t("Permanently delete")} withArrow>
<ActionIcon
size="lg"
variant="light"
color="red"
onClick={handlePermanentDelete}
loading={deletePageMutation.isPending}
aria-label={t("Permanently delete")}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
</>
)}
</Group>
</Paper>
);
}
@@ -0,0 +1,21 @@
import { Alert, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export function TrashBanner() {
const { t } = useTranslation();
const workspace = useAtomValue(workspaceAtom);
const retentionDays = workspace?.trashRetentionDays ?? 30;
return (
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
<Text size="sm" lh={1.35}>
{t("Pages in trash will be permanently deleted after {{count}} days.", {
count: retentionDays,
})}
</Text>
</Alert>
);
}
@@ -7,17 +7,16 @@ import {
Group,
ActionIcon,
Text,
Alert,
Stack,
Menu,
} from "@mantine/core";
import {
IconInfoCircle,
IconDots,
IconRestore,
IconTrash,
IconFileDescription,
} from "@tabler/icons-react";
import { TrashBanner } from "@/features/page/trash/components/trash-banner.tsx";
import {
useDeletedPagesQuery,
useRestorePageMutation,
@@ -31,12 +30,10 @@ import TrashPageContentModal from "@/features/page/trash/components/trash-page-c
import { UserInfo } from "@/components/common/user-info.tsx";
import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx";
export default function Trash() {
const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
const { spaceSlug } = useParams();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
@@ -45,6 +42,7 @@ export default function Trash() {
});
const restorePageMutation = useRestorePageMutation();
const deletePageMutation = useDeletePageMutation();
const { openRestoreModal } = useRestorePageModal();
const [selectedPage, setSelectedPage] = useState<{
title: string;
@@ -78,23 +76,6 @@ export default function Trash() {
});
};
const openRestoreModal = (pageId: string, pageTitle: string) => {
modals.openConfirmModal({
title: t("Restore page"),
children: (
<Text size="sm">
{t("Restore '{{title}}' and its sub-pages?", {
title: pageTitle || "Untitled",
})}
</Text>
),
centered: true,
labels: { confirm: t("Restore"), cancel: t("Cancel") },
confirmProps: { color: "blue" },
onConfirm: () => handleRestorePage(pageId),
});
};
const hasPages = deletedPages && deletedPages.items.length > 0;
const handlePageClick = (page: any) => {
@@ -109,11 +90,7 @@ export default function Trash() {
<Title order={2}>{t("Trash")}</Title>
</Group>
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
<Text size="sm">
{t("Pages in trash will be permanently deleted after {{count}} days.", { count: workspace?.trashRetentionDays ?? 30 })}
</Text>
</Alert>
<TrashBanner />
{isLoading || !deletedPages ? (
<></>
@@ -181,7 +158,10 @@ export default function Trash() {
<Menu.Item
leftSection={<IconRestore size={16} />}
onClick={() =>
openRestoreModal(page.id, page.title)
openRestoreModal({
title: page.title,
onConfirm: () => handleRestorePage(page.id),
})
}
>
{t("Restore")}
@@ -0,0 +1,5 @@
import { atom } from "jotai";
export type OpenMap = Record<string, boolean>;
export const openTreeNodesAtom = atom<OpenMap>({});

Some files were not shown because too many files have changed in this diff Show More