Merge branch 'main' into feat/bases

This commit is contained in:
Philipinho
2026-06-20 18:38:05 +01:00
74 changed files with 4280 additions and 2397 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) {
@@ -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);
@@ -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",
@@ -111,8 +111,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,
@@ -99,7 +99,7 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
<Menu.Item
leftSection={<IconRotate2 size={16} />}
onClick={() =>
editor.chain().focus().insertTransclusionSource().run()
editor.chain().focus().toggleTransclusionSource().run()
}
>
{t("Synced block")}
@@ -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;
@@ -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();
@@ -22,6 +22,7 @@ import {
IconMenu4,
IconPageBreak,
IconCalendar,
IconClock,
IconAppWindow,
IconSitemap,
IconColumns3,
@@ -494,6 +495,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.",
@@ -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";
@@ -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]);
@@ -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);
@@ -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,
@@ -160,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]);
@@ -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;
@@ -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);
@@ -99,4 +99,5 @@ export interface IExportPageParams {
export enum ExportFormat {
HTML = "html",
Markdown = "markdown",
Docx = "docx",
}
@@ -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",
),
});
@@ -24,6 +24,7 @@ export interface ISpace {
description: string;
logo?: string;
slug: string;
isPersonal?: boolean;
hostname: string;
creatorId: string;
createdAt: Date;
@@ -80,7 +80,7 @@ export function WorkspaceInviteForm({ onClose }: Props) {
defaultValue={UserRole.MEMBER}
allowDeselect={false}
checkIconPosition="right"
onChange={setRole}
onChange={(value) => setRole(value)}
/>
<MultiGroupSelect
@@ -28,6 +28,7 @@ export interface IWorkspace {
trashRetentionDays?: number;
restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean;
allowPersonalSpaces?: boolean;
isScimEnabled?: boolean;
}
@@ -36,6 +37,7 @@ export interface IWorkspaceSettings {
sharing?: IWorkspaceSharingSettings;
api?: IWorkspaceApiSettings;
templates?: IWorkspaceTemplateSettings;
spaces?: IWorkspaceSpaceSettings;
}
export interface IWorkspaceApiSettings {
@@ -57,6 +59,10 @@ export interface IWorkspaceTemplateSettings {
allowMemberTemplates?: boolean;
}
export interface IWorkspaceSpaceSettings {
allowPersonal?: boolean;
}
export interface ICreateInvite {
role: string;
emails: string[];