mirror of
https://github.com/docmost/docmost.git
synced 2026-08-28 17:27:06 +08:00
Merge branch 'main' into feat/integrations
# Conflicts: # apps/client/src/App.tsx # apps/server/src/ee # apps/server/src/integrations/queue/constants/queue.constants.ts # apps/server/src/integrations/queue/queue.module.ts # packages/editor-ext/src/index.ts
This commit is contained in:
@@ -15,6 +15,7 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { useCreateCommentMutation } from "@/features/comment/queries/comment-query";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -48,11 +49,14 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
||||
setReadOnlyCommentData(null);
|
||||
} else {
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
if (isEditorReady(editor)) {
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedText = () => {
|
||||
if (!isEditorReady(editor)) return "";
|
||||
const { from, to } = editor.state.selection;
|
||||
return editor.state.doc.textBetween(from, to);
|
||||
};
|
||||
@@ -74,24 +78,28 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
||||
|
||||
const createdComment =
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
editor
|
||||
.chain()
|
||||
.setComment(createdComment.id)
|
||||
.unsetCommentDecoration()
|
||||
.run();
|
||||
if (isEditorReady(editor)) {
|
||||
editor
|
||||
.chain()
|
||||
.setComment(createdComment.id)
|
||||
.unsetCommentDecoration()
|
||||
.run();
|
||||
editor.commands.setTextSelection({
|
||||
from: editor.view.state.selection.from,
|
||||
to: editor.view.state.selection.from,
|
||||
});
|
||||
}
|
||||
setActiveCommentId(createdComment.id);
|
||||
|
||||
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
|
||||
|
||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.scrollIntoView()
|
||||
);
|
||||
if (isEditorReady(editor)) {
|
||||
editor.view.dispatch(editor.state.tr.scrollIntoView());
|
||||
}
|
||||
}, 400);
|
||||
|
||||
} finally {
|
||||
|
||||
@@ -112,22 +112,24 @@ const CommentEditor = forwardRef(
|
||||
// websocket on another browser). Skip for editable editors to avoid
|
||||
// resetting the cursor position on every keystroke.
|
||||
useEffect(() => {
|
||||
if (!editable && commentEditor && defaultContent) {
|
||||
if (!editable && commentEditor && !commentEditor.isDestroyed && defaultContent) {
|
||||
commentEditor.commands.setContent(defaultContent);
|
||||
}
|
||||
}, [defaultContent, editable, commentEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (autofocus) {
|
||||
commentEditor?.commands.focus("end");
|
||||
if (autofocus && commentEditor && !commentEditor.isDestroyed) {
|
||||
commentEditor.commands.focus("end");
|
||||
}
|
||||
}, 10);
|
||||
}, [commentEditor, autofocus]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearContent: () => {
|
||||
commentEditor.commands.clearContent();
|
||||
if (commentEditor && !commentEditor.isDestroyed) {
|
||||
commentEditor.commands.clearContent();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAtom, useAtomValue } from "jotai";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
@@ -75,7 +76,9 @@ function CommentListItem({
|
||||
async function handleDeleteComment() {
|
||||
try {
|
||||
await deleteCommentMutation.mutateAsync(comment.id);
|
||||
editor?.commands.unsetComment(comment.id);
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.unsetComment(comment.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete comment:", error);
|
||||
}
|
||||
@@ -93,7 +96,7 @@ function CommentListItem({
|
||||
resolved: !isResolved,
|
||||
});
|
||||
|
||||
if (editor) {
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.setCommentResolved(comment.id, !isResolved);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -10,6 +10,8 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const yjsConnectionStatusAtom = atom<string>("");
|
||||
|
||||
export const yjsSyncedAtom = atom<boolean>(false);
|
||||
|
||||
export const showAiMenuAtom = atom(false);
|
||||
|
||||
export const showLinkMenuAtom = atom(false);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
HocuspocusProviderWebsocket,
|
||||
WebSocketStatus,
|
||||
} from "@hocuspocus/provider";
|
||||
import { getCollaborationUrl } from "@/lib/config.ts";
|
||||
|
||||
const RELEASE_GRACE_MS = 5000;
|
||||
|
||||
let socket: HocuspocusProviderWebsocket | null = null;
|
||||
let editorCount = 0;
|
||||
let releaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function getCollabSocket(): HocuspocusProviderWebsocket {
|
||||
if (!socket) {
|
||||
socket = new HocuspocusProviderWebsocket({
|
||||
url: getCollaborationUrl(),
|
||||
autoConnect: false,
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function acquireCollabSocket(): void {
|
||||
editorCount++;
|
||||
if (releaseTimer) {
|
||||
clearTimeout(releaseTimer);
|
||||
releaseTimer = null;
|
||||
}
|
||||
const collabSocket = getCollabSocket();
|
||||
collabSocket.shouldConnect = true;
|
||||
if (collabSocket.status === WebSocketStatus.Disconnected) {
|
||||
collabSocket.connect();
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseCollabSocket(): void {
|
||||
editorCount--;
|
||||
if (editorCount > 0) return;
|
||||
if (releaseTimer) clearTimeout(releaseTimer);
|
||||
releaseTimer = setTimeout(() => {
|
||||
releaseTimer = null;
|
||||
if (editorCount === 0) {
|
||||
socket?.disconnect();
|
||||
}
|
||||
}, RELEASE_GRACE_MS);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { NodeViewWrapper, NodeViewProps } from "@tiptap/react";
|
||||
import { ActionIcon, Box, Menu, Text } from "@mantine/core";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BaseView } from "@/ee/base/components/base-view";
|
||||
import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton";
|
||||
import { useBaseQuery } from "@/ee/base/queries/base-query";
|
||||
import { pinOffsetWatcher } from "@docmost/editor-ext";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { IconDots, IconTable, IconX } from "@tabler/icons-react";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||
import classes from "./base-embed.module.css";
|
||||
|
||||
const SIDE_GUTTER = 8;
|
||||
|
||||
// Extend the scroll viewport on both sides (toward AppShell.Main's
|
||||
// edges), but offset the grid content with padding-left = extendLeft
|
||||
// so the first cell still lines up with page-content on load.
|
||||
function applyExtension(wrapper: HTMLDivElement) {
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
if (rect.width === 0) return;
|
||||
|
||||
const main = wrapper.closest("main") as HTMLElement | null;
|
||||
const mainRect = main?.getBoundingClientRect();
|
||||
const targetLeft = (mainRect?.left ?? 0) + SIDE_GUTTER;
|
||||
const targetRight = mainRect
|
||||
? mainRect.right - SIDE_GUTTER
|
||||
: window.innerWidth - SIDE_GUTTER;
|
||||
|
||||
const extendLeft = Math.max(0, rect.left - targetLeft);
|
||||
const extendRight = Math.max(0, targetRight - rect.right);
|
||||
|
||||
wrapper.style.setProperty("--embed-extend-l", `${extendLeft}px`);
|
||||
wrapper.style.setProperty("--embed-extend-r", `${extendRight}px`);
|
||||
wrapper.style.setProperty("--embed-grid-pad-left", `${extendLeft}px`);
|
||||
// Symmetric right-side padding so the user can pan past the last
|
||||
// column into empty space.
|
||||
// This gives the table breathing room on the right when scrolled fully right.
|
||||
wrapper.style.setProperty("--embed-grid-pad-right", `${extendRight}px`);
|
||||
// Inline sticky band clears whatever fixed surface sits above the editor —
|
||||
// the page header AND the fixed formatting toolbar. `--editor-pin-offset`
|
||||
// is the same offset the default ProseMirror table header-pin uses
|
||||
// (published by pinOffsetWatcher); fall back to the page-header height.
|
||||
// Standalone leaves --sticky-band-top unset (resolves to the rule default
|
||||
// of 0).
|
||||
wrapper.style.setProperty(
|
||||
"--sticky-band-top",
|
||||
"var(--editor-pin-offset, var(--page-header-height))",
|
||||
);
|
||||
}
|
||||
|
||||
export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const pageId = node.attrs.pageId as string | null;
|
||||
const pendingKey = node.attrs.pendingKey as string | null;
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasBases = useHasFeature(Feature.BASES);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
// Suppress the query while the slash command awaits the server-assigned
|
||||
// pageId; useBaseQuery would otherwise fire with an empty key.
|
||||
const { data: base, isLoading, isError } = useBaseQuery(
|
||||
pendingKey ? "" : pageId ?? "",
|
||||
);
|
||||
const { data: page } = usePageQuery({ pageId: pageId ?? undefined });
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const update = () => applyExtension(wrapper);
|
||||
update();
|
||||
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(wrapper);
|
||||
// Sidebar collapse changes <main>'s left/width without resizing
|
||||
// the wrapper itself, so observe <main> too.
|
||||
const main = wrapper.closest("main");
|
||||
if (main) ro.observe(main);
|
||||
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [isLoading, isError, pageId]);
|
||||
|
||||
// Keep --editor-pin-offset published while the embed is mounted, so the
|
||||
// sticky column header clears the fixed toolbar even when this document
|
||||
// has no default ProseMirror table holding the watcher open.
|
||||
useEffect(() => {
|
||||
pinOffsetWatcher.acquire();
|
||||
return () => pinOffsetWatcher.release();
|
||||
}, []);
|
||||
|
||||
// Error/invalid states render a compact message, not a tall reserved box.
|
||||
// The 200px min-height (which avoids a layout jump when the real table
|
||||
// mounts) is reserved only for the skeleton/loading/table states.
|
||||
const isCompact = !pendingKey && (!pageId || isError);
|
||||
|
||||
const showControls = editor.isEditable && !pendingKey;
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (pendingKey) {
|
||||
// Slash command inserted the embed and is awaiting the server's
|
||||
// assigned pageId. Match the shape the create endpoint will
|
||||
// return for an inline-embed (Title + Text 1 + Text 2, one
|
||||
// empty row — see BaseService.create's `defaults`) so the swap
|
||||
// to the real table doesn't visibly collapse a large fake table
|
||||
// down to a small empty one.
|
||||
content = <BaseTableSkeleton rows={1} columns={3} />;
|
||||
} else if (!pageId) {
|
||||
content = (
|
||||
<Box p="md">
|
||||
<Text c="red">Invalid base embed (missing page id)</Text>
|
||||
</Box>
|
||||
);
|
||||
} else if (isLoading) {
|
||||
content = (
|
||||
<Box p="md">
|
||||
<Text c="dimmed">Loading...</Text>
|
||||
</Box>
|
||||
);
|
||||
} else if (isError) {
|
||||
content = (
|
||||
<Box p="md" bg="gray.0" style={{ borderRadius: 8 }}>
|
||||
<Text c="dimmed">You don't have access to this base.</Text>
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<BaseView
|
||||
pageId={pageId}
|
||||
embedded
|
||||
editable={hasBases && editor.isEditable && (base?.permissions?.canEdit ?? false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={classes.handleGutter}
|
||||
data-menu-open={menuOpen ? "true" : "false"}
|
||||
>
|
||||
{showControls && (
|
||||
<div
|
||||
className={classes.controls}
|
||||
contentEditable={false}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Menu position="bottom-end" withinPortal onChange={setMenuOpen}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="sm"
|
||||
aria-label={t("Base options")}
|
||||
>
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => deleteNode()}
|
||||
>
|
||||
{t("Remove from page")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
<div data-drag-preview hidden className={classes.dragPreview}>
|
||||
<IconTable size={16} />
|
||||
<span>{page?.title?.trim() || "Untitled base"}</span>
|
||||
</div>
|
||||
<div ref={wrapperRef} style={{ minHeight: isCompact ? undefined : 200 }}>
|
||||
{content}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
.handleGutter {
|
||||
position: relative;
|
||||
margin-left: -1.5rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.controls::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.handleGutter:hover .controls,
|
||||
.handleGutter[data-menu-open="true"] .controls {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.controls {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.handleGutter {
|
||||
margin-left: -1rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dragPreview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 260px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-white),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.dragPreview[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dragPreview svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dragPreview span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Editor, Range } from "@tiptap/core";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import api from "@/lib/api-client";
|
||||
import i18n from "@/i18n.ts";
|
||||
import { getApiErrorMessage } from "@/lib/api-error";
|
||||
|
||||
function findBaseEmbedPlaceholderPos(
|
||||
editor: Editor,
|
||||
pendingKey: string,
|
||||
): number | null {
|
||||
let foundPos: number | null = null;
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === "base" && node.attrs.pendingKey === pendingKey) {
|
||||
foundPos = pos;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return foundPos;
|
||||
}
|
||||
|
||||
export async function insertBaseEmbedBlock(
|
||||
editor: Editor,
|
||||
opts: { template?: "kanban"; range?: Range } = {},
|
||||
): Promise<void> {
|
||||
// @ts-ignore
|
||||
const parentPageId = editor.storage?.pageId as string | undefined;
|
||||
if (!parentPageId) return;
|
||||
|
||||
const pendingKey = uuid7();
|
||||
|
||||
const chain = editor.chain().focus();
|
||||
if (opts.range) chain.deleteRange(opts.range);
|
||||
chain.insertBaseEmbed({ pageId: null, pendingKey }).run();
|
||||
|
||||
try {
|
||||
const res = await api.post<{ id: string }>("/bases/create", {
|
||||
parentPageId,
|
||||
...(opts.template ? { template: opts.template } : {}),
|
||||
});
|
||||
|
||||
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
|
||||
if (pos === null) return;
|
||||
editor
|
||||
.chain()
|
||||
.command(({ tr }) => {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
pageId: res.data.id,
|
||||
pendingKey: null,
|
||||
});
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
} catch (err) {
|
||||
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
|
||||
if (pos !== null) {
|
||||
editor
|
||||
.chain()
|
||||
.command(({ tr }) => {
|
||||
const node = tr.doc.nodeAt(pos);
|
||||
if (node) tr.delete(pos, pos + node.nodeSize);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
}
|
||||
notifications.show({
|
||||
message: getApiErrorMessage(err, i18n.t("Failed to create base")),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||
import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext";
|
||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
@@ -38,9 +38,11 @@ export interface BubbleMenuItem {
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||
editor: Editor | null;
|
||||
templateMode?: boolean;
|
||||
};
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const { templateMode = false } = props;
|
||||
const { t } = useTranslation();
|
||||
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
@@ -224,7 +226,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
aria-label={t(item.name)}
|
||||
className={clsx({ [classes.active]: item.isActive() })}
|
||||
style={{ border: "none" }}
|
||||
onClick={item.command}
|
||||
onClick={() => isEditorReady(props.editor) && item.command()}
|
||||
>
|
||||
<item.icon style={{ width: rem(16) }} stroke={2} />
|
||||
</ActionIcon>
|
||||
@@ -232,8 +234,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
))}
|
||||
</ActionIcon.Group>
|
||||
|
||||
<LinkSelector />
|
||||
|
||||
<ColorSelector
|
||||
editor={props.editor}
|
||||
isOpen={isColorSelectorOpen}
|
||||
@@ -246,18 +246,22 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="6px"
|
||||
aria-label={t(commentItem.name)}
|
||||
style={{ border: "none" }}
|
||||
onClick={commentItem.command}
|
||||
>
|
||||
<IconMessage size={16} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<LinkSelector />
|
||||
|
||||
{!templateMode && (
|
||||
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="6px"
|
||||
aria-label={t(commentItem.name)}
|
||||
style={{ border: "none" }}
|
||||
onClick={() => isEditorReady(props.editor) && commentItem.command()}
|
||||
>
|
||||
<IconMessage size={16} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import clsx from "clsx";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
|
||||
@@ -253,6 +254,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
<SimpleGrid cols={5} spacing="xs">
|
||||
{TEXT_COLORS.map(({ name, color }, index) => {
|
||||
const applyTextColor = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetColor();
|
||||
} else {
|
||||
@@ -316,6 +318,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
<SimpleGrid cols={5} spacing="xs">
|
||||
{HIGHLIGHT_COLORS.map(({ name, color }, index) => {
|
||||
const applyHighlight = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetHighlight();
|
||||
} else {
|
||||
@@ -386,8 +389,10 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
data-color-grid="remove"
|
||||
className={classes.removeColor}
|
||||
onClick={() => {
|
||||
editor.commands.unsetColor();
|
||||
editor.commands.unsetHighlight();
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.unsetColor();
|
||||
editor.commands.unsetHighlight();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
|
||||
interface NodeSelectorProps {
|
||||
@@ -193,7 +194,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
justify="left"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
item.command();
|
||||
if (isEditorReady(editor)) item.command();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{ border: "none" }}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getRelativeSelection, ySyncPluginKey } from "@tiptap/y-tiptap";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
|
||||
type ReadonlyBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
@@ -29,6 +30,10 @@ export const ReadonlyBubbleMenu: FC<ReadonlyBubbleMenuProps> = ({ editor }) => {
|
||||
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
if (isInteractingRef.current) return;
|
||||
if (!isEditorReady(editor)) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const pmSelection = editor.state.selection;
|
||||
if (!(pmSelection instanceof TextSelection) || pmSelection.empty) {
|
||||
@@ -97,7 +102,7 @@ export const ReadonlyBubbleMenu: FC<ReadonlyBubbleMenuProps> = ({ editor }) => {
|
||||
}, [showReadOnlyCommentPopup]);
|
||||
|
||||
const handleCommentClick = () => {
|
||||
if (!editor) return;
|
||||
if (!isEditorReady(editor)) return;
|
||||
|
||||
const view = editor.view;
|
||||
const ystate = ySyncPluginKey.getState(view.state);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Menu, Button, Tooltip, rem } from "@mantine/core";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
|
||||
interface TextAlignmentProps {
|
||||
editor: Editor | null;
|
||||
@@ -117,7 +118,7 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
|
||||
activeItem.name === item.name ? <IconCheck size={16} /> : null
|
||||
}
|
||||
onClick={() => {
|
||||
item.command();
|
||||
if (isEditorReady(editor)) item.command();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isCountOpen, setIsCountOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const nodesWithMenus = [
|
||||
"callout",
|
||||
|
||||
@@ -127,8 +127,9 @@ async function reuploadPastedAttachments(
|
||||
const match = ATTACHMENT_URL_RE.exec(src);
|
||||
if (!match) return;
|
||||
|
||||
const cleanSrc = src.split("?")[0];
|
||||
const fileName =
|
||||
node.attrs.name || src.split("/").pop() || "file";
|
||||
node.attrs.name || cleanSrc.split("/").pop() || "file";
|
||||
|
||||
pastedNodes.push({
|
||||
pos,
|
||||
@@ -198,6 +199,7 @@ async function reuploadPastedAttachments(
|
||||
);
|
||||
|
||||
if (reuploadResults.size === 0) return;
|
||||
if (editor.isDestroyed) return;
|
||||
|
||||
editor.chain().command(({ tr }) => {
|
||||
const sorted = [...nodesToReupload].sort((a, b) => b.pos - a.pos);
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
/* Push the bar to the bottom of the full-height editor container. */
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 8px;
|
||||
/* Match the content indent used by .byline and .ProseMirror so the bar
|
||||
lines up with the title and paragraph rather than the container edge. */
|
||||
padding-left: 3rem;
|
||||
padding-right: 3rem;
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.chipRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconTable, IconLayoutKanban } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useConvertPageToBaseMutation } from "@/ee/base/queries/base-query";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
yjsSyncedAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import classes from "./empty-page-get-started.module.css";
|
||||
|
||||
type EmptyPageGetStartedProps = {
|
||||
pageId: string;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
export function EmptyPageGetStarted({
|
||||
pageId,
|
||||
editable,
|
||||
}: EmptyPageGetStartedProps) {
|
||||
const { t } = useTranslation();
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
const isSynced = useAtomValue(yjsSyncedAtom);
|
||||
const hasBases = useHasFeature(Feature.BASES);
|
||||
const convertMutation = useConvertPageToBaseMutation();
|
||||
|
||||
const [isEmpty, setIsEmpty] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const sync = () => setIsEmpty(editor.isEmpty);
|
||||
sync();
|
||||
editor.on("update", sync);
|
||||
editor.on("create", sync);
|
||||
return () => {
|
||||
editor.off("update", sync);
|
||||
editor.off("create", sync);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
if (!editable || !hasBases || !editor || !isSynced || !isEmpty) return null;
|
||||
|
||||
const chips = [
|
||||
{
|
||||
key: "base",
|
||||
label: t("Base"),
|
||||
icon: IconTable,
|
||||
onClick: () => convertMutation.mutate({ pageId }),
|
||||
disabled: convertMutation.isPending,
|
||||
},
|
||||
{
|
||||
key: "kanban",
|
||||
label: t("Kanban"),
|
||||
icon: IconLayoutKanban,
|
||||
onClick: () => convertMutation.mutate({ pageId, template: "kanban" }),
|
||||
disabled: convertMutation.isPending,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper} contentEditable={false}>
|
||||
<span className={classes.label}>{t("Get started with")}</span>
|
||||
<div className={classes.chipRow}>
|
||||
{chips.map((chip) => (
|
||||
<Button
|
||||
key={chip.key}
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xl"
|
||||
leftSection={<chip.icon size={16} />}
|
||||
onClick={chip.onClick}
|
||||
disabled={chip.disabled}
|
||||
>
|
||||
{chip.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
z-index: 99;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 45px;
|
||||
background: var(--mantine-color-body);
|
||||
border-bottom: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { FC } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { useToolbarState } from "./use-toolbar-state";
|
||||
import { BlockTypeGroup } from "./groups/block-type-group";
|
||||
import { InlineMarksGroup } from "./groups/inline-marks-group";
|
||||
import { ColorGroup } from "./groups/color-group";
|
||||
import { ListsGroup } from "./groups/lists-group";
|
||||
import { LinkGroup } from "./groups/link-group";
|
||||
import { AlignmentGroup } from "./groups/alignment-group";
|
||||
import { MediaGroup } from "./groups/media-group";
|
||||
import { QuickInsertsGroup } from "./groups/quick-inserts-group";
|
||||
@@ -16,14 +16,21 @@ import { AskAiGroup } from "./groups/ask-ai-group";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import classes from "./fixed-toolbar.module.css";
|
||||
|
||||
export const FixedToolbar: FC = () => {
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
type FixedToolbarProps = {
|
||||
editor?: Editor | null;
|
||||
templateMode?: boolean;
|
||||
};
|
||||
|
||||
export const FixedToolbar: FC<FixedToolbarProps> = ({
|
||||
editor: editorProp,
|
||||
templateMode = false,
|
||||
}) => {
|
||||
const editorFromAtom = useAtomValue(pageEditorAtom);
|
||||
const editor = editorProp ?? editorFromAtom;
|
||||
const state = useToolbarState(editor);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
||||
|
||||
if (!editor || !state) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -40,24 +47,26 @@ export const FixedToolbar: FC = () => {
|
||||
<div className={classes.divider} />
|
||||
</>
|
||||
)} */}
|
||||
<BlockTypeGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<InlineMarksGroup editor={editor} state={state} />
|
||||
<div className={classes.divider} />
|
||||
<ColorGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<ListsGroup editor={editor} state={state} />
|
||||
<div className={classes.divider} />
|
||||
<LinkGroup />
|
||||
<div className={classes.divider} />
|
||||
<AlignmentGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<MediaGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<QuickInsertsGroup editor={editor} />
|
||||
<MoreInsertsGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<HistoryGroup editor={editor} state={state} />
|
||||
{editor && state && (
|
||||
<>
|
||||
<BlockTypeGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<InlineMarksGroup editor={editor} state={state} />
|
||||
<div className={classes.divider} />
|
||||
<ColorGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<ListsGroup editor={editor} state={state} />
|
||||
<div className={classes.divider} />
|
||||
<AlignmentGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<MediaGroup editor={editor} templateMode={templateMode} />
|
||||
<div className={classes.divider} />
|
||||
<QuickInsertsGroup editor={editor} />
|
||||
<MoreInsertsGroup editor={editor} templateMode={templateMode} />
|
||||
<div className={classes.divider} />
|
||||
<HistoryGroup editor={editor} state={state} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.spacer} aria-hidden />
|
||||
|
||||
+5
-5
@@ -25,11 +25,11 @@ export const BlockTypeGroup: FC<Props> = ({ editor }) => {
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => ({
|
||||
isHeading1: ctx.editor.isActive("heading", { level: 1 }),
|
||||
isHeading2: ctx.editor.isActive("heading", { level: 2 }),
|
||||
isHeading3: ctx.editor.isActive("heading", { level: 3 }),
|
||||
isBlockquote: ctx.editor.isActive("blockquote"),
|
||||
isCodeBlock: ctx.editor.isActive("codeBlock"),
|
||||
isHeading1: !!ctx.editor?.isActive("heading", { level: 1 }),
|
||||
isHeading2: !!ctx.editor?.isActive("heading", { level: 2 }),
|
||||
isHeading3: !!ctx.editor?.isActive("heading", { level: 3 }),
|
||||
isBlockquote: !!ctx.editor?.isActive("blockquote"),
|
||||
isCodeBlock: !!ctx.editor?.isActive("codeBlock"),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector";
|
||||
|
||||
export const LinkGroup: FC = () => {
|
||||
return <LinkSelector />;
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import { uploadPdfAction } from "@/features/editor/components/pdf/upload-pdf-act
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
templateMode?: boolean;
|
||||
}
|
||||
|
||||
type UploadFn = (
|
||||
@@ -60,7 +61,7 @@ function pickFile(
|
||||
input.click();
|
||||
}
|
||||
|
||||
export const MediaGroup: FC<Props> = ({ editor }) => {
|
||||
export const MediaGroup: FC<Props> = ({ editor, templateMode }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -78,24 +79,30 @@ export const MediaGroup: FC<Props> = ({ editor }) => {
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconPhoto size={16} />}
|
||||
onClick={() => pickFile(editor, "image/*", true, uploadImageAction)}
|
||||
>
|
||||
{t("Image")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconMovie size={16} />}
|
||||
onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)}
|
||||
>
|
||||
{t("Video")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconMusic size={16} />}
|
||||
onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)}
|
||||
>
|
||||
{t("Audio")}
|
||||
</Menu.Item>
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconPhoto size={16} />}
|
||||
onClick={() => pickFile(editor, "image/*", true, uploadImageAction)}
|
||||
>
|
||||
{t("Image")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconMovie size={16} />}
|
||||
onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)}
|
||||
>
|
||||
{t("Video")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconMusic size={16} />}
|
||||
onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)}
|
||||
>
|
||||
{t("Audio")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<IconFileTypePdf size={16} />}
|
||||
onClick={() =>
|
||||
@@ -104,14 +111,16 @@ export const MediaGroup: FC<Props> = ({ editor }) => {
|
||||
>
|
||||
PDF
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconPaperclip size={16} />}
|
||||
onClick={() =>
|
||||
pickFile(editor, "", true, uploadAttachmentAction, true)
|
||||
}
|
||||
>
|
||||
{t("File attachment")}
|
||||
</Menu.Item>
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconPaperclip size={16} />}
|
||||
onClick={() =>
|
||||
pickFile(editor, "", true, uploadAttachmentAction, true)
|
||||
}
|
||||
>
|
||||
{t("File attachment")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
+83
-24
@@ -1,16 +1,18 @@
|
||||
import { FC } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Badge, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAppWindow,
|
||||
IconCalendar,
|
||||
IconCaretRightFilled,
|
||||
IconChevronDown,
|
||||
IconInfoCircle,
|
||||
IconLayoutKanban,
|
||||
IconMath,
|
||||
IconMathFunction,
|
||||
IconRotate2,
|
||||
IconSitemap,
|
||||
IconTable,
|
||||
IconTag,
|
||||
} from "@tabler/icons-react";
|
||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||
@@ -29,19 +31,26 @@ import {
|
||||
YoutubeIcon,
|
||||
} from "@/components/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
templateMode?: boolean;
|
||||
}
|
||||
|
||||
export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
|
||||
const { t } = useTranslation();
|
||||
export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const hasBases = useHasFeature(Feature.BASES);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const setEmbed = (provider: string) =>
|
||||
editor.chain().focus().setEmbed({ provider }).run();
|
||||
|
||||
const insertDate = () => {
|
||||
const currentDate = new Date().toLocaleDateString("en-US", {
|
||||
const currentDate = new Date().toLocaleDateString(i18n.language, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
@@ -91,14 +100,60 @@ export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
|
||||
>
|
||||
{t("Subpages")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconRotate2 size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().insertTransclusionSource().run()
|
||||
}
|
||||
>
|
||||
{t("Synced block")}
|
||||
</Menu.Item>
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconRotate2 size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleTransclusionSource().run()
|
||||
}
|
||||
>
|
||||
{t("Synced block")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!templateMode && (
|
||||
<Tooltip label={upgradeLabel} disabled={hasBases} position="right">
|
||||
<Menu.Item
|
||||
leftSection={<IconTable size={16} />}
|
||||
aria-disabled={!hasBases}
|
||||
closeMenuOnClick={hasBases}
|
||||
style={{ opacity: hasBases ? undefined : 0.7 }}
|
||||
rightSection={
|
||||
!hasBases && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{t("Upgrade")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (hasBases) insertBaseEmbedBlock(editor);
|
||||
}}
|
||||
>
|
||||
{t("Base (Inline)")}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!templateMode && (
|
||||
<Tooltip label={upgradeLabel} disabled={hasBases} position="right">
|
||||
<Menu.Item
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
aria-disabled={!hasBases}
|
||||
closeMenuOnClick={hasBases}
|
||||
style={{ opacity: hasBases ? undefined : 0.7 }}
|
||||
rightSection={
|
||||
!hasBases && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{t("Upgrade")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (hasBases) insertBaseEmbedBlock(editor, { template: "kanban" });
|
||||
}}
|
||||
>
|
||||
{t("Kanban")}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t("Diagrams")}</Menu.Label>
|
||||
@@ -115,18 +170,22 @@ export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
|
||||
>
|
||||
{t("Mermaid diagram")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconDrawio size={16} />}
|
||||
onClick={() => editor.chain().focus().setDrawio().run()}
|
||||
>
|
||||
Draw.io
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconExcalidraw size={16} />}
|
||||
onClick={() => editor.chain().focus().setExcalidraw().run()}
|
||||
>
|
||||
Excalidraw
|
||||
</Menu.Item>
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconDrawio size={16} />}
|
||||
onClick={() => editor.chain().focus().setDrawio().run()}
|
||||
>
|
||||
Draw.io
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconExcalidraw size={16} />}
|
||||
onClick={() => editor.chain().focus().setExcalidraw().run()}
|
||||
>
|
||||
Excalidraw
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t("Embeds")}</Menu.Label>
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface ToolbarState {
|
||||
// static editor (mainExtensions only, undoRedo disabled), neither is loaded
|
||||
// and editor.can().undo/redo is undefined.
|
||||
function safeCan(editor: Editor, command: "undo" | "redo"): boolean {
|
||||
const can = editor.can() as Record<string, unknown>;
|
||||
const can = editor?.can() as Record<string, unknown>;
|
||||
const fn = can[command];
|
||||
return typeof fn === "function" ? (fn as () => boolean)() : false;
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function useToolbarState(editor: Editor | null): ToolbarState | null {
|
||||
return useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) return null;
|
||||
if (!ctx.editor || ctx.editor.isDestroyed) return null;
|
||||
return {
|
||||
isBold: ctx.editor.isActive("bold"),
|
||||
isItalic: ctx.editor.isActive("italic"),
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||
import clsx from "clsx";
|
||||
@@ -186,7 +186,7 @@ export const LinkEditorPanel = ({
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<AutoTooltipText size="sm" fw={500} truncate lh={1.3}>
|
||||
{page.title || t("Untitled")}
|
||||
{getPageTitle(page.title, page.isBase, t)}
|
||||
</AutoTooltipText>
|
||||
{page.space?.name && (
|
||||
<AutoTooltipText size="xs" c="dimmed" truncate lh={1.4}>
|
||||
|
||||
@@ -28,7 +28,7 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
|
||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { sanitizeUrl, copyToClipboard } from "@docmost/editor-ext";
|
||||
import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext";
|
||||
import { normalizeUrl } from "@/lib/utils";
|
||||
|
||||
const parseInternalLink = (
|
||||
@@ -313,7 +313,9 @@ export default function LinkView(props: MarkViewProps) {
|
||||
);
|
||||
|
||||
const handleRemoveLink = useCallback(() => {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
if (isEditorReady(editor)) {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
}
|
||||
setPopoverState("closed");
|
||||
}, [editor]);
|
||||
|
||||
@@ -345,7 +347,7 @@ export default function LinkView(props: MarkViewProps) {
|
||||
NodeFilter.SHOW_TEXT,
|
||||
);
|
||||
const textNode = walker.nextNode();
|
||||
if (textNode) {
|
||||
if (textNode && isEditorReady(editor)) {
|
||||
const view = editor.view as any;
|
||||
view.domObserver.stop();
|
||||
textNode.nodeValue = val;
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
MentionSuggestionItem,
|
||||
} from "@/features/editor/components/mention/mention.type.ts";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { getPageTitle } from "@/features/page/page.utils";
|
||||
import {
|
||||
useCreatePageMutation,
|
||||
usePageQuery,
|
||||
@@ -103,7 +104,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
items = items.concat(
|
||||
suggestion.pages.map((page) => ({
|
||||
id: uuid7(),
|
||||
label: page.title || t("Untitled"),
|
||||
label: getPageTitle(page.title, page.isBase, t),
|
||||
entityType: "page",
|
||||
entityId: page.id,
|
||||
slugId: page.slugId,
|
||||
@@ -278,7 +279,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
|
||||
props.command({
|
||||
id: uuid7(),
|
||||
label: createdPage.title || "Untitled",
|
||||
label: getPageTitle(createdPage.title, createdPage.isBase, t),
|
||||
entityType: "page",
|
||||
entityId: createdPage.id,
|
||||
slugId: createdPage.slugId,
|
||||
|
||||
+10
-2
@@ -17,6 +17,7 @@ import {
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -64,13 +65,13 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
replaceButtonToggle();
|
||||
}
|
||||
// Clear search term in editor
|
||||
if (editor) {
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.setSearchTerm("");
|
||||
}
|
||||
};
|
||||
|
||||
const goToSelection = () => {
|
||||
if (!editor) return;
|
||||
if (!isEditorReady(editor)) return;
|
||||
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
//TODO: check type error
|
||||
@@ -90,27 +91,32 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.nextSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.previousSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const replace = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.replace();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const replaceAll = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.replaceAll();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.setSearchTerm(searchText);
|
||||
editor.commands.resetIndex();
|
||||
editor.commands.selectCurrentItem();
|
||||
@@ -118,6 +124,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
|
||||
const handleOpenEvent = (e) => {
|
||||
setPageFindState({ isOpen: true });
|
||||
if (!isEditorReady(editor)) return;
|
||||
const selectedText = editor.state.doc.textBetween(
|
||||
editor.state.selection.from,
|
||||
editor.state.selection.to,
|
||||
@@ -149,6 +156,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
}, [pageFindState.isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
|
||||
editor.commands.resetIndex();
|
||||
goToSelection();
|
||||
|
||||
@@ -5,16 +5,21 @@ import {
|
||||
} from "@/features/editor/components/slash-menu/types";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import classes from "./slash-menu.module.css";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
const CommandList = ({
|
||||
items,
|
||||
@@ -33,6 +38,14 @@ const CommandList = ({
|
||||
const [countAnnouncement, setCountAnnouncement] = useState("");
|
||||
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
|
||||
|
||||
const hasBases = useHasFeature(Feature.BASES);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
// Without the bases entitlement the item stays visible but inert; an
|
||||
// expired license the client can't detect falls through to a handled
|
||||
// create failure.
|
||||
const isItemDisabled = (item: SlashMenuItemType) =>
|
||||
!hasBases && item.requiresBases === true;
|
||||
|
||||
const flatItems = useMemo(() => {
|
||||
return Object.values(items).flat();
|
||||
}, [items]);
|
||||
@@ -40,11 +53,11 @@ const CommandList = ({
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = flatItems[index];
|
||||
if (item) {
|
||||
if (item && !isItemDisabled(item)) {
|
||||
command(item);
|
||||
}
|
||||
},
|
||||
[command, flatItems],
|
||||
[command, flatItems, hasBases],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -140,19 +153,27 @@ const CommandList = ({
|
||||
{categoryItems.map((item: SlashMenuItemType) => {
|
||||
flatIndex += 1;
|
||||
const itemIndex = flatIndex;
|
||||
const disabled = isItemDisabled(item);
|
||||
return (
|
||||
<Tooltip
|
||||
key={itemIndex}
|
||||
label={upgradeLabel}
|
||||
disabled={!disabled}
|
||||
position="right"
|
||||
>
|
||||
<UnstyledButton
|
||||
data-item-index={itemIndex}
|
||||
key={itemIndex}
|
||||
id={`slash-command-option-${itemIndex}`}
|
||||
role="option"
|
||||
aria-selected={itemIndex === selectedIndex}
|
||||
aria-disabled={disabled}
|
||||
onClick={() => selectItem(itemIndex)}
|
||||
className={clsx(classes.menuBtn, {
|
||||
[classes.selectedItem]: itemIndex === selectedIndex,
|
||||
[classes.gatedItem]: disabled,
|
||||
})}
|
||||
>
|
||||
<Group>
|
||||
<Group wrap="nowrap">
|
||||
<ActionIcon variant="default" component="div" aria-hidden="true">
|
||||
<item.icon size={18} />
|
||||
</ActionIcon>
|
||||
@@ -166,8 +187,15 @@ const CommandList = ({
|
||||
{t(item.description)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{disabled && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{t("Upgrade")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconH2,
|
||||
IconH3,
|
||||
IconInfoCircle,
|
||||
IconLayoutKanban,
|
||||
IconList,
|
||||
IconListNumbers,
|
||||
IconMath,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
IconMenu4,
|
||||
IconPageBreak,
|
||||
IconCalendar,
|
||||
IconClock,
|
||||
IconAppWindow,
|
||||
IconSitemap,
|
||||
IconColumns3,
|
||||
@@ -43,6 +45,7 @@ import IconMermaid from "@/components/icons/icon-mermaid";
|
||||
import IconDrawio from "@/components/icons/icon-drawio";
|
||||
import { IconColumns4 } from "@/components/icons/icon-columns-4";
|
||||
import { IconColumns5 } from "@/components/icons/icon-columns-5";
|
||||
import i18n from "@/i18n.ts";
|
||||
import {
|
||||
AirtableIcon,
|
||||
FigmaIcon,
|
||||
@@ -55,6 +58,7 @@ import {
|
||||
VimeoIcon,
|
||||
YoutubeIcon,
|
||||
} from "@/components/icons";
|
||||
import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed";
|
||||
|
||||
const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
basic: [
|
||||
@@ -357,6 +361,26 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: "Base (Inline)",
|
||||
description: "Insert an inline base on this page",
|
||||
searchTerms: ["base", "database", "table", "grid", "spreadsheet"],
|
||||
icon: IconTable,
|
||||
requiresBases: true,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertBaseEmbedBlock(editor, { range });
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Kanban",
|
||||
description: "Insert a kanban board on this page",
|
||||
searchTerms: ["kanban", "board", "cards", "status", "task", "database"],
|
||||
icon: IconLayoutKanban,
|
||||
requiresBases: true,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertBaseEmbedBlock(editor, { range, template: "kanban" });
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Toggle block",
|
||||
description: "Insert collapsible block.",
|
||||
@@ -459,7 +483,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
searchTerms: ["date", "today"],
|
||||
icon: IconCalendar,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
const currentDate = new Date().toLocaleDateString("en-US", {
|
||||
const currentDate = new Date().toLocaleDateString(i18n.language, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
@@ -473,6 +497,25 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Time",
|
||||
description: "Insert current time",
|
||||
searchTerms: ["time", "now", "clock"],
|
||||
icon: IconClock,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
const currentTime = new Date().toLocaleTimeString(i18n.language, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
});
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent(currentTime)
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
description: "Insert inline status badge.",
|
||||
@@ -766,18 +809,34 @@ export const getSuggestionItems = ({
|
||||
for (const [group, items] of Object.entries(CommandGroups)) {
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (excludeItems?.has(item.title)) return false;
|
||||
const translatedTitle = i18n.t(item.title);
|
||||
const translatedDescription = i18n.t(item.description);
|
||||
return (
|
||||
fuzzyMatch(search, item.title) ||
|
||||
fuzzyMatch(search, translatedTitle) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
translatedDescription.toLowerCase().includes(search) ||
|
||||
(item.searchTerms &&
|
||||
item.searchTerms.some((term: string) => term.includes(search)))
|
||||
item.searchTerms.some(
|
||||
(term: string) =>
|
||||
term.includes(search) ||
|
||||
i18n.t(term).toLowerCase().includes(search),
|
||||
))
|
||||
);
|
||||
});
|
||||
|
||||
if (filteredItems.length) {
|
||||
filteredGroups[group] = filteredItems.sort((a, b) => {
|
||||
const aTitle = a.title.toLowerCase().includes(search) ? 0 : 1;
|
||||
const bTitle = b.title.toLowerCase().includes(search) ? 0 : 1;
|
||||
const aTitle =
|
||||
a.title.toLowerCase().includes(search) ||
|
||||
i18n.t(a.title).toLowerCase().includes(search)
|
||||
? 0
|
||||
: 1;
|
||||
const bTitle =
|
||||
b.title.toLowerCase().includes(search) ||
|
||||
i18n.t(b.title).toLowerCase().includes(search)
|
||||
? 0
|
||||
: 1;
|
||||
return aTitle - bTitle;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,3 +25,7 @@
|
||||
background: var(--mantine-color-gray-light);
|
||||
}
|
||||
}
|
||||
|
||||
.gatedItem {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export type SlashMenuItemType = {
|
||||
searchTerms: string[];
|
||||
command: (props: CommandProps) => void;
|
||||
disable?: (editor: ReturnType<typeof useEditor>) => boolean;
|
||||
requiresBases?: true;
|
||||
};
|
||||
|
||||
export type SlashMenuGroupedItemsType = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, type JSX } from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -25,7 +25,7 @@ const recalculateLinks = (nodePos: NodePos[]) => {
|
||||
(acc, item) => {
|
||||
const label = item.node.textContent;
|
||||
const level = Number(item.node.attrs.level);
|
||||
if (label.length && level <= 4) {
|
||||
if (label.length && level <= 6) {
|
||||
acc.push({
|
||||
label,
|
||||
level,
|
||||
@@ -50,6 +50,7 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
||||
const headerPaddingRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleScrollToHeading = (position: number) => {
|
||||
if (!props.editor || props.editor.isDestroyed) return;
|
||||
const { view } = props.editor;
|
||||
|
||||
const headerOffset = parseInt(
|
||||
@@ -73,16 +74,21 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
||||
};
|
||||
|
||||
const handleUpdate = () => {
|
||||
const result = recalculateLinks(props.editor?.$nodes("heading"));
|
||||
if (!props.editor || props.editor.isDestroyed) return;
|
||||
|
||||
const result = recalculateLinks(props.editor.$nodes("heading"));
|
||||
|
||||
setLinks(result.links);
|
||||
setHeadingDOMNodes(result.nodes);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// "create" repopulates once the editor view mounts after this component
|
||||
props.editor?.on("create", handleUpdate);
|
||||
props.editor?.on("update", handleUpdate);
|
||||
|
||||
return () => {
|
||||
props.editor?.off("create", handleUpdate);
|
||||
props.editor?.off("update", handleUpdate);
|
||||
};
|
||||
}, [props.editor]);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Menu, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCellSelection } from "@docmost/editor-ext";
|
||||
import { isCellSelection, isEditorReady } from "@docmost/editor-ext";
|
||||
import { CellChevronMenu } from "./menus/cell-chevron-menu";
|
||||
import classes from "./handle.module.css";
|
||||
|
||||
@@ -27,7 +27,9 @@ export const CellChevron = React.memo(function CellChevron({
|
||||
tablePos,
|
||||
}: CellChevronProps) {
|
||||
const { t } = useTranslation();
|
||||
const cellDom = editor.view.nodeDOM(cellPos) as HTMLElement | null;
|
||||
const cellDom = isEditorReady(editor)
|
||||
? (editor.view.nodeDOM(cellPos) as HTMLElement | null)
|
||||
: null;
|
||||
|
||||
const { refs, floatingStyles, middlewareData } = useFloating({
|
||||
placement: "top-end",
|
||||
@@ -61,6 +63,7 @@ export const CellChevron = React.memo(function CellChevron({
|
||||
});
|
||||
|
||||
const onOpen = useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
const current = editor.state.selection;
|
||||
|
||||
// Preserve an existing multi-cell CellSelection that already covers
|
||||
@@ -86,6 +89,7 @@ export const CellChevron = React.memo(function CellChevron({
|
||||
}, [editor, cellPos]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.unfreezeHandles();
|
||||
}, [editor]);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { isEditorReady } from "@docmost/editor-ext";
|
||||
import classes from "./handle.module.css";
|
||||
|
||||
interface ColumnHandleProps {
|
||||
@@ -35,7 +36,9 @@ export const ColumnHandle = React.memo(function ColumnHandle({
|
||||
// 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 lookupDom = isEditorReady(editor)
|
||||
? editor.view.nodeDOM(anchorPos)
|
||||
: null;
|
||||
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
|
||||
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
||||
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
||||
|
||||
+3
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import { buildRowOrColumnSelection, Orientation } from "../lib/select-row-column";
|
||||
|
||||
interface Args {
|
||||
@@ -19,6 +20,7 @@ export function useColumnRowMenuLifecycle({
|
||||
tablePos,
|
||||
}: Args) {
|
||||
const onOpen = useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
const selection = buildRowOrColumnSelection(
|
||||
editor.state,
|
||||
tableNode,
|
||||
@@ -33,6 +35,7 @@ export function useColumnRowMenuLifecycle({
|
||||
}, [editor, orientation, index, tableNode, tablePos]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.unfreezeHandles();
|
||||
}, [editor]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
|
||||
type Scope =
|
||||
| { kind: "col"; index: number }
|
||||
@@ -15,6 +16,7 @@ export function useTableClear(
|
||||
scope: Scope,
|
||||
) {
|
||||
return useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
const tr = editor.state.tr;
|
||||
const tableStart = tablePos + 1;
|
||||
const map = TableMap.get(tableNode);
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { moveColumn, moveRow } from "@docmost/editor-ext";
|
||||
import { isEditorReady, moveColumn, moveRow } from "@docmost/editor-ext";
|
||||
|
||||
export type MoveDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
@@ -25,7 +25,7 @@ export function useTableMoveRowColumn(
|
||||
const canMove = target >= 0 && target <= maxIndex;
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!canMove) return;
|
||||
if (!canMove || !isEditorReady(editor)) return;
|
||||
const tr = editor.state.tr;
|
||||
const moved =
|
||||
orientation === "col"
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
convertArrayOfRowsToTableNode,
|
||||
convertTableNodeToArrayOfRows,
|
||||
isEditorReady,
|
||||
transpose,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
@@ -63,7 +64,7 @@ export function useTableSort({
|
||||
}, [tableNode, orientation, index]);
|
||||
|
||||
const handleSort = useCallback(() => {
|
||||
if (!canSort) return;
|
||||
if (!canSort || !isEditorReady(editor)) return;
|
||||
|
||||
const rows = convertTableNodeToArrayOfRows(tableNode);
|
||||
const axes = orientation === "col" ? rows : transpose(rows);
|
||||
|
||||
@@ -101,14 +101,14 @@ export const CellChevronMenu = React.memo(function CellChevronMenu({
|
||||
<Menu.Item
|
||||
leftSection={<IconBoxMargin size={16} />}
|
||||
onClick={() => editor.chain().focus().mergeCells().run()}
|
||||
disabled={!editor.can().mergeCells()}
|
||||
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()}
|
||||
disabled={!editor?.can().splitCell()}
|
||||
>
|
||||
{t("Split cell")}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { isEditorReady } from "@docmost/editor-ext";
|
||||
import classes from "./handle.module.css";
|
||||
|
||||
interface RowHandleProps {
|
||||
@@ -33,7 +34,9 @@ export const RowHandle = React.memo(function RowHandle({
|
||||
// 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 lookupDom = isEditorReady(editor)
|
||||
? editor.view.nodeDOM(anchorPos)
|
||||
: null;
|
||||
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
|
||||
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
||||
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, type JSX } from "react";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, type JSX } from "react";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
|
||||
+1
@@ -105,6 +105,7 @@ function TransclusionReferenceBody({
|
||||
sourcePageId,
|
||||
transclusionId,
|
||||
});
|
||||
if (editor.isDestroyed) return;
|
||||
const pos = getPos();
|
||||
if (typeof pos !== "number") return;
|
||||
const from = pos;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
|
||||
export const CleanStyles = Extension.create({
|
||||
name: "cleanStyles",
|
||||
priority: 80,
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("cleanStyles"),
|
||||
props: {
|
||||
transformPastedHTML(html) {
|
||||
return html.replace(/\s+style="[^"]*"/gi, "");
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -34,6 +34,8 @@ export interface GlobalDragHandleOptions {
|
||||
* Custom nodes to be included for drag handle
|
||||
*/
|
||||
customNodes: string[];
|
||||
|
||||
atomNodes: string[];
|
||||
}
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
@@ -76,6 +78,10 @@ function nodeDOMAtCoords(
|
||||
`[data-type=${node}] p`,
|
||||
`.node-${node} p`,
|
||||
]);
|
||||
const atomSelectors = options.atomNodes.flatMap((node) => [
|
||||
`[data-type=${node}]`,
|
||||
`.node-${node}`,
|
||||
]);
|
||||
|
||||
const selectors = [
|
||||
"li",
|
||||
@@ -95,8 +101,9 @@ function nodeDOMAtCoords(
|
||||
".tableWrapper",
|
||||
...customParagraphSelectors,
|
||||
...customSelectors,
|
||||
...atomSelectors,
|
||||
].join(", ");
|
||||
return document
|
||||
const found = document
|
||||
.elementsFromPoint(coords.x, coords.y)
|
||||
.find((elem: Element) => {
|
||||
// Skip elements that belong to a nested editor (e.g. transclusion
|
||||
@@ -108,6 +115,11 @@ function nodeDOMAtCoords(
|
||||
elem.matches(selectors)
|
||||
);
|
||||
});
|
||||
if (found && atomSelectors.length > 0) {
|
||||
const atomWrapper = found.closest(atomSelectors.join(", "));
|
||||
if (atomWrapper) return atomWrapper;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
function nodePosAtDOM(
|
||||
node: Element,
|
||||
@@ -127,7 +139,7 @@ function isCustomNodeDOM(
|
||||
options: GlobalDragHandleOptions,
|
||||
): boolean {
|
||||
if (!elem) return false;
|
||||
for (const name of options.customNodes) {
|
||||
for (const name of [...options.customNodes, ...options.atomNodes]) {
|
||||
if (
|
||||
elem.getAttribute("data-type") === name ||
|
||||
elem.classList.contains(`node-${name}`)
|
||||
@@ -210,7 +222,10 @@ export function DragHandlePlugin(
|
||||
// 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);
|
||||
const customTypes = new Set([
|
||||
...options.customNodes,
|
||||
...options.atomNodes,
|
||||
]);
|
||||
for (let d = $sel.depth; d > 0; d--) {
|
||||
if (customTypes.has($sel.node(d).type.name)) {
|
||||
selection = NodeSelection.create(
|
||||
@@ -264,7 +279,23 @@ export function DragHandlePlugin(
|
||||
event.dataTransfer.setData("text/plain", text);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
const previewTemplate =
|
||||
node.querySelector<HTMLElement>("[data-drag-preview]");
|
||||
if (previewTemplate) {
|
||||
const preview = previewTemplate.cloneNode(true) as HTMLElement;
|
||||
preview.removeAttribute("hidden");
|
||||
preview.style.position = "fixed";
|
||||
preview.style.top = "0";
|
||||
preview.style.left = "-10000px";
|
||||
preview.style.pointerEvents = "none";
|
||||
document.body.appendChild(preview);
|
||||
event.dataTransfer.setDragImage(preview, 0, 0);
|
||||
document.addEventListener("dragend", () => preview.remove(), {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
}
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
@@ -497,6 +528,7 @@ const GlobalDragHandle = Extension.create({
|
||||
scrollThreshold: 100,
|
||||
excludedTags: [],
|
||||
customNodes: [],
|
||||
atomNodes: [],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -509,6 +541,7 @@ const GlobalDragHandle = Extension.create({
|
||||
dragHandleSelector: this.options.dragHandleSelector,
|
||||
excludedTags: this.options.excludedTags,
|
||||
customNodes: this.options.customNodes,
|
||||
atomNodes: this.options.atomNodes,
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
@@ -3,7 +3,8 @@ import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Code } from "@tiptap/extension-code";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||
import { Placeholder, CharacterCount } from "@tiptap/extensions";
|
||||
import { CharacterCount, UndoRedo } from "@tiptap/extensions";
|
||||
import { Placeholder } from "@/features/editor/extensions/placeholder";
|
||||
import { Superscript } from "@tiptap/extension-superscript";
|
||||
import SubScript from "@tiptap/extension-subscript";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
TableView,
|
||||
BaseEmbed as BaseEmbedNode,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -93,6 +95,7 @@ import SubpagesView from "@/features/editor/components/subpages/subpages-view.ts
|
||||
import IntegrationLinkView from "@/features/editor/components/integration-link/integration-link-view.tsx";
|
||||
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
|
||||
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
|
||||
import { BaseEmbedView } from "@/features/editor/components/base-embed/base-embed-view.tsx";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
@@ -114,6 +117,7 @@ import EmojiCommand from "./emoji-command";
|
||||
import { countWords } from "alfaaz";
|
||||
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
|
||||
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
|
||||
import { CleanStyles } from "@/features/editor/extensions/clean-styles.ts";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
@@ -193,16 +197,18 @@ export const mainExtensions = [
|
||||
return i18n.t("Toggle title");
|
||||
}
|
||||
if (node.type.name === "paragraph") {
|
||||
const $pos = editor.state.doc.resolve(pos);
|
||||
const parentName = $pos.parent.type.name;
|
||||
if (
|
||||
parentName === "column" ||
|
||||
parentName === "tableCell" ||
|
||||
parentName === "tableHeader" ||
|
||||
parentName === "callout" ||
|
||||
parentName === "blockquote"
|
||||
) {
|
||||
return i18n.t("Write...");
|
||||
const doc = editor.state.doc;
|
||||
if (pos >= 0 && pos <= doc.content.size) {
|
||||
const parentName = doc.resolve(pos).parent.type.name;
|
||||
if (
|
||||
parentName === "column" ||
|
||||
parentName === "tableCell" ||
|
||||
parentName === "tableHeader" ||
|
||||
parentName === "callout" ||
|
||||
parentName === "blockquote"
|
||||
) {
|
||||
return i18n.t("Write...");
|
||||
}
|
||||
}
|
||||
return i18n.t('Write anything. Enter "/" for commands');
|
||||
}
|
||||
@@ -232,6 +238,7 @@ export const mainExtensions = [
|
||||
TrailingNode,
|
||||
GlobalDragHandle.configure({
|
||||
customNodes: ["transclusionSource", "transclusionReference"],
|
||||
atomNodes: ["base"],
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
@@ -385,9 +392,15 @@ export const mainExtensions = [
|
||||
TransclusionReference.configure({
|
||||
view: TransclusionReferenceView,
|
||||
}),
|
||||
BaseEmbedNode.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(BaseEmbedView);
|
||||
},
|
||||
}),
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
CleanStyles,
|
||||
CharacterCount.configure({
|
||||
wordCounter: (text) => countWords(text),
|
||||
}),
|
||||
@@ -423,7 +436,9 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
|
||||
"Draw.io (diagrams.net)",
|
||||
"Excalidraw (Whiteboard)",
|
||||
"Audio",
|
||||
"Synced block"
|
||||
"Synced block",
|
||||
"Base (Inline)",
|
||||
"Kanban"
|
||||
]);
|
||||
|
||||
const TemplateSlashCommand = Command.configure({
|
||||
@@ -440,6 +455,7 @@ const TemplateSlashCommand = Command.configure({
|
||||
export const templateExtensions = [
|
||||
...mainExtensions.filter((ext: any) => ext !== SlashCommand),
|
||||
TemplateSlashCommand,
|
||||
UndoRedo,
|
||||
] as any;
|
||||
|
||||
export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { isNodeEmpty } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { Placeholder as TiptapPlaceholder } from "@tiptap/extensions";
|
||||
|
||||
export const Placeholder = TiptapPlaceholder.extend({
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
const options = this.options;
|
||||
const dataAttribute = `data-${options.dataAttribute || "placeholder"}`;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("docmostPlaceholder"),
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
if (options.showOnlyWhenEditable && !editor.isEditable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { doc, selection } = state;
|
||||
const { anchor } = selection;
|
||||
const decorations: Decoration[] = [];
|
||||
const isEmptyDoc = editor.isEmpty;
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.type.isTextblock) {
|
||||
return options.includeChildren;
|
||||
}
|
||||
|
||||
const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize;
|
||||
const isEmpty = !node.isLeaf && isNodeEmpty(node);
|
||||
|
||||
if ((hasAnchor || !options.showOnlyCurrent) && isEmpty) {
|
||||
const emptyNodeClass =
|
||||
typeof options.emptyNodeClass === "function"
|
||||
? options.emptyNodeClass({ editor, node, pos, hasAnchor })
|
||||
: options.emptyNodeClass;
|
||||
const classes = [emptyNodeClass];
|
||||
if (isEmptyDoc) {
|
||||
classes.push(options.emptyEditorClass);
|
||||
}
|
||||
|
||||
decorations.push(
|
||||
Decoration.node(pos, pos + node.nodeSize, {
|
||||
class: classes.join(" "),
|
||||
[dataAttribute]:
|
||||
typeof options.placeholder === "function"
|
||||
? options.placeholder({ editor, node, pos, hasAnchor })
|
||||
: options.placeholder,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return options.includeChildren;
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import { useAsideTriggerProps } 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";
|
||||
import { EmptyPageGetStarted } from "@/features/editor/components/empty-page/empty-page-get-started";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
@@ -90,6 +91,7 @@ export function FullEditor({
|
||||
fluid={fullPageWidth}
|
||||
size={!fullPageWidth && 900}
|
||||
className={classes.editor}
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{editorToolbarEnabled && editable && isEditMode && (
|
||||
<MemoizedFixedToolbar />
|
||||
@@ -113,6 +115,7 @@ export function FullEditor({
|
||||
content={content}
|
||||
canComment={canComment}
|
||||
/>
|
||||
<EmptyPageGetStarted pageId={pageId} editable={editable} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { getCollaborationUrl } from "@/lib/config.ts";
|
||||
|
||||
const useCollaborationURL = (): string => {
|
||||
return getCollaborationUrl();
|
||||
};
|
||||
|
||||
export default useCollaborationURL;
|
||||
@@ -42,6 +42,10 @@ export const useEditorScroll = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor.isDestroyed) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const dom = editor.view.dom.querySelector(`[id="${targetId}"], [data-id="${targetId}"]`);
|
||||
if (dom) {
|
||||
dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
@@ -2,19 +2,22 @@ import "@/features/editor/styles/index.css";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
HocuspocusProvider,
|
||||
onStatusParameters,
|
||||
WebSocketStatus,
|
||||
HocuspocusProviderWebsocket,
|
||||
onSyncedParameters,
|
||||
onStatelessParameters,
|
||||
} from "@hocuspocus/provider";
|
||||
import {
|
||||
HocuspocusProviderWebsocketComponent,
|
||||
HocuspocusRoom,
|
||||
useHocuspocusEvent,
|
||||
useHocuspocusProvider,
|
||||
} from "@hocuspocus/provider-react";
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
@@ -27,12 +30,12 @@ import {
|
||||
mainExtensions,
|
||||
} from "@/features/editor/extensions/extensions";
|
||||
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,
|
||||
yjsSyncedAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import {
|
||||
@@ -74,6 +77,11 @@ import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
acquireCollabSocket,
|
||||
getCollabSocket,
|
||||
releaseCollabSocket,
|
||||
} from "@/features/editor/collab-socket";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -89,7 +97,80 @@ export default function PageEditor({
|
||||
canComment,
|
||||
}: PageEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const [socket] = useState(getCollabSocket);
|
||||
const hasCollabToken = !!collabQuery?.token;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasCollabToken) return;
|
||||
acquireCollabSocket();
|
||||
return () => releaseCollabSocket();
|
||||
}, [hasCollabToken]);
|
||||
|
||||
const handleStateless = ({ payload }: onStatelessParameters) => {
|
||||
try {
|
||||
const message = JSON.parse(payload);
|
||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||
if (pageData) {
|
||||
queryClient.setQueryData(["pages", slugId], {
|
||||
...pageData,
|
||||
updatedAt: message.updatedAt,
|
||||
...(message.lastUpdatedBy && {
|
||||
lastUpdatedBy: message.lastUpdatedBy,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore unrelated stateless messages
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthenticationFailed = () => {
|
||||
const payload = jwtDecode(collabQuery?.token);
|
||||
const now = Date.now().valueOf() / 1000;
|
||||
const isTokenExpired = now >= payload.exp;
|
||||
if (isTokenExpired) {
|
||||
refetchCollabToken();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TransclusionLookupProvider>
|
||||
{collabQuery?.token ? (
|
||||
<HocuspocusProviderWebsocketComponent websocketProvider={socket}>
|
||||
<HocuspocusRoom
|
||||
name={`page.${pageId}`}
|
||||
token={collabQuery.token}
|
||||
flushDelay={500}
|
||||
onStateless={handleStateless}
|
||||
onAuthenticationFailed={handleAuthenticationFailed}
|
||||
>
|
||||
<CollabPageEditor
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
content={content}
|
||||
canComment={canComment}
|
||||
/>
|
||||
</HocuspocusRoom>
|
||||
</HocuspocusProviderWebsocketComponent>
|
||||
) : (
|
||||
<StaticPageEditor content={content} ariaLabel={t("Page content")} />
|
||||
)}
|
||||
</TransclusionLookupProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function CollabPageEditor({
|
||||
pageId,
|
||||
editable,
|
||||
content,
|
||||
canComment,
|
||||
}: PageEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const provider = useHocuspocusProvider();
|
||||
const isComponentMounted = useRef(false);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
@@ -108,8 +189,8 @@ export default function PageEditor({
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
yjsConnectionStatusAtom,
|
||||
);
|
||||
const [, setYjsSynced] = useAtom(yjsSyncedAtom);
|
||||
const menuContainerRef = useRef(null);
|
||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||
const documentState = useDocumentVisibility();
|
||||
const { pageSlug } = useParams();
|
||||
@@ -120,76 +201,24 @@ export default function PageEditor({
|
||||
[isComponentMounted],
|
||||
);
|
||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||
// Providers only created once per pageId
|
||||
const providersRef = useRef<{
|
||||
local: IndexeddbPersistence;
|
||||
remote: HocuspocusProvider;
|
||||
socket: HocuspocusProviderWebsocket;
|
||||
} | null>(null);
|
||||
const [providersReady, setProvidersReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providersRef.current) {
|
||||
const documentName = `page.${pageId}`;
|
||||
const ydoc = new Y.Doc();
|
||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||
const socket = new HocuspocusProviderWebsocket({
|
||||
url: collaborationURL,
|
||||
});
|
||||
const onLocalSyncedHandler = () => {
|
||||
setIsLocalSynced(true);
|
||||
};
|
||||
const onStatusHandler = (event: onStatusParameters) => {
|
||||
setYjsConnectionStatus(event.status);
|
||||
};
|
||||
const onSyncedHandler = (event: onSyncedParameters) => {
|
||||
setIsRemoteSynced(event.state);
|
||||
};
|
||||
const onAuthenticationFailedHandler = () => {
|
||||
const payload = jwtDecode(collabQuery?.token);
|
||||
const now = Date.now().valueOf() / 1000;
|
||||
const isTokenExpired = now >= payload.exp;
|
||||
if (isTokenExpired) {
|
||||
refetchCollabToken().then((result) => {
|
||||
if (result.data?.token) {
|
||||
socket.disconnect();
|
||||
setTimeout(() => {
|
||||
remote.configuration.token = result.data.token;
|
||||
socket.connect();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const remote = new HocuspocusProvider({
|
||||
websocketProvider: socket,
|
||||
name: documentName,
|
||||
document: ydoc,
|
||||
token: collabQuery?.token,
|
||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
||||
onStatus: onStatusHandler,
|
||||
onSynced: onSyncedHandler,
|
||||
});
|
||||
|
||||
local.on("synced", onLocalSyncedHandler);
|
||||
providersRef.current = { socket, local, remote };
|
||||
setProvidersReady(true);
|
||||
} else {
|
||||
setProvidersReady(true);
|
||||
}
|
||||
// Only destroy on final unmount
|
||||
const local = new IndexeddbPersistence(
|
||||
provider.configuration.name,
|
||||
provider.document,
|
||||
);
|
||||
local.on("synced", () => setIsLocalSynced(true));
|
||||
return () => {
|
||||
providersRef.current?.socket.destroy();
|
||||
providersRef.current?.remote.destroy();
|
||||
providersRef.current?.local.destroy();
|
||||
providersRef.current = null;
|
||||
local.destroy();
|
||||
};
|
||||
}, [pageId]);
|
||||
}, [provider]);
|
||||
|
||||
useHocuspocusEvent("synced", ({ state }) => setIsRemoteSynced(state));
|
||||
useHocuspocusEvent("status", ({ status }) => setYjsConnectionStatus(status));
|
||||
|
||||
// Only connect/disconnect on tab/idle, not destroy
|
||||
useEffect(() => {
|
||||
if (!providersReady || !providersRef.current) return;
|
||||
const socket = providersRef.current.socket;
|
||||
const socket = provider.configuration.websocketProvider;
|
||||
|
||||
if (
|
||||
isIdle &&
|
||||
@@ -206,23 +235,15 @@ export default function PageEditor({
|
||||
resetIdle();
|
||||
socket.connect();
|
||||
}
|
||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||
|
||||
// Attach here, to make sure the connection gets properly established
|
||||
providersRef.current?.remote.attach();
|
||||
}, [isIdle, documentState, provider, resetIdle]);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
||||
if (!currentUser?.user) {
|
||||
return mainExtensions;
|
||||
}
|
||||
|
||||
const remoteProvider = providersRef.current.remote;
|
||||
|
||||
return [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider, currentUser?.user),
|
||||
];
|
||||
}, [providersReady, currentUser?.user]);
|
||||
return [...mainExtensions, ...collabExtensions(provider, currentUser.user)];
|
||||
}, [provider, currentUser?.user]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
@@ -304,6 +325,16 @@ export default function PageEditor({
|
||||
[pageId, editable, extensions],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (editor && !editor.isDestroyed) {
|
||||
// @ts-ignore
|
||||
setEditor(editor);
|
||||
// @ts-ignore
|
||||
editor.storage.pageId = pageId;
|
||||
editorRef.current = editor;
|
||||
}
|
||||
}, [editor, pageId, setEditor]);
|
||||
|
||||
const editorIsEditable = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
@@ -318,7 +349,6 @@ export default function PageEditor({
|
||||
queryClient.setQueryData(["pages", slugId], {
|
||||
...pageData,
|
||||
content: newContent,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}, 3000);
|
||||
@@ -359,6 +389,14 @@ export default function PageEditor({
|
||||
|
||||
const isSynced = isLocalSynced && isRemoteSynced;
|
||||
|
||||
useEffect(() => {
|
||||
setYjsSynced(isSynced);
|
||||
}, [isSynced, setYjsSynced]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setYjsSynced(false);
|
||||
}, [setYjsSynced]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||
@@ -387,63 +425,72 @@ export default function PageEditor({
|
||||
}
|
||||
}, [yjsConnectionStatus, isSynced]);
|
||||
|
||||
if (showStatic) {
|
||||
return <StaticPageEditor content={content} ariaLabel={t("Page content")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TransclusionLookupProvider>
|
||||
{showStatic ? (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
editorProps={{
|
||||
attributes: {
|
||||
"aria-label": t("Page content"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="editor-container" style={{ position: "relative" }}>
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
<div className="editor-container" style={{ position: "relative" }}>
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{editor && (
|
||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||
)}
|
||||
{editor && (
|
||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||
)}
|
||||
|
||||
{editor && editorIsEditable && (
|
||||
<div>
|
||||
<EditorAiMenu editor={editor} />
|
||||
<EditorLinkMenu editor={editor} />
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
<TableMenu editor={editor} />
|
||||
<TableHandlesLayer editor={editor} />
|
||||
<ImageMenu editor={editor} />
|
||||
<VideoMenu editor={editor} />
|
||||
<PdfMenu editor={editor} />
|
||||
<CalloutMenu editor={editor} />
|
||||
<SubpagesMenu editor={editor} />
|
||||
<ExcalidrawMenu editor={editor} />
|
||||
<DrawioMenu editor={editor} />
|
||||
<ColumnsMenu editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
{editor &&
|
||||
!editorIsEditable &&
|
||||
(editable || canComment) &&
|
||||
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
|
||||
{showCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} />
|
||||
)}
|
||||
{showReadOnlyCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
||||
)}
|
||||
{editor && editorIsEditable && (
|
||||
<div>
|
||||
<EditorAiMenu editor={editor} />
|
||||
<EditorLinkMenu editor={editor} />
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
<TableMenu editor={editor} />
|
||||
<TableHandlesLayer editor={editor} />
|
||||
<ImageMenu editor={editor} />
|
||||
<VideoMenu editor={editor} />
|
||||
<PdfMenu editor={editor} />
|
||||
<CalloutMenu editor={editor} />
|
||||
<SubpagesMenu editor={editor} />
|
||||
<ExcalidrawMenu editor={editor} />
|
||||
<DrawioMenu editor={editor} />
|
||||
<ColumnsMenu editor={editor} />
|
||||
</div>
|
||||
<div
|
||||
onClick={() => editor.commands.focus("end")}
|
||||
style={{ paddingBottom: "20vh" }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</TransclusionLookupProvider>
|
||||
)}
|
||||
{editor && !editorIsEditable && (editable || canComment) && (
|
||||
<ReadonlyBubbleMenu editor={editor} />
|
||||
)}
|
||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||
{showReadOnlyCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (editor && !editor.isDestroyed) editor.commands.focus("end");
|
||||
}}
|
||||
style={{ paddingBottom: "20vh" }}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StaticPageEditor({
|
||||
content,
|
||||
ariaLabel,
|
||||
}: {
|
||||
content: any;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
editorProps={{
|
||||
attributes: {
|
||||
"aria-label": ariaLabel,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ interface PageEditorProps {
|
||||
title: string;
|
||||
content: any;
|
||||
pageId?: string;
|
||||
printMode?: boolean;
|
||||
/**
|
||||
* When rendering inside a public share, pass the share's id (or key). Lookups
|
||||
* for transclusion content then resolve against the share graph instead of
|
||||
@@ -28,6 +29,7 @@ export default function ReadonlyPageEditor({
|
||||
title,
|
||||
content,
|
||||
pageId,
|
||||
printMode = false,
|
||||
shareId,
|
||||
}: PageEditorProps) {
|
||||
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
||||
@@ -48,8 +50,12 @@ export default function ReadonlyPageEditor({
|
||||
}, []);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const excludedExtensions = new Set([
|
||||
"uniqueID",
|
||||
...(printMode ? ["tableHeaderPin", "tableReadonlySort"] : []),
|
||||
]);
|
||||
const filteredExtensions = mainExtensions.filter(
|
||||
(ext) => ext.name !== "uniqueID",
|
||||
(ext) => !excludedExtensions.has(ext.name),
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -59,7 +65,7 @@ export default function ReadonlyPageEditor({
|
||||
updateDocument: false,
|
||||
}),
|
||||
];
|
||||
}, []);
|
||||
}, [printMode]);
|
||||
|
||||
const titleExtensions = [
|
||||
Document.extend({
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.ProseMirror {
|
||||
.node-base {
|
||||
/* Suppress the default ProseMirror atom-node selection outline —
|
||||
* the embed reads as a document block, not a focused widget. */
|
||||
&.ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Page-reference pills are self-contained chips. The editor's `a` rule
|
||||
* would otherwise add a link underline + bold weight on top of the chip,
|
||||
* making it look different from a standalone base. Keep it a plain chip. */
|
||||
a.pagePill {
|
||||
border-bottom: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,13 +103,13 @@
|
||||
margin: 0;
|
||||
|
||||
@mixin where-light {
|
||||
background-color: var(--code-bg, var(--mantine-color-gray-1));
|
||||
color: var(--mantine-color-pink-7);
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
@mixin where-dark {
|
||||
background-color: var(--mantine-color-dark-8);
|
||||
color: var(--mantine-color-pink-7);
|
||||
background-color: var(--mantine-color-dark-5) !important;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
:root {
|
||||
/* Height of the fixed PageHeader at the top of every page. Used by
|
||||
* standalone base layout (paddingTop) and by sticky bands inside
|
||||
* inline base embeds (top offset). One source of truth; if the
|
||||
* page header ever changes height, edit only this. */
|
||||
--page-header-height: 45px;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-white),
|
||||
@@ -286,3 +294,24 @@
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
/* The full-page base view positions its title with its own outer
|
||||
* wrapper padding (so it can align with the table below). The global
|
||||
* 3rem .ProseMirror padding-x would push the title further in than
|
||||
* the table — drop it inside the base title wrapper only. */
|
||||
.base-page-title .ProseMirror {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Definite height so the inner .tableScrollport scrolls and the sticky table
|
||||
* header pins. Flow on print, else it emits a trailing blank page. */
|
||||
.base-page-root {
|
||||
height: calc(100dvh - var(--app-shell-header-height, 45px));
|
||||
}
|
||||
|
||||
@media print {
|
||||
.base-page-root {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
.editor {
|
||||
height: 100%;
|
||||
min-height: calc(100dvh - var(--app-shell-header-height, 45px) - 96px);
|
||||
padding: 8px 0;
|
||||
margin: 48px auto;
|
||||
|
||||
@media print {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,3 +17,4 @@
|
||||
@import "./indent.css";
|
||||
@import "./columns.css";
|
||||
@import "./status.css";
|
||||
@import "./base-embed.css";
|
||||
|
||||
@@ -163,8 +163,13 @@
|
||||
|
||||
@media print {
|
||||
.tableWrapper.tableHeaderPinned table tr:first-child {
|
||||
position: static;
|
||||
transform: none;
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.tableReadonlySortChevron {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,10 +209,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ProseMirror table th:has(.tableReadonlySortChevron) {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.tableReadonlySortChevron:hover {
|
||||
background: light-dark(
|
||||
rgba(55, 53, 47, 0.16),
|
||||
@@ -272,4 +273,4 @@
|
||||
.prosemirror-dropcursor-inline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface TitleEditorProps {
|
||||
title: string;
|
||||
spaceSlug: string;
|
||||
editable: boolean;
|
||||
isBase?: boolean;
|
||||
}
|
||||
|
||||
export function TitleEditor({
|
||||
@@ -43,6 +44,7 @@ export function TitleEditor({
|
||||
title,
|
||||
spaceSlug,
|
||||
editable,
|
||||
isBase,
|
||||
}: TitleEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
||||
@@ -64,7 +66,7 @@ export function TitleEditor({
|
||||
}),
|
||||
Text,
|
||||
Placeholder.configure({
|
||||
placeholder: t("Untitled"),
|
||||
placeholder: isBase ? t("Untitled base") : t("Untitled"),
|
||||
showOnlyWhenEditable: false,
|
||||
}),
|
||||
History.configure({
|
||||
@@ -106,11 +108,17 @@ export function TitleEditor({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const anchorId = window.location.hash
|
||||
? window.location.hash.substring(1)
|
||||
: undefined;
|
||||
const pageSlug = buildPageUrl(spaceSlug, slugId, title, anchorId);
|
||||
navigate(pageSlug, { replace: true });
|
||||
// Canonicalize only the path slug; keep query params (?row=, ?view=
|
||||
// deep links) and the hash anchor intact.
|
||||
const pageSlug = buildPageUrl(spaceSlug, slugId, title);
|
||||
navigate(
|
||||
{
|
||||
pathname: pageSlug,
|
||||
search: window.location.search,
|
||||
hash: window.location.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [title]);
|
||||
|
||||
const saveTitle = useCallback(() => {
|
||||
@@ -152,7 +160,11 @@ export function TitleEditor({
|
||||
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (titleEditor && title !== titleEditor.getText()) {
|
||||
if (
|
||||
titleEditor &&
|
||||
!titleEditor.isDestroyed &&
|
||||
title !== titleEditor.getText()
|
||||
) {
|
||||
titleEditor.commands.setContent(title);
|
||||
}
|
||||
}, [pageId, title, titleEditor]);
|
||||
|
||||
@@ -14,6 +14,7 @@ export type IFavorite = {
|
||||
slugId: string;
|
||||
title: string;
|
||||
icon: string | null;
|
||||
isBase: boolean;
|
||||
spaceId: string;
|
||||
};
|
||||
space?: {
|
||||
|
||||
@@ -91,7 +91,9 @@ export default function GroupMembersList() {
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
aria-label={t("Member actions")}
|
||||
aria-label={t("Member actions for {{name}}", {
|
||||
name: user.name,
|
||||
})}
|
||||
>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -4,15 +4,15 @@ import {
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
ThemeIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import { useCreatedByQuery } from "@/features/page/queries/page-query";
|
||||
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
|
||||
import { PageListIcon } from "@/components/common/page-list-icon";
|
||||
import { IconFiles } from "@tabler/icons-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -62,17 +62,9 @@ export default function CreatedByMe({ spaceId }: Props) {
|
||||
)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{page.icon || (
|
||||
<ThemeIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<PageListIcon icon={page.icon} isBase={page.isBase} />
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || t("Untitled")}
|
||||
{getPageTitle(page.title, page.isBase, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
@@ -4,15 +4,15 @@ import {
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
ThemeIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
|
||||
import { IconFileDescription, IconStar } from "@tabler/icons-react";
|
||||
import { PageListIcon } from "@/components/common/page-list-icon";
|
||||
import { IconStar } from "@tabler/icons-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -63,17 +63,12 @@ export default function FavoritesPages({ spaceId }: Props) {
|
||||
)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{fav.page.icon || (
|
||||
<ThemeIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<PageListIcon
|
||||
icon={fav.page.icon}
|
||||
isBase={fav.page.isBase}
|
||||
/>
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{fav.page.title || t("Untitled")}
|
||||
{getPageTitle(fav.page.title, fav.page.isBase, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { format, isThisYear, isToday, isYesterday } from "date-fns";
|
||||
import { isThisYear, isToday, isYesterday } from "date-fns";
|
||||
import i18n from "@/i18n.ts";
|
||||
import { formatLocalized, getDateFnsLocale } from "@/lib/date-locale.ts";
|
||||
|
||||
export function formatLabelListDate(date: Date): string {
|
||||
const locale = getDateFnsLocale();
|
||||
if (isToday(date)) {
|
||||
return i18n.t("Today, {{time}}", { time: format(date, "h:mma") });
|
||||
return i18n.t("Today, {{time}}", {
|
||||
time: formatLocalized(date, "h:mma", "p", locale),
|
||||
});
|
||||
}
|
||||
if (isYesterday(date)) {
|
||||
return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") });
|
||||
return i18n.t("Yesterday, {{time}}", {
|
||||
time: formatLocalized(date, "h:mma", "p", locale),
|
||||
});
|
||||
}
|
||||
if (isThisYear(date)) {
|
||||
return format(date, "MMM dd");
|
||||
if (locale.code?.startsWith("en")) {
|
||||
return formatLocalized(date, "MMM dd", "MMM dd", locale);
|
||||
}
|
||||
return new Intl.DateTimeFormat(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
return format(date, "MMM dd, yyyy");
|
||||
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useMarkReadMutation } from "../queries/notification-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
|
||||
import { formatRelativeTime } from "../notification.utils";
|
||||
import classes from "../notification.module.css";
|
||||
|
||||
@@ -143,7 +143,7 @@ export function NotificationItem({
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{notification.page.title || t("Untitled")}
|
||||
{getPageTitle(notification.page.title, undefined, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
@@ -31,6 +31,7 @@ import classes from "../notification.module.css";
|
||||
|
||||
export function NotificationPopover() {
|
||||
const { t } = useTranslation();
|
||||
const titleId = useId();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [tab, setTab] = useState<NotificationTab>("direct");
|
||||
const [filter, setFilter] = useState<NotificationFilter>("all");
|
||||
@@ -83,10 +84,11 @@ export function NotificationPopover() {
|
||||
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
aria-labelledby={titleId}
|
||||
style={{ width: "min(420px, calc(100vw - 24px))" }}
|
||||
>
|
||||
<Group justify="space-between" px="md" py="sm">
|
||||
<Title order={2} fz="sm" fw={600}>
|
||||
<Title id={titleId} order={2} fz="sm" fw={600}>
|
||||
{t("Notifications")}
|
||||
</Title>
|
||||
<Group gap={4}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import i18n from "@/i18n.ts";
|
||||
import { INotification } from "./types/notification.types";
|
||||
|
||||
export function formatRelativeTime(dateStr: string): string {
|
||||
@@ -8,15 +9,15 @@ export function formatRelativeTime(dateStr: string): string {
|
||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
|
||||
if (diffMin < 1) return "now";
|
||||
if (diffMin < 1) return i18n.t("now");
|
||||
if (diffMin < 60) return `${diffMin}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
return new Intl.DateTimeFormat(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
type TimeGroup = "today" | "yesterday" | "this_week" | "older";
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
BacklinkDirection,
|
||||
IBacklinkPageItem,
|
||||
} from "@/features/page-details/types/backlink.types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
|
||||
import { getPageIcon } from "@/lib";
|
||||
|
||||
interface BacklinksListProps {
|
||||
@@ -86,7 +86,7 @@ export function BacklinksList({
|
||||
{getPageIcon(item.icon ?? "")}
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{item.title || t("Untitled")}
|
||||
{getPageTitle(item.title, undefined, t)}
|
||||
</Text>
|
||||
{item.space?.name && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
|
||||
@@ -16,7 +16,8 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
|
||||
import { BacklinksModal } from "./backlinks-modal";
|
||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { LabelsSection } from "@/features/label/components/labels-section.tsx";
|
||||
|
||||
@@ -139,6 +140,7 @@ function StatsSection({
|
||||
updatedAt: Date | string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const lastUpdated = useTimeAgo(updatedAt);
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
@@ -150,10 +152,7 @@ function StatsSection({
|
||||
label={t("Created")}
|
||||
value={formattedDate(new Date(createdAt))}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("Last updated")}
|
||||
value={timeAgo(new Date(updatedAt))}
|
||||
/>
|
||||
<StatRow label={t("Last updated")} value={lastUpdated} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function HistoryEditor({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !content) return;
|
||||
if (!editor || editor.isDestroyed || !content) return;
|
||||
|
||||
let decorationSet = DecorationSet.empty;
|
||||
let addedCount = 0;
|
||||
|
||||
@@ -42,6 +42,14 @@ export function useHistoryRestore() {
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
if (!activeHistoryData) return;
|
||||
if (
|
||||
!mainEditor ||
|
||||
mainEditor.isDestroyed ||
|
||||
!mainEditorTitle ||
|
||||
mainEditorTitle.isDestroyed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainEditorTitle
|
||||
.chain()
|
||||
|
||||
@@ -15,15 +15,17 @@ import { IconCornerDownRightDouble, IconDots } from "@tabler/icons-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import classes from "./breadcrumb.module.css";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
|
||||
import type { TFunction } from "i18next";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function getTitle(name: string, icon: string) {
|
||||
if (icon) {
|
||||
return `${icon} ${name}`;
|
||||
function getTitle(node: SpaceTreeNode, t: TFunction) {
|
||||
const name = getPageTitle(node.name, node.isBase, t);
|
||||
if (node.icon) {
|
||||
return `${node.icon} ${name}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
@@ -58,7 +60,7 @@ export default function Breadcrumb() {
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<Text fz={"sm"} className={classes.truncatedText}>
|
||||
{getTitle(node.name, node.icon)}
|
||||
{getTitle(node, t)}
|
||||
</Text>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
@@ -75,7 +77,7 @@ export default function Breadcrumb() {
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<Text fz={"sm"} className={classes.truncatedText}>
|
||||
{getTitle(node.name, node.icon)}
|
||||
{getTitle(node, t)}
|
||||
</Text>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
@@ -83,7 +85,7 @@ export default function Breadcrumb() {
|
||||
|
||||
const renderAnchor = useCallback(
|
||||
(node: SpaceTreeNode, isCurrent = false) => (
|
||||
<Tooltip label={node.name} key={node.id}>
|
||||
<Tooltip label={getPageTitle(node.name, node.isBase, t)} key={node.id}>
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
||||
@@ -93,11 +95,11 @@ export default function Breadcrumb() {
|
||||
className={classes.truncatedText}
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
>
|
||||
{getTitle(node.name, node.icon)}
|
||||
{getTitle(node, t)}
|
||||
</Anchor>
|
||||
</Tooltip>
|
||||
),
|
||||
[spaceSlug],
|
||||
[spaceSlug, t],
|
||||
);
|
||||
|
||||
const getBreadcrumbItems = () => {
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
<>
|
||||
<ConnectionWarning />
|
||||
|
||||
{!readOnly && <PageEditModeToggle size="xs" />}
|
||||
{!readOnly && !page?.isBase && <PageEditModeToggle size="xs" />}
|
||||
|
||||
<PageShareModal readOnly={readOnly} />
|
||||
|
||||
@@ -116,16 +116,18 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
aria-label={t("Table of contents")}
|
||||
{...tocTriggerProps}
|
||||
>
|
||||
<IconList size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{!page?.isBase && (
|
||||
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
aria-label={t("Table of contents")}
|
||||
{...tocTriggerProps}
|
||||
>
|
||||
<IconList size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<PageActionMenu readOnly={readOnly} />
|
||||
</>
|
||||
@@ -234,12 +236,14 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
{t("Copy link")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconMarkdown size={16} />}
|
||||
onClick={handleCopyAsMarkdown}
|
||||
>
|
||||
{t("Copy as Markdown")}
|
||||
</Menu.Item>
|
||||
{!page?.isBase && (
|
||||
<Menu.Item
|
||||
leftSection={<IconMarkdown size={16} />}
|
||||
onClick={handleCopyAsMarkdown}
|
||||
>
|
||||
{t("Copy as Markdown")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
@@ -270,22 +274,26 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
{!page?.isBase && <Menu.Divider />}
|
||||
|
||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||
<Group wrap="nowrap">
|
||||
<PageWidthToggle label={t("Full width")} />
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
{!page?.isBase && (
|
||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||
<Group wrap="nowrap">
|
||||
<PageWidthToggle label={t("Full width")} />
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={16} />}
|
||||
onClick={openHistoryModal}
|
||||
>
|
||||
{t("Page history")}
|
||||
</Menu.Item>
|
||||
{!page?.isBase && (
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={16} />}
|
||||
onClick={openHistoryModal}
|
||||
>
|
||||
{t("Page history")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
{!readOnly && !page?.isBase && (
|
||||
<PageVerificationMenuItem
|
||||
pageId={page?.id}
|
||||
onClick={openVerificationModal}
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
/**
|
||||
* Display title for a page, with a base-aware empty-title fallback: bases
|
||||
* fall back to "Untitled base", normal pages to "Untitled". Single chokepoint
|
||||
* so the fallback stays consistent across the UI.
|
||||
*/
|
||||
export function getPageTitle(
|
||||
title: string | null | undefined,
|
||||
isBase: boolean | undefined,
|
||||
t: TFunction,
|
||||
): string {
|
||||
return title || (isBase ? t("Untitled base") : t("Untitled"));
|
||||
}
|
||||
|
||||
const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
|
||||
const titleSlug = slugify(pageTitle?.substring(0, 70) || "untitled", {
|
||||
|
||||
@@ -193,6 +193,7 @@ export function useRestorePageMutation() {
|
||||
spaceId: restoredPage.spaceId,
|
||||
parentPageId: restoredPage.parentPageId,
|
||||
hasChildren: restoredPage.hasChildren || false,
|
||||
isBase: restoredPage.isBase,
|
||||
children: [],
|
||||
};
|
||||
|
||||
@@ -332,6 +333,22 @@ export function useDeletedPagesQuery(
|
||||
});
|
||||
}
|
||||
|
||||
function getChildrenCacheKeys(
|
||||
parentPageId: string | null,
|
||||
spaceId: string,
|
||||
): QueryKey[] {
|
||||
if (parentPageId === null) {
|
||||
return [["root-sidebar-pages", spaceId]];
|
||||
}
|
||||
return queryClient
|
||||
.getQueriesData({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === "sidebar-pages" &&
|
||||
(query.queryKey[1] as { pageId?: string })?.pageId === parentPageId,
|
||||
})
|
||||
.map(([key]) => key);
|
||||
}
|
||||
|
||||
export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
const newPage: Partial<IPage> = {
|
||||
creatorId: data.creatorId,
|
||||
@@ -345,35 +362,27 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
title: data.title,
|
||||
};
|
||||
|
||||
let queryKey: QueryKey = null;
|
||||
if (data.parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", data.spaceId];
|
||||
} else {
|
||||
queryKey = [
|
||||
"sidebar-pages",
|
||||
{ pageId: data.parentPageId, spaceId: data.spaceId },
|
||||
];
|
||||
}
|
||||
|
||||
//update all sidebar pages
|
||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||
queryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) => {
|
||||
if (index === old.pages.length - 1) {
|
||||
return {
|
||||
...page,
|
||||
items: [...page.items, newPage],
|
||||
};
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
getChildrenCacheKeys(data.parentPageId, data.spaceId).forEach((queryKey) => {
|
||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||
queryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) => {
|
||||
if (index === old.pages.length - 1) {
|
||||
return {
|
||||
...page,
|
||||
items: [...page.items, newPage],
|
||||
};
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
//update sidebar haschildren
|
||||
if (data.parentPageId !== null) {
|
||||
@@ -437,30 +446,30 @@ export function invalidateOnUpdatePage(
|
||||
title: string,
|
||||
icon: string,
|
||||
) {
|
||||
let queryKey: QueryKey = null;
|
||||
if (parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", spaceId];
|
||||
} else {
|
||||
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
|
||||
}
|
||||
//update all sidebar pages
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
queryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === id
|
||||
? { ...sidebarPage, title: title, icon: icon }
|
||||
: sidebarPage,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
getChildrenCacheKeys(parentPageId, spaceId).forEach((queryKey) => {
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
queryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === id
|
||||
? {
|
||||
...sidebarPage,
|
||||
...(title !== undefined ? { title } : {}),
|
||||
...(icon !== undefined ? { icon } : {}),
|
||||
}
|
||||
: sidebarPage,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
@@ -476,24 +485,21 @@ export function updateCacheOnMovePage(
|
||||
pageData: Partial<IPage>,
|
||||
) {
|
||||
// Remove page from old parent's cache
|
||||
const oldQueryKey =
|
||||
oldParentId === null
|
||||
? ["root-sidebar-pages", spaceId]
|
||||
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
oldQueryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((item) => item.id !== pageId),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
getChildrenCacheKeys(oldParentId, spaceId).forEach((oldQueryKey) => {
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
oldQueryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((item) => item.id !== pageId),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Update old parent's hasChildren flag if it has no more children
|
||||
if (oldParentId !== null) {
|
||||
@@ -535,36 +541,33 @@ export function updateCacheOnMovePage(
|
||||
}
|
||||
|
||||
// Add page to new parent's cache
|
||||
const newQueryKey =
|
||||
newParentId === null
|
||||
? ["root-sidebar-pages", spaceId]
|
||||
: ["sidebar-pages", { pageId: newParentId, spaceId }];
|
||||
getChildrenCacheKeys(newParentId, spaceId).forEach((newQueryKey) => {
|
||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||
newQueryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||
newQueryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
// Check if page already exists in new location
|
||||
const exists = old.pages.some((page) =>
|
||||
page.items.some((item) => item.id === pageId),
|
||||
);
|
||||
if (exists) return old;
|
||||
|
||||
// Check if page already exists in new location
|
||||
const exists = old.pages.some((page) =>
|
||||
page.items.some((item) => item.id === pageId),
|
||||
);
|
||||
if (exists) return old;
|
||||
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) => {
|
||||
if (index === old.pages.length - 1) {
|
||||
return {
|
||||
...page,
|
||||
items: [...page.items, pageData],
|
||||
};
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) => {
|
||||
if (index === old.pages.length - 1) {
|
||||
return {
|
||||
...page,
|
||||
items: [...page.items, pageData],
|
||||
};
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Update new parent's hasChildren flag
|
||||
if (newParentId !== null) {
|
||||
|
||||
@@ -132,6 +132,25 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
|
||||
saveAs(req.data, decodedFileName);
|
||||
}
|
||||
|
||||
export async function exportPageToDocx(data: { pageId: string }): Promise<void> {
|
||||
const req = await api.post("/docx-export", data, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
const fileName = req?.headers["content-disposition"]
|
||||
.split("filename=")[1]
|
||||
.replace(/"/g, "");
|
||||
|
||||
let decodedFileName = fileName;
|
||||
try {
|
||||
decodedFileName = decodeURIComponent(fileName);
|
||||
} catch (err) {
|
||||
// fallback to raw filename
|
||||
}
|
||||
|
||||
saveAs(req.data, decodedFileName);
|
||||
}
|
||||
|
||||
export async function importPage(file: File, spaceId: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("spaceId", spaceId);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Modal, Text, ScrollArea } from "@mantine/core";
|
||||
import { IconTable } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
pageTitle: string;
|
||||
pageContent: any;
|
||||
isBase?: boolean;
|
||||
}
|
||||
|
||||
export default function TrashPageContentModal({
|
||||
@@ -14,6 +17,7 @@ export default function TrashPageContentModal({
|
||||
onClose,
|
||||
pageTitle,
|
||||
pageContent,
|
||||
isBase,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const title = pageTitle || t("Untitled");
|
||||
@@ -32,7 +36,15 @@ export default function TrashPageContentModal({
|
||||
</Modal.Header>
|
||||
<Modal.Body p={0}>
|
||||
<ScrollArea h="650" w="100%" scrollbarSize={5}>
|
||||
<ReadonlyPageEditor title={title} content={pageContent} />
|
||||
{isBase ? (
|
||||
<EmptyState
|
||||
icon={IconTable}
|
||||
title={t("Base preview unavailable")}
|
||||
description={t("Restore this base to view its contents.")}
|
||||
/>
|
||||
) : (
|
||||
<ReadonlyPageEditor title={title} content={pageContent} />
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
IconDots,
|
||||
IconRestore,
|
||||
IconTrash,
|
||||
IconFileDescription,
|
||||
} from "@tabler/icons-react";
|
||||
import { TrashBanner } from "@/features/page/trash/components/trash-banner.tsx";
|
||||
import {
|
||||
@@ -31,6 +30,7 @@ import { UserInfo } from "@/components/common/user-info.tsx";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||
import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx";
|
||||
import { PageListIcon } from "@/components/common/page-list-icon";
|
||||
|
||||
export default function Trash() {
|
||||
const { t } = useTranslation();
|
||||
@@ -47,6 +47,7 @@ export default function Trash() {
|
||||
const [selectedPage, setSelectedPage] = useState<{
|
||||
title: string;
|
||||
content: any;
|
||||
isBase?: boolean;
|
||||
} | null>(null);
|
||||
const [modalOpened, setModalOpened] = useState(false);
|
||||
|
||||
@@ -79,7 +80,11 @@ export default function Trash() {
|
||||
const hasPages = deletedPages && deletedPages.items.length > 0;
|
||||
|
||||
const handlePageClick = (page: any) => {
|
||||
setSelectedPage({ title: page.title, content: page.content });
|
||||
setSelectedPage({
|
||||
title: page.title,
|
||||
content: page.content,
|
||||
isBase: page.isBase,
|
||||
});
|
||||
setModalOpened(true);
|
||||
};
|
||||
|
||||
@@ -118,15 +123,7 @@ export default function Trash() {
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handlePageClick(page)}
|
||||
>
|
||||
{page.icon || (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<PageListIcon icon={page.icon} isBase={page.isBase} />
|
||||
<div>
|
||||
<Text fw={500} size="sm" lineClamp={1}>
|
||||
{page.title || t("Untitled")}
|
||||
@@ -207,6 +204,7 @@ export default function Trash() {
|
||||
onClose={() => setModalOpened(false)}
|
||||
pageTitle={selectedPage.title}
|
||||
pageContent={selectedPage.content}
|
||||
isBase={selectedPage.isBase}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
|
||||
import {
|
||||
@@ -145,7 +146,15 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
getOffset: pointerOutsideOfPreview({ x: '16px', y: '8px' }),
|
||||
render: ({ container }) => {
|
||||
const root = createRoot(container);
|
||||
root.render(<DocTreeDragPreview label={getDragLabel(node)} />);
|
||||
// flushSync forces the preview to paint into `container`
|
||||
// synchronously, before pragmatic-dnd snapshots it for the
|
||||
// native drag image. Without it, createRoot's async render
|
||||
// leaves the container empty at snapshot time, so the browser
|
||||
// falls back to a default snapshot of the source row (and the
|
||||
// stale image can linger on screen).
|
||||
flushSync(() => {
|
||||
root.render(<DocTreeDragPreview label={getDragLabel(node)} />);
|
||||
});
|
||||
return () => root.unmount();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import CopyPageModal from "@/features/page/components/copy-page-modal.tsx";
|
||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getPageTitle } from "@/features/page/page.utils";
|
||||
import { duplicatePage } from "@/features/page/services/page-service.ts";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
@@ -34,6 +35,7 @@ import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||
|
||||
export interface NodeMenuProps {
|
||||
node: SpaceTreeNode;
|
||||
@@ -123,9 +125,10 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
aria-label={t("Page menu for {{name}}", { name: node.name || t("untitled") })}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
className={classes.actionIcon}
|
||||
aria-label={t("Page menu for {{name}}", { name: getPageTitle(node.name, node.isBase, t) })}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
IconFileDescription,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
IconTable,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getPageTitle } from "@/features/page/page.utils";
|
||||
import { getPageById } from "@/features/page/services/page-service.ts";
|
||||
import {
|
||||
useUpdatePageMutation,
|
||||
@@ -161,7 +163,13 @@ export function SpaceTreeRow({
|
||||
<EmojiPicker
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
icon={
|
||||
node.icon ? node.icon : <IconFileDescription size="18" />
|
||||
node.icon ? (
|
||||
node.icon
|
||||
) : node.isBase ? (
|
||||
<IconTable size={18} />
|
||||
) : (
|
||||
<IconFileDescription size="18" />
|
||||
)
|
||||
}
|
||||
readOnly={!canEdit}
|
||||
removeEmojiAction={handleRemoveEmoji}
|
||||
@@ -169,7 +177,7 @@ export function SpaceTreeRow({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={classes.text}>{node.name || t("untitled")}</span>
|
||||
<span className={classes.text}>{getPageTitle(node.name, node.isBase, t)}</span>
|
||||
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} canEdit={canEdit} />
|
||||
@@ -201,13 +209,13 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={classes.actionIcon}
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
@@ -220,7 +228,8 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
|
||||
<ActionIcon
|
||||
size={20}
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
color="gray"
|
||||
className={classes.actionIcon}
|
||||
aria-label={isOpen ? t("Collapse") : t("Expand")}
|
||||
aria-expanded={isOpen}
|
||||
tabIndex={-1}
|
||||
@@ -272,8 +281,9 @@ function CreateNode({
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
className={classes.actionIcon}
|
||||
aria-label={t("Create subpage of {{name}}", { name: node.name || t("untitled") })}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
mergeRootTrees,
|
||||
} from "@/features/page/tree/utils/utils.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { getPageTitle } from "@/features/page/page.utils";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { getPageBreadcrumbs } from "@/features/page/services/page-service.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
@@ -200,7 +201,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
[],
|
||||
);
|
||||
const getDragLabel = useCallback(
|
||||
(n: SpaceTreeNode) => n.name || t("untitled"),
|
||||
(n: SpaceTreeNode) => getPageTitle(n.name, n.isBase, t),
|
||||
[t],
|
||||
);
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
color: light-dark(var(--mantine-color-dark-3), var(--mantine-color-gray-4));
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
/* min-width: 0 lets a flex child shrink below its content size — required
|
||||
@@ -87,8 +91,6 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ---- pragmatic-tree additions ---- */
|
||||
|
||||
.rowWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -7,6 +7,7 @@ export type SpaceTreeNode = {
|
||||
spaceId: string;
|
||||
parentPageId: string;
|
||||
hasChildren: boolean;
|
||||
isBase?: boolean;
|
||||
canEdit?: boolean;
|
||||
children: SpaceTreeNode[];
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||
hasChildren: page.hasChildren,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
isBase: page.isBase,
|
||||
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
||||
children: [],
|
||||
};
|
||||
@@ -42,10 +43,6 @@ export function findBreadcrumbPath(
|
||||
path: SpaceTreeNode[] = [],
|
||||
): SpaceTreeNode[] | null {
|
||||
for (const node of tree) {
|
||||
if (!node.name || node.name.trim() === "") {
|
||||
node.name = "untitled";
|
||||
}
|
||||
|
||||
if (node.id === pageId) {
|
||||
return [...path, node];
|
||||
}
|
||||
@@ -206,7 +203,14 @@ export function mergeRootTrees(
|
||||
prevRoots: SpaceTreeNode[],
|
||||
incomingRoots: SpaceTreeNode[],
|
||||
): SpaceTreeNode[] {
|
||||
const seen = new Set(prevRoots.map((r) => r.id));
|
||||
const seen = new Set<string>();
|
||||
const collect = (nodes: SpaceTreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
seen.add(node.id);
|
||||
if (node.children?.length) collect(node.children);
|
||||
}
|
||||
};
|
||||
collect(prevRoots);
|
||||
|
||||
// add new roots that were not present before
|
||||
const merged = [...prevRoots];
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface IPage {
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
isLocked: boolean;
|
||||
isBase: boolean;
|
||||
lastUpdatedById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -98,4 +99,5 @@ export interface IExportPageParams {
|
||||
export enum ExportFormat {
|
||||
HTML = "html",
|
||||
Markdown = "markdown",
|
||||
Docx = "docx",
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu";
|
||||
import { RadioMenuItem } from "@/components/ui/radio-menu-item";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import classes from "./search-spotlight-filters.module.css";
|
||||
@@ -175,7 +176,7 @@ export function SearchSpotlightFilters({
|
||||
{contentTypeOptions.map((option) => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
role="menuitemradio"
|
||||
component={RadioMenuItem}
|
||||
aria-checked={contentType === option.value}
|
||||
onClick={() =>
|
||||
!option.disabled &&
|
||||
|
||||
@@ -7,8 +7,8 @@ import Paginate from "@/components/common/paginate.tsx";
|
||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
|
||||
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
||||
import { format } from "date-fns";
|
||||
import ShareActionMenu from "@/features/share/components/share-action-menu.tsx";
|
||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
|
||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getPageIcon } from "@/lib";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
@@ -20,6 +20,7 @@ export default function ShareList() {
|
||||
const { t } = useTranslation();
|
||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||
const { data, isLoading } = useGetSharesQuery({ cursor });
|
||||
const locale = useDateFnsLocale();
|
||||
|
||||
if (!isLoading && data?.items.length === 0) {
|
||||
return <EmptyState icon={IconWorld} title={t("No shared pages")} />;
|
||||
@@ -81,7 +82,12 @@ export default function ShareList() {
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{format(new Date(share.createdAt), "MMM dd, yyyy")}
|
||||
{formatLocalized(
|
||||
share.createdAt,
|
||||
"MMM dd, yyyy",
|
||||
"PP",
|
||||
locale,
|
||||
)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function ShareShell({
|
||||
const [fullWidth, setFullWidth] = useAtom(sharedPageFullWidthAtom);
|
||||
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const sidebarRef = useRef<HTMLElement | null>(null);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const startResizing = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -17,8 +17,8 @@ const formSchema = z.object({
|
||||
.min(2)
|
||||
.max(100)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9]+$/,
|
||||
"Space slug must be alphanumeric. No special characters",
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
|
||||
"Space slug must start with a letter or number and may contain hyphens and underscores",
|
||||
),
|
||||
description: z.string().max(500),
|
||||
});
|
||||
|
||||
@@ -15,8 +15,8 @@ const formSchema = z.object({
|
||||
.min(2)
|
||||
.max(100)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9]+$/,
|
||||
"Space slug must be alphanumeric. No special characters",
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
|
||||
"Space slug must start with a letter or number and may contain hyphens and underscores",
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconCheck, IconSearch } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import { RadioMenuItem } from "@/components/ui/radio-menu-item";
|
||||
|
||||
type SpaceFilterMenuProps = {
|
||||
value: string | null;
|
||||
@@ -75,7 +76,7 @@ export function SpaceFilterMenu({
|
||||
|
||||
<ScrollArea.Autosize mah={280}>
|
||||
<Menu.Item
|
||||
role="menuitemradio"
|
||||
component={RadioMenuItem}
|
||||
aria-checked={!value}
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
@@ -103,7 +104,7 @@ export function SpaceFilterMenu({
|
||||
{orderedSpaces.map((space) => (
|
||||
<Menu.Item
|
||||
key={space.id}
|
||||
role="menuitemradio"
|
||||
component={RadioMenuItem}
|
||||
aria-checked={value === space.id}
|
||||
onClick={() => onChange(space.id)}
|
||||
>
|
||||
|
||||
@@ -210,7 +210,9 @@ export default function SpaceMembersList({
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
aria-label={t("Member actions")}
|
||||
aria-label={t("Member actions for {{name}}", {
|
||||
name: member.name,
|
||||
})}
|
||||
>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -11,6 +11,9 @@ export enum SpaceCaslSubject {
|
||||
Page = "page",
|
||||
}
|
||||
|
||||
// Bases are pages and inherit Page permissions — a separate Base
|
||||
// subject was redundant and has been dropped from the server's casl
|
||||
// rules too. Anything that used to check Base now checks Page.
|
||||
export type SpaceAbility =
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Settings]
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Member]
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ISpace {
|
||||
description: string;
|
||||
logo?: string;
|
||||
slug: string;
|
||||
isPersonal?: boolean;
|
||||
hostname: string;
|
||||
creatorId: string;
|
||||
createdAt: Date;
|
||||
|
||||
@@ -23,7 +23,6 @@ export const useQuerySubscription = () => {
|
||||
const data: WebSocketEvent = event;
|
||||
|
||||
let entity = null;
|
||||
let queryKeyId = null;
|
||||
|
||||
switch (data.operation) {
|
||||
case "invalidate":
|
||||
@@ -106,21 +105,23 @@ export const useQuerySubscription = () => {
|
||||
case "deleteTreeNode":
|
||||
invalidateOnDeletePage(data.payload.node.id);
|
||||
break;
|
||||
case "updateOne":
|
||||
case "updateOne": {
|
||||
entity = data.entity[0];
|
||||
if (entity === "pages") {
|
||||
// we have to do this because the usePageQuery cache key is the slugId.
|
||||
queryKeyId = data.payload.slugId;
|
||||
} else {
|
||||
queryKeyId = data.id;
|
||||
}
|
||||
const keyIds =
|
||||
entity === "pages" ? [data.payload.slugId, data.id] : [data.id];
|
||||
|
||||
// only update if data was already in cache
|
||||
if (queryClient.getQueryData([...data.entity, queryKeyId])) {
|
||||
queryClient.setQueryData([...data.entity, queryKeyId], {
|
||||
...queryClient.getQueryData([...data.entity, queryKeyId]),
|
||||
...data.payload,
|
||||
});
|
||||
for (const keyId of keyIds) {
|
||||
if (!keyId) continue;
|
||||
const cached = queryClient.getQueryData<Record<string, unknown>>([
|
||||
...data.entity,
|
||||
keyId,
|
||||
]);
|
||||
if (cached) {
|
||||
queryClient.setQueryData([...data.entity, keyId], {
|
||||
...cached,
|
||||
...data.payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entity === "pages") {
|
||||
@@ -132,20 +133,8 @@ export const useQuerySubscription = () => {
|
||||
data.payload.icon,
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: [data.entity, data.id] },
|
||||
(oldData: any) => {
|
||||
const update = (entity: Record<string, unknown>) =>
|
||||
entity.id === data.id ? { ...entity, ...data.payload } : entity;
|
||||
return Array.isArray(oldData)
|
||||
? oldData.map(update)
|
||||
: update(oldData as Record<string, unknown>);
|
||||
},
|
||||
);
|
||||
*/
|
||||
break;
|
||||
}
|
||||
case "refetchRootTreeNodeEvent": {
|
||||
const spaceId = data.spaceId;
|
||||
queryClient.refetchQueries({
|
||||
|
||||
@@ -48,6 +48,11 @@ export const useTreeSocket = () => {
|
||||
icon: event.payload.icon,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
if (event.payload?.isBase !== undefined) {
|
||||
next = treeModel.update(next, event.id, {
|
||||
isBase: event.payload.isBase,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
+7
-2
@@ -12,9 +12,14 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
name: string;
|
||||
deactivatedAt: Date | null;
|
||||
}
|
||||
export default function MemberActionMenu({ userId, deactivatedAt }: Props) {
|
||||
export default function MemberActionMenu({
|
||||
userId,
|
||||
name,
|
||||
deactivatedAt,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
|
||||
const deactivateMutation = useDeactivateWorkspaceMemberMutation();
|
||||
@@ -86,7 +91,7 @@ export default function MemberActionMenu({ userId, deactivatedAt }: Props) {
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
aria-label={t("Member actions")}
|
||||
aria-label={t("Member actions for {{name}}", { name })}
|
||||
>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user