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:
Philipinho
2026-08-07 01:13:39 +01:00
406 changed files with 33557 additions and 5788 deletions
@@ -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);
@@ -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 />
@@ -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>
);
@@ -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,
@@ -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);
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import { buildRowOrColumnSelection, Orientation } from "../lib/select-row-column";
interface Args {
@@ -19,6 +20,7 @@ export function useColumnRowMenuLifecycle({
tablePos,
}: Args) {
const onOpen = useCallback(() => {
if (!isEditorReady(editor)) return;
const selection = buildRowOrColumnSelection(
editor.state,
tableNode,
@@ -33,6 +35,7 @@ export function useColumnRowMenuLifecycle({
}, [editor, orientation, index, tableNode, tablePos]);
const onClose = useCallback(() => {
if (!isEditorReady(editor)) return;
editor.commands.unfreezeHandles();
}, [editor]);
@@ -2,6 +2,7 @@ import { useCallback } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TableMap } from "@tiptap/pm/tables";
import { isEditorReady } from "@docmost/editor-ext";
type Scope =
| { kind: "col"; index: number }
@@ -15,6 +16,7 @@ export function useTableClear(
scope: Scope,
) {
return useCallback(() => {
if (!isEditorReady(editor)) return;
const tr = editor.state.tr;
const tableStart = tablePos + 1;
const map = TableMap.get(tableNode);
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TableMap } from "@tiptap/pm/tables";
import { moveColumn, moveRow } from "@docmost/editor-ext";
import { isEditorReady, moveColumn, moveRow } from "@docmost/editor-ext";
export type MoveDirection = "left" | "right" | "up" | "down";
@@ -25,7 +25,7 @@ export function useTableMoveRowColumn(
const canMove = target >= 0 && target <= maxIndex;
const handleMove = useCallback(() => {
if (!canMove) return;
if (!canMove || !isEditorReady(editor)) return;
const tr = editor.state.tr;
const moved =
orientation === "col"
@@ -4,6 +4,7 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import {
convertArrayOfRowsToTableNode,
convertTableNodeToArrayOfRows,
isEditorReady,
transpose,
} from "@docmost/editor-ext";
import {
@@ -63,7 +64,7 @@ export function useTableSort({
}, [tableNode, orientation, index]);
const handleSort = useCallback(() => {
if (!canSort) return;
if (!canSort || !isEditorReady(editor)) return;
const rows = convertTableNodeToArrayOfRows(tableNode);
const axes = orientation === "col" ? rows : transpose(rows);
@@ -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,
@@ -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' });
+185 -138
View File
@@ -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;
});
}
@@ -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