Compare commits

..

7 Commits

Author SHA1 Message Date
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
40 changed files with 757 additions and 195 deletions
@@ -71,6 +71,7 @@
"Export": "Export", "Export": "Export",
"Failed to create page": "Failed to create page", "Failed to create page": "Failed to create page",
"Failed to delete page": "Failed to delete 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 fetch recent pages": "Failed to fetch recent pages",
"Failed to import pages": "Failed to import pages", "Failed to import pages": "Failed to import pages",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.", "Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
@@ -581,6 +582,8 @@
"Move to trash": "Move to trash", "Move to trash": "Move to trash",
"Move this page to trash?": "Move this page to trash?", "Move this page to trash?": "Move this page to trash?",
"Restore page": "Restore page", "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 moved to trash": "Page moved to trash",
"Page restored successfully": "Page restored successfully", "Page restored successfully": "Page restored successfully",
"Deleted by": "Deleted by", "Deleted by": "Deleted by",
@@ -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 CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; 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"; import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
export default function Aside() { export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom); const [{ tab }, setAsideState] = useAtom(asideStateAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom); const pageEditor = useAtomValue(pageEditorAtom);
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
let title: string; let title: string;
let component: ReactNode; let component: ReactNode;
@@ -45,9 +47,19 @@ export default function Aside() {
{component && ( {component && (
<> <>
{tab !== "chat" && ( {tab !== "chat" && (
<Text mb="md" fw={500}> <Group justify="space-between" wrap="nowrap" mb="md">
{t(title)} <Text fw={500}>{t(title)}</Text>
</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" ? ( {tab === "comments" || tab === "chat" ? (
@@ -1,5 +1,6 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export const pageEditorAtom = atom<Editor | null>(null); export const pageEditorAtom = atom<Editor | null>(null);
@@ -12,3 +13,7 @@ export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false); export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = 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 { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -46,7 +47,7 @@ export function AudioMenu({ editor }: EditorMenuProps) {
); );
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "audio"; const predicate = (node: PMNode) => node.type.name === "audio";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
@@ -16,7 +16,7 @@ import {
IconMoodSmile, IconMoodSmile,
IconNotes, IconNotes,
} from "@tabler/icons-react"; } 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 { useTranslation } from "react-i18next";
import EmojiPicker from "@/components/ui/emoji-picker.tsx"; import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
@@ -55,7 +55,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
}); });
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout"; const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
@@ -19,7 +19,7 @@ import {
IconCopy, IconCopy,
IconTrash, IconTrash,
} from "@tabler/icons-react"; } 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 type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
@@ -82,7 +82,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const shouldShow = useCallback(
({ state }: ShouldShowProps) => { ({ state }: ShouldShowProps) => {
if (!state) return false; if (!state || !isEditorReady(editor)) return false;
if (!editor.isActive("columns")) return false; if (!editor.isActive("columns")) return false;
if (isTextSelected(editor)) return false; if (isTextSelected(editor)) return false;
if (nodesWithMenus.some((name) => editor.isActive(name))) return false; if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
@@ -121,7 +121,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
}); });
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "columns"; const predicate = (node: PMNode) => node.type.name === "columns";
const parent = findParentNode(predicate)(selection); 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 { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -81,7 +82,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
); );
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio"; const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
@@ -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 { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -94,7 +95,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
); );
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw"; const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection); 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>
);
}
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef } from "react"; import React, { useCallback, useRef } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -56,7 +57,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
); );
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image"; const predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection); 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 { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -37,9 +38,8 @@ export function PdfMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const shouldShow = useCallback(
({ state }: ShouldShowProps) => { ({ state }: ShouldShowProps) => {
if (!state || !editor.isActive("pdf")) { if (!state || !isEditorReady(editor)) return false;
return false; if (!editor.isActive("pdf")) return false;
}
const { selection } = state; const { selection } = state;
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null; const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
@@ -51,7 +51,7 @@ export function PdfMenu({ editor }: EditorMenuProps) {
); );
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "pdf"; const predicate = (node: PMNode) => node.type.name === "pdf";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
@@ -6,6 +6,7 @@ import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react"; import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { isEditorReady } from "@docmost/editor-ext";
interface SubpagesMenuProps { interface SubpagesMenuProps {
editor: Editor; editor: Editor;
@@ -33,6 +34,7 @@ export const SubpagesMenu = React.memo(
); );
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
if (!isEditorReady(editor)) return new DOMRect();
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "subpages"; const predicate = (node: PMNode) => node.type.name === "subpages";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
@@ -31,7 +31,12 @@ export const ColumnHandle = React.memo(function ColumnHandle({
// (the plugin re-emits `hoveringCell` with the mapped pos a tick later); // (the plugin re-emits `hoveringCell` with the mapped pos a tick later);
// unmounting the source element here would make pragmatic-dnd silently // unmounting the source element here would make pragmatic-dnd silently
// abort the active drag. // abort the active drag.
const lookupCellDom = editor.view.nodeDOM(anchorPos) as HTMLElement | null; // `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 [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom); const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
useEffect(() => { useEffect(() => {
@@ -29,7 +29,12 @@ export const RowHandle = React.memo(function RowHandle({
// See ColumnHandle for the rationale: keep the last valid cell DOM cached // See ColumnHandle for the rationale: keep the last valid cell DOM cached
// so the handle div stays mounted across stale-anchor renders, otherwise // so the handle div stays mounted across stale-anchor renders, otherwise
// pragmatic-dnd silently aborts an in-flight drag. // pragmatic-dnd silently aborts an in-flight drag.
const lookupCellDom = editor.view.nodeDOM(anchorPos) as HTMLElement | null; // `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 [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom); const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
useEffect(() => { useEffect(() => {
@@ -18,7 +18,7 @@ import {
IconTrashX, IconTrashX,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { BubbleMenu } from "@tiptap/react/menus"; 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 { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
@@ -38,6 +38,7 @@ export const TableMenu = React.memo(
); );
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "table"; const predicate = (node: PMNode) => node.type.name === "table";
const parent = findParentNode(predicate)(selection); 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 { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -53,7 +54,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
); );
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video"; const predicate = (node: PMNode) => node.type.name === "video";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
@@ -85,7 +85,7 @@ import AudioView from "@/features/editor/components/audio/audio-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-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 CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import DrawioView from "../components/drawio/drawio-view"; 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 EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx"; import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
+30 -11
View File
@@ -1,5 +1,5 @@
import classes from "@/features/editor/styles/editor.module.css"; 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 { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from "@/features/editor/page-editor"; import PageEditor from "@/features/editor/page-editor";
import { 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 { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
import clsx from "clsx"; import clsx from "clsx";
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
const MemoizedTitleEditor = React.memo(TitleEditor); const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor); const MemoizedPageEditor = React.memo(PageEditor);
const MemoizedFixedToolbar = React.memo(FixedToolbar);
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
type PageCreator = { type PageUser = {
id: string; id: string;
name: string; name: string;
avatarUrl: 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 { export interface FullEditorProps {
pageId: string; pageId: string;
slugId: string; slugId: string;
@@ -41,7 +49,7 @@ export interface FullEditorProps {
content: string; content: string;
spaceSlug: string; spaceSlug: string;
editable: boolean; editable: boolean;
creator?: PageCreator; creator?: PageUser;
contributors?: IContributor[]; contributors?: IContributor[];
canComment?: boolean; canComment?: boolean;
} }
@@ -61,9 +69,21 @@ export function FullEditor({
const fullPageWidth = user.settings?.preferences?.fullPageWidth; const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled = const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false; user.settings?.preferences?.editorToolbar ?? false;
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
currentPageEditModeAtom,
);
const userPageEditMode = const userPageEditMode =
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; 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 ( return (
<Container <Container
@@ -71,7 +91,10 @@ export function FullEditor({
size={!fullPageWidth && 900} size={!fullPageWidth && 900}
className={classes.editor} className={classes.editor}
> >
{editorToolbarEnabled && editable && isEditMode && <FixedToolbar />} {editorToolbarEnabled && editable && isEditMode && (
<MemoizedFixedToolbar />
)}
<MemoizedDeletedPageBanner slugId={slugId} />
<MemoizedTitleEditor <MemoizedTitleEditor
pageId={pageId} pageId={pageId}
slugId={slugId} slugId={slugId}
@@ -95,16 +118,12 @@ export function FullEditor({
} }
type PageBylineProps = { type PageBylineProps = {
creator?: PageCreator; creator?: PageUser;
contributors?: IContributor[]; contributors?: IContributor[];
readOnly?: boolean; readOnly?: boolean;
}; };
function PageByline({ function PageByline({ creator, contributors, readOnly }: PageBylineProps) {
creator,
contributors,
readOnly,
}: PageBylineProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const toggleAside = useToggleAside(); const toggleAside = useToggleAside();
@@ -26,10 +26,11 @@ import {
collabExtensions, collabExtensions,
mainExtensions, mainExtensions,
} from "@/features/editor/extensions/extensions"; } from "@/features/editor/extensions/extensions";
import { useAtom } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url"; import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { import {
currentPageEditModeAtom,
pageEditorAtom, pageEditorAtom,
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms"; } from "@/features/editor/atoms/editor-atoms";
@@ -54,7 +55,7 @@ import {
handleFileDrop, handleFileDrop,
handlePaste, handlePaste,
} from "@/features/editor/components/common/editor-paste-handler.tsx"; } 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 DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx"; import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
@@ -112,8 +113,7 @@ export default function PageEditor({
const documentState = useDocumentVisibility(); const documentState = useDocumentVisibility();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug); const slugId = extractPageSlugId(pageSlug);
const userPageEditMode = const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback( const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current), () => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted], [isComponentMounted],
@@ -373,19 +373,9 @@ export default function PageEditor({
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [yjsConnectionStatus, isSynced]); }, [yjsConnectionStatus, isSynced]);
useEffect(() => { useEffect(() => {
// Only honor user default page edit mode preference and permissions if (!editor) return;
if (editor) { editor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
if (userPageEditMode && editable) { }, [currentPageEditMode, editor, editable]);
if (userPageEditMode === PageEditMode.Edit) {
editor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
editor.setEditable(false);
}
} else {
editor.setEditable(false);
}
}
}, [userPageEditMode, editor, editable]);
const hasConnectedOnceRef = useRef(false); const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true); const [showStatic, setShowStatic] = useState(true);
@@ -7,6 +7,7 @@ import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import {
currentPageEditModeAtom,
pageEditorAtom, pageEditorAtom,
titleEditorAtom, titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms"; } 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 EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
import { UpdateEvent } from "@/features/websocket/types"; import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts"; 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 { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts"; import { searchSpotlight } from "@/features/search/constants.ts";
import { platformModifierKey } from "@/lib"; import { platformModifierKey } from "@/lib";
@@ -52,9 +52,7 @@ export function TitleEditor({
const emit = useQueryEmit(); const emit = useQueryEmit();
const navigate = useNavigate(); const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId); const [activePageId, setActivePageId] = useState(pageId);
const [currentUser] = useAtom(currentUserAtom); const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const titleEditor = useEditor({ const titleEditor = useEditor({
extensions: [ extensions: [
@@ -172,18 +170,9 @@ export function TitleEditor({
}, [pageId]); }, [pageId]);
useEffect(() => { useEffect(() => {
if (titleEditor) { if (!titleEditor) return;
if (userPageEditMode && editable) { titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
if (userPageEditMode === PageEditMode.Edit) { }, [currentPageEditMode, titleEditor, editable]);
titleEditor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
titleEditor.setEditable(false);
}
} else {
titleEditor.setEditable(false);
}
}
}, [userPageEditMode, titleEditor, editable]);
const openSearchDialog = () => { const openSearchDialog = () => {
const event = new CustomEvent("openFindDialogFromEditor", {}); const event = new CustomEvent("openFindDialogFromEditor", {});
@@ -40,7 +40,7 @@ import {
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts"; } from "@/features/editor/atoms/editor-atoms.ts";
import { formattedDate } from "@/lib/time.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 MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission"; import { PageShareModal } from "@/ee/page-permission";
@@ -65,6 +65,11 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const toggleAside = useToggleAside(); const toggleAside = useToggleAside();
const { pageSlug } = useParams();
const { data: page } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
const isDeleted = !!page?.deletedAt;
useHotkeys( useHotkeys(
[ [
@@ -87,11 +92,15 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
[], [],
); );
if (isDeleted) {
return null;
}
return ( return (
<> <>
<ConnectionWarning /> <ConnectionWarning />
{!readOnly && <PageStateSegmentedControl size="xs" />} {!readOnly && <PageEditModeToggle size="xs" />}
<PageShareModal readOnly={readOnly} /> <PageShareModal readOnly={readOnly} />
@@ -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;
}
@@ -117,10 +117,20 @@ export function useUpdatePageMutation() {
} }
export function useRemovePageMutation() { export function useRemovePageMutation() {
const { t } = useTranslation();
return useMutation({ return useMutation({
mutationFn: (pageId: string) => deletePage(pageId, false), mutationFn: (pageId: string) => deletePage(pageId, false),
onSuccess: (_, pageId) => { 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); invalidateOnDeletePage(pageId);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: (item) => predicate: (item) =>
@@ -128,7 +138,7 @@ export function useRemovePageMutation() {
}); });
}, },
onError: (error) => { onError: (error) => {
notifications.show({ message: "Failed to delete page", color: "red" }); notifications.show({ message: t("Failed to delete page"), color: "red" });
}, },
}); });
} }
@@ -162,13 +172,14 @@ export function useMovePageMutation() {
} }
export function useRestorePageMutation() { export function useRestorePageMutation() {
const { t } = useTranslation();
const [treeData, setTreeData] = useAtom(treeDataAtom); const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit(); const emit = useQueryEmit();
return useMutation({ return useMutation({
mutationFn: (pageId: string) => restorePage(pageId), mutationFn: (pageId: string) => restorePage(pageId),
onSuccess: async (restoredPage) => { onSuccess: async (restoredPage) => {
notifications.show({ message: "Page restored successfully" }); notifications.show({ message: t("Page restored successfully") });
// Check if the page already exists in the tree (it shouldn't) // Check if the page already exists in the tree (it shouldn't)
if (!treeModel.find(treeData, restoredPage.id)) { if (!treeModel.find(treeData, restoredPage.id)) {
@@ -222,9 +233,16 @@ export function useRestorePageMutation() {
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ["trash-list", restoredPage.spaceId], 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) => { 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, Group,
ActionIcon, ActionIcon,
Text, Text,
Alert,
Stack, Stack,
Menu, Menu,
} from "@mantine/core"; } from "@mantine/core";
import { import {
IconInfoCircle,
IconDots, IconDots,
IconRestore, IconRestore,
IconTrash, IconTrash,
IconFileDescription, IconFileDescription,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { TrashBanner } from "@/features/page/trash/components/trash-banner.tsx";
import { import {
useDeletedPagesQuery, useDeletedPagesQuery,
useRestorePageMutation, useRestorePageMutation,
@@ -31,12 +30,10 @@ import TrashPageContentModal from "@/features/page/trash/components/trash-page-c
import { UserInfo } from "@/components/common/user-info.tsx"; import { UserInfo } from "@/components/common/user-info.tsx";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useAtom } from "jotai"; import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function Trash() { export default function Trash() {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { cursor, goNext, goPrev } = useCursorPaginate(); const { cursor, goNext, goPrev } = useCursorPaginate();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug); const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
@@ -45,6 +42,7 @@ export default function Trash() {
}); });
const restorePageMutation = useRestorePageMutation(); const restorePageMutation = useRestorePageMutation();
const deletePageMutation = useDeletePageMutation(); const deletePageMutation = useDeletePageMutation();
const { openRestoreModal } = useRestorePageModal();
const [selectedPage, setSelectedPage] = useState<{ const [selectedPage, setSelectedPage] = useState<{
title: string; 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 hasPages = deletedPages && deletedPages.items.length > 0;
const handlePageClick = (page: any) => { const handlePageClick = (page: any) => {
@@ -109,11 +90,7 @@ export default function Trash() {
<Title order={2}>{t("Trash")}</Title> <Title order={2}>{t("Trash")}</Title>
</Group> </Group>
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red"> <TrashBanner />
<Text size="sm">
{t("Pages in trash will be permanently deleted after {{count}} days.", { count: workspace?.trashRetentionDays ?? 30 })}
</Text>
</Alert>
{isLoading || !deletedPages ? ( {isLoading || !deletedPages ? (
<></> <></>
@@ -181,7 +158,10 @@ export default function Trash() {
<Menu.Item <Menu.Item
leftSection={<IconRestore size={16} />} leftSection={<IconRestore size={16} />}
onClick={() => onClick={() =>
openRestoreModal(page.id, page.title) openRestoreModal({
title: page.title,
onConfirm: () => handleRestorePage(page.id),
})
} }
> >
{t("Restore")} {t("Restore")}
@@ -6,6 +6,7 @@ import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row"; import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
export default function PageStatePref() { export default function PageStatePref() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -71,3 +72,24 @@ export function PageStateSegmentedControl({
/> />
); );
} }
// Header variant: updates the current page's mode locally without persisting
// the preference to the server.
export function PageEditModeToggle({ size }: { size?: MantineSize }) {
const { t } = useTranslation();
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
currentPageEditModeAtom,
);
return (
<SegmentedControl
size={size}
value={currentPageEditMode}
onChange={(v) => setCurrentPageEditMode(v as PageEditMode)}
data={[
{ label: t("Edit"), value: PageEditMode.Edit },
{ label: t("Read"), value: PageEditMode.Read },
]}
/>
);
}
+1 -1
View File
@@ -52,7 +52,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) }); } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = page?.permissions?.canEdit ?? false; const canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false);
const canComment = const canComment =
canEdit || canEdit ||
(space?.settings?.comments?.allowViewerComments === true); (space?.settings?.comments?.allowViewerComments === true);
+5 -5
View File
@@ -38,12 +38,12 @@ export default defineConfig(({ mode }) => {
build: { build: {
rolldownOptions: { rolldownOptions: {
output: { output: {
codeSplitting: { advancedChunks: {
groups: [ groups: [
{ name: "vendor-mantine", test: /@mantine/ }, {
{ name: "vendor-mermaid", test: /mermaid|cytoscape|elkjs/ }, name: "vendor-mantine",
{ name: "vendor-excalidraw", test: /excalidraw/ }, test: /[\\/]node_modules[\\/]@mantine[\\/]/,
{ name: "vendor-katex", test: /katex/ }, },
], ],
}, },
}, },
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { AppController } from '../../app.controller'; import { AppController } from '../../app.controller';
import { AppService } from '../../app.service'; import { AppService } from '../../app.service';
import { EnvironmentModule } from '../../integrations/environment/environment.module'; import { EnvironmentModule } from '../../integrations/environment/environment.module';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { CollaborationModule } from '../collaboration.module'; import { CollaborationModule } from '../collaboration.module';
import { DatabaseModule } from '@docmost/db/database.module'; import { DatabaseModule } from '@docmost/db/database.module';
import { QueueModule } from '../../integrations/queue/queue.module'; import { QueueModule } from '../../integrations/queue/queue.module';
@@ -12,6 +13,8 @@ import { LoggerModule } from '../../common/logger/logger.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis'; import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from '../../integrations/redis/redis-config.service'; import { RedisConfigService } from '../../integrations/redis/redis-config.service';
import { CaslModule } from '../../core/casl/casl.module'; import { CaslModule } from '../../core/casl/casl.module';
import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
@Module({ @Module({
imports: [ imports: [
@@ -26,6 +29,18 @@ import { CaslModule } from '../../core/casl/casl.module';
RedisModule.forRootAsync({ RedisModule.forRootAsync({
useClass: RedisConfigService, useClass: RedisConfigService,
}), }),
CacheModule.registerAsync({
isGlobal: true,
useFactory: async (environmentService: EnvironmentService) => {
const redisUrl = environmentService.getRedisUrl();
return {
ttl: 5 * 1000,
stores: [new KeyvRedis(redisUrl)],
};
},
inject: [EnvironmentService],
}),
], ],
controllers: [ controllers: [
AppController, AppController,
@@ -1,3 +1,11 @@
export const CacheKey = { export const CacheKey = {
LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`, LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`,
SPACE_ROLES: (userId: string, spaceId: string) =>
`perm:space-roles:${userId}:${spaceId}`,
PAGE_CAN_EDIT: (userId: string, pageId: string) =>
`perm:can-edit:${userId}:${pageId}`,
}; };
// Permission caches dedupe repeated checks within and across short request bursts.
// 5s keeps staleness on revocations bounded.
export const PERMISSION_CACHE_TTL_MS = 5_000;
@@ -0,0 +1,27 @@
import { Cache } from 'cache-manager';
export async function withCache<T>(
cacheManager: Cache,
key: string,
ttlMs: number,
fn: () => Promise<T>,
): Promise<T> {
try {
const cached = await cacheManager.get<{ v: T }>(key);
if (cached !== undefined && cached !== null) {
return cached.v;
}
} catch (err) {
console.warn(`[withCache] get failed for "${key}", falling back to source`, err);
}
const value = await fn();
try {
await cacheManager.set(key, { v: value }, ttlMs);
} catch (err) {
console.warn(`[withCache] set failed for "${key}"`, err);
}
return value;
}
@@ -76,6 +76,7 @@ export class PageController {
includeCreator: true, includeCreator: true,
includeLastUpdatedBy: true, includeLastUpdatedBy: true,
includeContributors: true, includeContributors: true,
includeDeletedBy: true,
}); });
if (!page) { if (!page) {
@@ -1,4 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils'; import { dbOrTx } from '@docmost/db/utils';
@@ -17,6 +19,11 @@ import {
executeWithCursorPagination, executeWithCursorPagination,
} from '@docmost/db/pagination/cursor-pagination'; } from '@docmost/db/pagination/cursor-pagination';
import { PagePermissionMember } from './types/page-permission.types'; import { PagePermissionMember } from './types/page-permission.types';
import { withCache } from '../../../common/helpers/with-cache';
import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
export { PagePermissionMember } from './types/page-permission.types'; export { PagePermissionMember } from './types/page-permission.types';
@@ -25,6 +32,7 @@ export class PagePermissionRepo {
constructor( constructor(
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo, private readonly groupRepo: GroupRepo,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {} ) {}
async findPageAccessByPageId( async findPageAccessByPageId(
@@ -361,40 +369,8 @@ export class PagePermissionRepo {
* Check if user can access a page by verifying they have permission on ALL restricted ancestors. * Check if user can access a page by verifying they have permission on ALL restricted ancestors.
*/ */
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> { async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
const deniedAncestor = await this.db const { canAccess } = await this.canUserEditPage(userId, pageId);
.withRecursive('ancestors', (qb) => return canAccess;
qb
.selectFrom('pages')
.select(['pages.id as ancestorId', 'pages.parentPageId'])
.where('pages.id', '=', pageId)
.unionAll((eb) =>
eb
.selectFrom('pages')
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
.select(['pages.id as ancestorId', 'pages.parentPageId']),
),
)
.selectFrom('ancestors')
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
this.userGroupIdsSubquery(eb, userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pagePermissions.id', 'is', null)
.executeTakeFirst();
return !deniedAncestor;
} }
/** /**
@@ -412,43 +388,50 @@ export class PagePermissionRepo {
canAccess: boolean; canAccess: boolean;
canEdit: boolean; canEdit: boolean;
}> { }> {
const result = await sql<{ return withCache(
canAccess: boolean | null; this.cacheManager,
canEdit: boolean | null; CacheKey.PAGE_CAN_EDIT(userId, pageId),
}>` PERMISSION_CACHE_TTL_MS,
WITH RECURSIVE ancestors AS ( async () => {
SELECT id AS ancestor_id, parent_page_id, 0 AS depth const result = await sql<{
FROM pages canAccess: boolean | null;
WHERE id = ${pageId}::uuid canEdit: boolean | null;
UNION ALL }>`
SELECT p.id, p.parent_page_id, a.depth + 1 WITH RECURSIVE ancestors AS (
FROM pages p SELECT id AS ancestor_id, parent_page_id, 0 AS depth
JOIN ancestors a ON a.parent_page_id = p.id FROM pages
) WHERE id = ${pageId}::uuid
SELECT UNION ALL
bool_and(pp.id IS NOT NULL) AS "canAccess", SELECT p.id, p.parent_page_id, a.depth + 1
-- nearest restricted ancestor's highest role wins (DESC: 'writer' > 'reader', NULLS LAST: no-permission after real roles) FROM pages p
(array_agg(pp.role ORDER BY a.depth ASC, pp.role DESC NULLS LAST))[1] = 'writer' AS "canEdit" JOIN ancestors a ON a.parent_page_id = p.id
FROM ancestors a
JOIN page_access pa ON pa.page_id = a.ancestor_id
LEFT JOIN page_permissions pp ON pp.page_access_id = pa.id
AND (
pp.user_id = ${userId}::uuid
OR pp.group_id IN (
SELECT gu.group_id FROM group_users gu WHERE gu.user_id = ${userId}::uuid
) )
) SELECT
`.execute(this.db); bool_and(pp.id IS NOT NULL) AS "canAccess",
-- nearest restricted ancestor's highest role wins (DESC: 'writer' > 'reader', NULLS LAST: no-permission after real roles)
(array_agg(pp.role ORDER BY a.depth ASC, pp.role DESC NULLS LAST))[1] = 'writer' AS "canEdit"
FROM ancestors a
JOIN page_access pa ON pa.page_id = a.ancestor_id
LEFT JOIN page_permissions pp ON pp.page_access_id = pa.id
AND (
pp.user_id = ${userId}::uuid
OR pp.group_id IN (
SELECT gu.group_id FROM group_users gu WHERE gu.user_id = ${userId}::uuid
)
)
`.execute(this.db);
const row = result.rows[0]; const row = result.rows[0];
if (!row || row.canAccess === null) { if (!row || row.canAccess === null) {
return { hasAnyRestriction: false, canAccess: true, canEdit: true }; return { hasAnyRestriction: false, canAccess: true, canEdit: true };
} }
return { return {
hasAnyRestriction: true, hasAnyRestriction: true,
canAccess: row.canAccess, canAccess: row.canAccess,
canEdit: row.canAccess && (row.canEdit ?? false), canEdit: row.canAccess && (row.canEdit ?? false),
}; };
},
);
} }
/** /**
@@ -54,6 +54,7 @@ export class PageRepo {
includeCreator?: boolean; includeCreator?: boolean;
includeLastUpdatedBy?: boolean; includeLastUpdatedBy?: boolean;
includeContributors?: boolean; includeContributors?: boolean;
includeDeletedBy?: boolean;
includeHasChildren?: boolean; includeHasChildren?: boolean;
withLock?: boolean; withLock?: boolean;
trx?: KyselyTransaction; trx?: KyselyTransaction;
@@ -83,6 +84,10 @@ export class PageRepo {
query = query.select((eb) => this.withContributors(eb)); query = query.select((eb) => this.withContributors(eb));
} }
if (opts?.includeDeletedBy) {
query = query.select((eb) => this.withDeletedBy(eb));
}
if (opts?.includeSpace) { if (opts?.includeSpace) {
query = query.select((eb) => this.withSpace(eb)); query = query.select((eb) => this.withSpace(eb));
} }
@@ -1,4 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils'; import { dbOrTx } from '@docmost/db/utils';
@@ -13,6 +15,11 @@ import { MemberInfo, UserSpaceRole } from './types';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { withCache } from '../../../common/helpers/with-cache';
import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
@Injectable() @Injectable()
export class SpaceMemberRepo { export class SpaceMemberRepo {
@@ -20,6 +27,7 @@ export class SpaceMemberRepo {
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo, private readonly groupRepo: GroupRepo,
private readonly spaceRepo: SpaceRepo, private readonly spaceRepo: SpaceRepo,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {} ) {}
async insertSpaceMember( async insertSpaceMember(
@@ -214,25 +222,36 @@ export class SpaceMemberRepo {
userId: string, userId: string,
spaceId: string, spaceId: string,
): Promise<UserSpaceRole[]> { ): Promise<UserSpaceRole[]> {
const roles = await this.db return withCache(
.selectFrom('spaceMembers') this.cacheManager,
.select(['userId', 'role']) CacheKey.SPACE_ROLES(userId, spaceId),
.where('userId', '=', userId) PERMISSION_CACHE_TTL_MS,
.where('spaceId', '=', spaceId) async () => {
.unionAll( const roles = await this.db
this.db
.selectFrom('spaceMembers') .selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId') .select(['userId', 'role'])
.select(['groupUsers.userId', 'spaceMembers.role']) .where('userId', '=', userId)
.where('groupUsers.userId', '=', userId) .where('spaceId', '=', spaceId)
.where('spaceMembers.spaceId', '=', spaceId), .unionAll(
) this.db
.execute(); .selectFrom('spaceMembers')
.innerJoin(
'groupUsers',
'groupUsers.groupId',
'spaceMembers.groupId',
)
.select(['groupUsers.userId', 'spaceMembers.role'])
.where('groupUsers.userId', '=', userId)
.where('spaceMembers.spaceId', '=', spaceId),
)
.execute();
if (!roles || roles.length === 0) { if (!roles || roles.length === 0) {
return undefined; return undefined;
} }
return roles; return roles;
},
);
} }
async getUserIdsWithSpaceAccess( async getUserIdsWithSpaceAccess(
@@ -162,6 +162,28 @@ export const Callout = Node.create<CalloutOptions>({
return false; return false;
} }
// Empty callout: delete the whole node so Backspace inside it isn't
// a no-op (isolating: true blocks the default join with the block
// above).
const calloutDepth = $from.depth - 1;
if (calloutDepth >= 0) {
const calloutNode = $from.node(calloutDepth);
if (
calloutNode.type === this.type &&
calloutNode.childCount === 1 &&
calloutNode.firstChild?.content.size === 0
) {
const calloutPos = $from.before(calloutDepth);
const { tr } = state;
tr.delete(calloutPos, calloutPos + calloutNode.nodeSize);
tr.setSelection(
TextSelection.near(tr.doc.resolve(calloutPos), -1),
);
view.dispatch(tr);
return true;
}
}
const previousPosition = $from.before($from.depth) - 1; const previousPosition = $from.before($from.depth) - 1;
// If nothing above to join with // If nothing above to join with
@@ -207,6 +229,56 @@ export const Callout = Node.create<CalloutOptions>({
} }
return false; return false;
}, },
// Exit the callout into a fresh paragraph below when the cursor sits
// in an empty trailing child. An empty callout (single empty
// paragraph) exits on the first Enter and keeps the empty callout
// intact; a callout with content needs the double-Enter pattern
// (first Enter splits, second Enter on the new trailing empty exits
// and removes that trailing paragraph).
Enter: ({ editor }) => {
const { state, view } = editor;
const { selection } = state;
if (!selection.empty) return false;
const { $from } = selection;
const calloutDepth = $from.depth - 1;
if (calloutDepth < 0) return false;
const calloutNode = $from.node(calloutDepth);
if (calloutNode.type !== this.type) return false;
if ($from.parent.content.size !== 0) return false;
if ($from.index(calloutDepth) !== calloutNode.childCount - 1) {
return false;
}
const paragraphType = state.schema.nodes.paragraph;
const containerDepth = calloutDepth - 1;
const container = $from.node(containerDepth);
const indexAfter = $from.indexAfter(containerDepth);
if (
!container.canReplaceWith(indexAfter, indexAfter, paragraphType)
) {
return false;
}
const calloutEnd = $from.after(calloutDepth);
const paragraph = paragraphType.create();
const { tr } = state;
if (calloutNode.childCount === 1) {
tr.insert(calloutEnd, paragraph);
tr.setSelection(TextSelection.create(tr.doc, calloutEnd + 1));
} else {
tr.delete($from.before(), $from.after());
const insertPos = tr.mapping.map(calloutEnd);
tr.insert(insertPos, paragraph);
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
}
view.dispatch(tr);
return true;
},
}; };
}, },
@@ -1,5 +1,7 @@
import type { CodeBlockOptions } from '@tiptap/extension-code-block'; import type { CodeBlockOptions } from '@tiptap/extension-code-block';
import CodeBlock from '@tiptap/extension-code-block'; import CodeBlock from '@tiptap/extension-code-block';
import { Plugin, Selection, TextSelection } from '@tiptap/pm/state';
import { GapCursor } from '@tiptap/pm/gapcursor';
import { LowlightPlugin } from './lowlight-plugin.js'; import { LowlightPlugin } from './lowlight-plugin.js';
import { ReactNodeViewRenderer } from '@tiptap/react'; import { ReactNodeViewRenderer } from '@tiptap/react';
@@ -19,7 +21,11 @@ const TAB_CHAR = '\u00A0\u00A0';
* @see https://tiptap.dev/api/nodes/code-block-lowlight * @see https://tiptap.dev/api/nodes/code-block-lowlight
*/ */
export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
// Run ahead of Gapcursor (100) so the mermaid arrow-into-source plugin
// can intercept before gapcursor takes over.
priority: 101,
selectable: true, selectable: true,
isolating: true,
addOptions() { addOptions() {
return { return {
@@ -35,8 +41,86 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
}, },
addKeyboardShortcuts() { addKeyboardShortcuts() {
const isMermaid = (node: any) =>
node?.type === this.type && node.attrs.language === 'mermaid';
return { return {
...this.parent?.(), ...this.parent?.(),
// Stop at the gap (or enter mermaid source) instead of jumping
// straight into the next block, so the user can place a cursor
// between two adjacent isolating blocks.
ArrowDown: ({ editor }) => {
const { state } = editor;
const { selection, doc } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) return false;
if ($from.parentOffset !== $from.parent.nodeSize - 2) return false;
const after = $from.after();
if (after >= doc.content.size) {
return editor.commands.exitCode();
}
const $after = doc.resolve(after);
const nodeAfter = $after.nodeAfter;
if (isMermaid(nodeAfter)) {
return editor.commands.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, after + 1));
return true;
});
}
if (
nodeAfter?.type.spec.isolating &&
!nodeAfter.type.spec.atom
) {
return editor.commands.command(({ tr }) => {
tr.setSelection(new GapCursor(tr.doc.resolve(after)));
return true;
});
}
return editor.commands.command(({ tr }) => {
tr.setSelection(Selection.near(tr.doc.resolve(after)));
return true;
});
},
// Mirror of ArrowDown; upstream has no ArrowUp handler.
ArrowUp: ({ editor }) => {
const { state } = editor;
const { selection, doc } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) return false;
if ($from.parentOffset !== 0) return false;
const before = $from.before();
if (before <= 0) return false;
const $before = doc.resolve(before);
const nodeBefore = $before.nodeBefore;
if (isMermaid(nodeBefore)) {
return editor.commands.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, before - 1));
return true;
});
}
if (
nodeBefore?.type.spec.isolating &&
!nodeBefore.type.spec.atom
) {
return editor.commands.command(({ tr }) => {
tr.setSelection(new GapCursor(tr.doc.resolve(before)));
return true;
});
}
return false;
},
'Mod-a': () => { 'Mod-a': () => {
if (this.editor.isActive('codeBlock')) { if (this.editor.isActive('codeBlock')) {
const { state } = this.editor; const { state } = this.editor;
@@ -84,6 +168,7 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
}, },
addProseMirrorPlugins() { addProseMirrorPlugins() {
const codeBlockType = this.type;
return [ return [
...(this.parent?.() || []), ...(this.parent?.() || []),
LowlightPlugin({ LowlightPlugin({
@@ -91,6 +176,60 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
lowlight: this.options.lowlight, lowlight: this.options.lowlight,
defaultLanguage: this.options.defaultLanguage, defaultLanguage: this.options.defaultLanguage,
}), }),
// Mermaid hides its <pre> when unselected, so the browser's native
// vertical caret movement skips past it. Land the cursor inside the
// source explicitly.
new Plugin({
props: {
handleKeyDown: (view, event) => {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
return false;
}
const { state } = view;
const { selection } = state;
if (
!selection.empty ||
!(selection instanceof TextSelection)
) {
return false;
}
const { $from } = selection;
if ($from.depth === 0 || $from.parent.type === codeBlockType) {
return false;
}
const dir = event.key === 'ArrowUp' ? 'up' : 'down';
if (!view.endOfTextblock(dir)) return false;
const isMermaid = (node: any) =>
node?.type === codeBlockType && node.attrs.language === 'mermaid';
if (event.key === 'ArrowUp') {
if ($from.parentOffset !== 0) return false;
const beforePos = $from.before();
const prev = state.doc.resolve(beforePos).nodeBefore;
if (!isMermaid(prev)) return false;
const endPos = beforePos - 1;
view.dispatch(
state.tr.setSelection(
TextSelection.create(state.doc, endPos),
),
);
return true;
}
if ($from.parentOffset !== $from.parent.nodeSize - 2) return false;
const afterPos = $from.after();
const next = state.doc.resolve(afterPos).nodeAfter;
if (!isMermaid(next)) return false;
const startPos = afterPos + 1;
view.dispatch(
state.tr.setSelection(
TextSelection.create(state.doc, startPos),
),
);
return true;
},
},
}),
]; ];
}, },
}); });
+9
View File
@@ -338,6 +338,15 @@ export const isRowGripSelected = ({
return !!gripRow; return !!gripRow;
}; };
// TipTap's `editor.view` proxy throws if accessed before mount or after destroy.
// Guard floating-menu callbacks (getReferencedVirtualElement, shouldShow) with
// this before touching `editor.view.nodeDOM(...)`.
export function isEditorReady(
editor: Editor | null | undefined,
): editor is Editor {
return !!editor && editor.isInitialized;
}
export function isTextSelected(editor: Editor) { export function isTextSelected(editor: Editor) {
const { const {
state: { state: {