Compare commits

..

1 Commits

Author SHA1 Message Date
Philipinho 3ae39b522d reusable media utils 2026-03-04 12:49:37 +00:00
69 changed files with 621 additions and 2395 deletions
@@ -289,11 +289,6 @@
"Save & Exit": "Save & Exit",
"Double-click to edit Excalidraw diagram": "Double-click to edit Excalidraw diagram",
"Paste link": "Paste link",
"Paste link or search pages": "Paste link or search pages",
"Link to web page": "Link to web page",
"Recents": "Recents",
"Page or URL": "Page or URL",
"Link title": "Link title",
"Edit link": "Edit link",
"Remove link": "Remove link",
"Add link": "Add link",
@@ -698,16 +693,5 @@
"Failed to update trash retention": "Failed to update trash retention",
"Removed page restriction": "Removed page restriction",
"Added page permission": "Added page permission",
"Removed page permission": "Removed page permission",
"Verifying your email": "Verifying your email",
"Please wait...": "Please wait...",
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
"Check your email": "Check your email",
"We sent a verification link to {{email}}.": "We sent a verification link to {{email}}.",
"We sent a verification link to your email.": "We sent a verification link to your email.",
"Click the link to verify your email and access your workspace.": "Click the link to verify your email and access your workspace.",
"Resend verification email": "Resend verification email",
"Verification email sent. Please check your inbox.": "Verification email sent. Please check your inbox.",
"Failed to resend verification email. Please try again.": "Failed to resend verification email. Please try again.",
"We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces."
"Removed page permission": "Removed page permission"
}
-2
View File
@@ -38,7 +38,6 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
export default function App() {
const { t } = useTranslation();
@@ -64,7 +63,6 @@ export default function App() {
<>
<Route path={"/create"} element={<CreateWorkspace />} />
<Route path={"/select"} element={<CloudLogin />} />
<Route path={"/verify-email"} element={<VerifyEmail />} />
</>
)}
@@ -34,7 +34,6 @@ export function AutoTooltipText({
disabled={!isTruncated || !label}
multiline
withArrow
withinPortal={false}
{...tooltipProps}
>
<Text
@@ -5,15 +5,3 @@ export async function getJoinedWorkspaces(): Promise<Partial<IWorkspace[]>> {
const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined");
return req.data;
}
export async function findWorkspacesByEmail(email: string): Promise<void> {
await api.post("/workspace/find-by-email", { email });
}
export async function verifyEmail(data: { token: string }): Promise<void> {
await api.post("/workspace/verify-email", data);
}
export async function resendVerificationEmail(data: { email: string; sig: string }): Promise<void> {
await api.post("/workspace/resend-verification", data);
}
@@ -20,21 +20,14 @@ import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx";
import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts";
import { findWorkspacesByEmail } from "@/ee/cloud/service/cloud-service.ts";
const formSchema = z.object({
hostname: z.string().min(1, { message: "subdomain is required" }),
});
const findWorkspaceSchema = z.object({
email: z.string().email({ message: "Please enter a valid email" }),
});
export function CloudLoginForm() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isFindLoading, setIsFindLoading] = useState<boolean>(false);
const [findEmailSent, setFindEmailSent] = useState<boolean>(false);
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
const form = useForm<any>({
@@ -44,13 +37,6 @@ export function CloudLoginForm() {
},
});
const findForm = useForm<any>({
validate: zod4Resolver(findWorkspaceSchema),
initialValues: {
email: "",
},
});
async function onSubmit(data: { hostname: string }) {
setIsLoading(true);
@@ -68,19 +54,6 @@ export function CloudLoginForm() {
setIsLoading(false);
}
async function onFindSubmit(data: { email: string }) {
setIsFindLoading(true);
try {
await findWorkspacesByEmail(data.email);
setFindEmailSent(true);
} catch {
findForm.setFieldError("email", "An error occurred. Please try again.");
}
setIsFindLoading(false);
}
return (
<div>
<Container size={420} className={classes.container}>
@@ -110,38 +83,6 @@ export function CloudLoginForm() {
{t("Continue")}
</Button>
</form>
<Divider my="lg" label="or" labelPosition="center" />
{findEmailSent ? (
<Text ta="center" size="sm" c="dimmed">
{t("We've sent you an email with your associated workspaces.")}
</Text>
) : (
<form onSubmit={findForm.onSubmit(onFindSubmit)}>
<Text fw={600} mb="xs">
{t("Find your workspaces")}
</Text>
<TextInput
type="email"
placeholder="name@company.com"
description={t(
"We'll send a list of your workspaces to this email.",
)}
withErrorStyles={false}
{...findForm.getInputProps("email")}
/>
<Button
type="submit"
fullWidth
mt="md"
variant="light"
loading={isFindLoading}
>
{t("Send")}
</Button>
</form>
)}
</Box>
</Container>
-107
View File
@@ -1,107 +0,0 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Container, Title, Text, Button, Box } from "@mantine/core";
import classes from "../../features/auth/components/auth.module.css";
import {
verifyEmail,
resendVerificationEmail,
} from "@/ee/cloud/service/cloud-service.ts";
import { notifications } from "@mantine/notifications";
import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
export default function VerifyEmail() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get("token");
const rawEmail = searchParams.get("email");
const email = rawEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail) ? rawEmail : null;
const sig = searchParams.get("sig");
const [isResending, setIsResending] = useState(false);
const [resent, setResent] = useState(false);
useEffect(() => {
if (token) {
handleVerify(token);
}
}, [token]);
async function handleVerify(verifyToken: string) {
try {
await verifyEmail({ token: verifyToken });
navigate(APP_ROUTE.HOME);
} catch (err) {
notifications.show({
message: t("Verification failed. The link may have expired."),
color: "red",
});
navigate(APP_ROUTE.AUTH.LOGIN);
}
}
async function handleResend() {
if (!email || !sig) return;
setIsResending(true);
try {
await resendVerificationEmail({ email, sig });
setResent(true);
} catch {
notifications.show({
message: t("Failed to resend verification email. Please try again."),
color: "red",
});
}
setIsResending(false);
}
if (token) {
return (
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Verifying your email")}
</Title>
<Text ta="center" c="dimmed">
{t("Please wait...")}
</Text>
</Box>
</Container>
);
}
return (
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Check your email")}
</Title>
<Text ta="center" c="dimmed" mb="md">
{email
? t("We sent a verification link to {{email}}.", { email })
: t("We sent a verification link to your email.")}
</Text>
<Text ta="center" size="sm" c="dimmed" mb="lg">
{t("Click the link to verify your email and access your workspace.")}
</Text>
{email && sig && !resent && (
<Button
fullWidth
variant="light"
onClick={handleResend}
loading={isResending}
>
{t("Resend verification email")}
</Button>
)}
{resent && (
<Text ta="center" size="sm" c="dimmed">
{t("Verification email sent. Please check your inbox.")}
</Text>
)}
</Box>
</Container>
);
}
@@ -22,11 +22,11 @@ import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({
workspaceName: z.string().trim().max(50).optional(),
name: z.string().min(1, { message: "Name is required" }).max(50),
name: z.string().min(1).max(50),
email: z
.email({ message: "Invalid email address" })
.min(1, { message: "Email is required" }),
password: z.string().min(8, { message: "Password must be at least 8 characters" }),
.email()
.min(1, { message: "email is required" }),
password: z.string().min(8),
});
type FormValues = z.infer<typeof formSchema>;
@@ -27,7 +27,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
export default function useAuth() {
const { t } = useTranslation();
@@ -52,18 +52,9 @@ export default function useAuth() {
}
} catch (err) {
setIsLoading(false);
const message = err.response?.data?.message;
if (isCloud() && message?.includes("verify your email")) {
const sig = err.response?.data?.emailSignature;
navigate(
`${APP_ROUTE.AUTH.VERIFY_EMAIL}?email=${encodeURIComponent(data.email)}${sig ? `&sig=${sig}` : ""}`,
);
return;
}
console.log(err);
notifications.show({
message,
message: err.response?.data.message,
color: "red",
});
}
@@ -101,17 +92,6 @@ export default function useAuth() {
try {
if (isCloud()) {
const res = await createWorkspace(data);
if (res?.requiresEmailVerification) {
const hostname = res?.workspace?.hostname;
if (hostname) {
window.location.href =
getHostnameUrl(hostname) +
`/verify-email?email=${encodeURIComponent(data.email)}&sig=${res.emailSignature}`;
}
return;
}
const hostname = res?.workspace?.hostname;
const exchangeToken = res?.exchangeToken;
if (hostname && exchangeToken) {
@@ -50,5 +50,4 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
export async function getCollabToken(): Promise<ICollabToken> {
const req = await api.post<ICollabToken>("/auth/collab-token");
return req.data;
}
}
@@ -24,12 +24,7 @@ function CommentActions({
</Button>
)}
<Button
size="compact-sm"
loading={isLoading}
onClick={onSave}
onMouseDown={(e) => e.preventDefault()}
>
<Button size="compact-sm" loading={isLoading} onClick={onSave}>
{t("Save")}
</Button>
</Group>
@@ -27,9 +27,6 @@ import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
function CommentListWithTabs() {
const { t } = useTranslation();
@@ -348,7 +345,6 @@ const PageCommentInput = ({ onSave, isLoading }) => {
const [content, setContent] = useState("");
const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null);
const [currentUser] = useAtom(currentUserAtom);
const handleSave = useCallback(() => {
onSave(null, content);
@@ -367,30 +363,19 @@ const PageCommentInput = ({ onSave, isLoading }) => {
position: "relative",
}}
>
<Group wrap="nowrap" align="flex-start" gap="xs">
<CustomAvatar
size="sm"
avatarUrl={currentUser?.user?.avatarUrl}
name={currentUser?.user?.name}
style={{ flexShrink: 0, marginTop: 10 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<CommentEditor
ref={commentEditorRef}
onUpdate={setContent}
onSave={handleSave}
editable={true}
placeholder={t("Add a comment...")}
/>
</div>
</Group>
<CommentEditor
ref={commentEditorRef}
onUpdate={setContent}
onSave={handleSave}
editable={true}
placeholder={t("Add a comment...")}
/>
{focused && (
<ActionIcon
variant="filled"
radius="xl"
size="sm"
onClick={handleSave}
onMouseDown={(e) => e.preventDefault()}
loading={isLoading}
style={{ position: "absolute", right: 8, bottom: 30 }}
>
@@ -1,7 +1,7 @@
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
import { isNodeSelection, useEditorState } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { FC, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
import { FC, useEffect, useRef, useState } from "react";
import {
IconBold,
IconCode,
@@ -49,7 +49,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu);
const isLinkSelectorOpenRef = useRef(false);
useEffect(() => {
showCommentPopupRef.current = showCommentPopup;
@@ -126,10 +125,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
shouldShow: ({ state, editor }) => {
if (isLinkSelectorOpenRef.current) {
return true;
}
const { selection } = state;
const { empty } = selection;
@@ -160,14 +155,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
const [isLinkSelectorOpen, _setIsLinkSelectorOpen] = useState(false);
const setIsLinkSelectorOpen = useCallback((value: SetStateAction<boolean>) => {
_setIsLinkSelectorOpen((prev) => {
const next = typeof value === 'function' ? value(prev) : value;
isLinkSelectorOpenRef.current = next;
return next;
});
}, []);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
// Hide the bubble menu immediately when AI menu is shown
@@ -4,7 +4,6 @@ import { ActionIcon, Popover, Tooltip } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { TextSelection } from "@tiptap/pm/state";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { normalizeUrl } from "@/features/editor/components/link/link-view";
import { useTranslation } from "react-i18next";
interface LinkSelectorProps {
@@ -20,12 +19,12 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
}) => {
const { t } = useTranslation();
const onLink = useCallback(
(url: string, internal?: boolean) => {
(url: string) => {
setIsOpen(false);
editor
.chain()
.focus()
.setLink({ href: internal ? url : normalizeUrl(url), internal: !!internal } as any)
.setLink({ href: url })
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
@@ -37,12 +36,11 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
return (
<Popover
width={320}
width={300}
opened={isOpen}
trapFocus
offset={{ mainAxis: 35, crossAxis: 0 }}
withArrow
shadow="md"
>
<Popover.Target>
<Tooltip label={t("Add link")} withArrow>
@@ -60,7 +58,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
</Tooltip>
</Popover.Target>
<Popover.Dropdown p="sm">
<Popover.Dropdown>
<LinkEditorPanel onSetLink={onLink} />
</Popover.Dropdown>
</Popover>
@@ -1,6 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
@@ -9,7 +9,6 @@ import {
import {
ActionIcon,
Modal,
Text,
Tooltip,
useComputedColorScheme,
} from "@mantine/core";
@@ -30,12 +29,10 @@ import {
DrawIoEmbed,
DrawIoEmbedRef,
EventExit,
EventExport,
EventSave,
} from "react-drawio";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import { modals } from "@mantine/modals";
import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) {
@@ -44,8 +41,6 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null);
const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const editorState = useEditorState({
editor,
@@ -136,13 +131,33 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection();
}, [editor]);
const saveData = useCallback(async (svgXml: string) => {
if (isSavingRef.current) return;
isSavingRef.current = true;
const handleOpen = useCallback(async () => {
if (!editorState?.src) return;
try {
const svgString = decodeBase64ToSvgString(svgXml);
const url = getFileUrl(editorState.src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
} catch (err) {
console.error(err);
} finally {
open();
}
}, [editorState?.src, open]);
const handleSave = useCallback(
async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
@@ -164,85 +179,10 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
attachmentId: attachment.id,
});
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [editor, editorState?.attachmentId]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
}
modals.openConfirmModal({
title: t("Unsaved changes"),
children: (
<Text size="sm">
{t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
});
}, [close, t]);
const handleOpen = useCallback(async () => {
if (!editorState?.src) return;
try {
const url = getFileUrl(editorState.src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
} catch (err) {
console.error(err);
} finally {
isDirtyRef.current = false;
open();
}
}, [editorState?.src, open]);
useEffect(() => {
if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current && drawioRef.current) {
drawioRef.current.exportDiagram({ format: "xmlsvg" });
}
}, 60_000);
return () => clearInterval(interval);
}, [opened]);
useEffect(() => {
if (!opened) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleClose();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [opened, handleClose]);
},
[editor, editorState?.attachmentId, close],
);
return (
<>
@@ -336,7 +276,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
</div>
</BaseBubbleMenu>
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body>
@@ -345,7 +285,6 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
ref={drawioRef}
xml={initialXML}
baseUrl={getDrawioUrl()}
autosave
urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true,
@@ -357,19 +296,13 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
if (data.parentEvent !== "save") {
return;
}
saveData(data.xml).then(() => close()).catch(() => {});
handleSave(data);
}}
onClose={(data: EventExit) => {
if (data.parentEvent) {
return;
}
handleClose();
}}
onAutoSave={() => {
isDirtyRef.current = true;
}}
onExport={(data: EventExport) => {
saveData(data.data).catch(() => {});
close();
}}
/>
</div>
@@ -6,7 +6,7 @@ import {
Text,
useComputedColorScheme,
} from "@mantine/core";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { useDisclosure } from "@mantine/hooks";
import { getDrawioUrl } from "@/lib/config.ts";
@@ -14,7 +14,6 @@ import {
DrawIoEmbed,
DrawIoEmbedRef,
EventExit,
EventExport,
EventSave,
} from "react-drawio";
import { IAttachment } from "@/features/attachments/types/attachment.types";
@@ -22,7 +21,6 @@ import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { modals } from "@mantine/modals";
export default function DrawioView(props: NodeViewProps) {
const { t } = useTranslation();
@@ -32,108 +30,42 @@ export default function DrawioView(props: NodeViewProps) {
const [initialXML, setInitialXML] = useState<string>("");
const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const handleOpen = async () => {
if (!editor.isEditable) {
return;
}
isDirtyRef.current = false;
open();
};
const saveData = async (svgXml: string, updateSrc = true) => {
if (isSavingRef.current) return;
const handleSave = async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
isSavingRef.current = true;
//@ts-ignore
const pageId = editor.storage?.pageId;
try {
const svgString = decodeBase64ToSvgString(svgXml);
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
//@ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else {
attachment = await uploadFile(drawioSVGFile, pageId);
}
if (updateSrc) {
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
} else {
updateAttributes({
attachmentId: attachment.id,
});
}
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
};
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else {
attachment = await uploadFile(drawioSVGFile, pageId);
}
modals.openConfirmModal({
title: t("Unsaved changes"),
children: (
<Text size="sm">
{t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
}, [close, t]);
useEffect(() => {
if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current && drawioRef.current) {
drawioRef.current.exportDiagram({ format: "xmlsvg" });
}
}, 30_000);
return () => clearInterval(interval);
}, [opened]);
useEffect(() => {
if (!opened) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleClose();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [opened, handleClose]);
close();
};
return (
<NodeViewWrapper data-drag-handle>
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body>
@@ -142,7 +74,6 @@ export default function DrawioView(props: NodeViewProps) {
ref={drawioRef}
xml={initialXML}
baseUrl={getDrawioUrl()}
autosave
urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true,
@@ -154,19 +85,13 @@ export default function DrawioView(props: NodeViewProps) {
if (data.parentEvent !== "save") {
return;
}
saveData(data.xml, true).then(() => close()).catch(() => {});
handleSave(data);
}}
onClose={(data: EventExit) => {
if (data.parentEvent) {
return;
}
handleClose();
}}
onAutoSave={() => {
isDirtyRef.current = true;
}}
onExport={(data: EventExport) => {
saveData(data.data, false).catch(() => {});
close();
}}
/>
</div>
@@ -1,6 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { lazy, Suspense, useCallback, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
@@ -10,11 +10,9 @@ import {
ActionIcon,
Button,
Group,
Text,
Tooltip,
useComputedColorScheme,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { useDisclosure } from "@mantine/hooks";
import clsx from "clsx";
import {
@@ -54,10 +52,6 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
});
const [excalidrawData, setExcalidrawData] = useState<any>(null);
const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const isInitialLoadRef = useRef(true);
const lastFingerprintRef = useRef("");
const editorState = useEditorState({
editor,
@@ -166,109 +160,57 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
} catch (err) {
console.error(err);
} finally {
isDirtyRef.current = false;
isInitialLoadRef.current = true;
open();
}
}, [editorState?.src, open]);
const saveData = useCallback(async () => {
if (!excalidrawAPI || isSavingRef.current) {
const handleSave = useCallback(async () => {
if (!excalidrawAPI) {
return;
}
isSavingRef.current = true;
const { exportToSvg } = await import("@excalidraw/excalidraw");
try {
const { exportToSvg } = await import("@excalidraw/excalidraw");
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svg);
svgString = svgString.replace(
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
const attachmentId = editorState?.attachmentId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
editor.commands.updateAttributes("excalidraw", {
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [editor, excalidrawAPI, editorState?.attachmentId]);
const handleSaveAndExit = useCallback(async () => {
try {
await saveData();
close();
} catch {
// save failed, modal stays open
}
}, [saveData, close]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
}
modals.openConfirmModal({
title: t("Unsaved changes"),
children: (
<Text size="sm">
{t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
}, [close, t]);
useEffect(() => {
if (!opened) return;
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svg);
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current) {
saveData().catch(() => {});
}
}, 60_000);
svgString = svgString.replace(
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
return () => clearInterval(interval);
}, [opened, saveData]);
const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
const attachmentId = editorState?.attachmentId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
editor.commands.updateAttributes("excalidraw", {
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
close();
}, [editor, excalidrawAPI, editorState?.attachmentId, close]);
return (
<>
@@ -375,7 +317,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
zIndex: 200,
}}
isOpen={opened}
onRequestClose={handleClose}
onRequestClose={close}
disableCloseOnBgClick={true}
contentProps={{
style: {
@@ -390,10 +332,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
bg="var(--mantine-color-body)"
p="xs"
>
<Button onClick={handleSaveAndExit} size={"compact-sm"}>
<Button onClick={handleSave} size={"compact-sm"}>
{t("Save & Exit")}
</Button>
<Button onClick={handleClose} color="red" size={"compact-sm"}>
<Button onClick={close} color="red" size={"compact-sm"}>
{t("Exit")}
</Button>
</Group>
@@ -401,18 +343,6 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<Suspense fallback={null}>
<ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)}
onChange={(elements, _appState, files) => {
const fingerprint = `${elements.length}:${elements.reduce((s, e) => s + (e.version || 0), 0)}:${Object.keys(files).length}`;
if (isInitialLoadRef.current) {
lastFingerprintRef.current = fingerprint;
isInitialLoadRef.current = false;
return;
}
if (fingerprint !== lastFingerprintRef.current) {
lastFingerprintRef.current = fingerprint;
isDirtyRef.current = true;
}
}}
initialData={{
...excalidrawData,
scrollToContent: true,
@@ -7,14 +7,7 @@ import {
Text,
useComputedColorScheme,
} from "@mantine/core";
import {
lazy,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { lazy, Suspense, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks";
@@ -27,7 +20,6 @@ import { IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { modals } from "@mantine/modals";
const ExcalidrawComponent = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({
@@ -50,122 +42,59 @@ export default function ExcalidrawView(props: NodeViewProps) {
const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const isInitialLoadRef = useRef(true);
const lastFingerprintRef = useRef("");
const handleOpen = async () => {
if (!editor.isEditable) {
return;
}
isDirtyRef.current = false;
isInitialLoadRef.current = true;
open();
};
const saveData = useCallback(async (updateSrc = true) => {
if (!excalidrawAPI || isSavingRef.current) {
const handleSave = async () => {
if (!excalidrawAPI) {
return;
}
isSavingRef.current = true;
const { exportToSvg } = await import("@excalidraw/excalidraw");
try {
const { exportToSvg } = await import("@excalidraw/excalidraw");
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svg);
svgString = svgString.replace(
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
if (updateSrc) {
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
} else {
updateAttributes({
attachmentId: attachment.id,
});
}
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [excalidrawAPI, editor, attachmentId, updateAttributes]);
const handleSaveAndExit = useCallback(async () => {
try {
await saveData();
close();
} catch {
/* empty */
}
}, [saveData, close]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
}
modals.openConfirmModal({
title: t("Unsaved changes"),
children: (
<Text size="sm">
{t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
}, [close, t]);
useEffect(() => {
if (!opened) return;
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svg);
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current) {
saveData(false).catch(() => {});
}
}, 30_000);
svgString = svgString.replace(
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
return () => clearInterval(interval);
}, [opened, saveData]);
const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
close();
};
return (
<NodeViewWrapper data-drag-handle>
@@ -176,7 +105,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
zIndex: 200,
}}
isOpen={opened}
onRequestClose={handleClose}
onRequestClose={close}
disableCloseOnBgClick={true}
contentProps={{
style: {
@@ -191,10 +120,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
bg="var(--mantine-color-body)"
p="xs"
>
<Button onClick={handleSaveAndExit} size={"compact-sm"}>
<Button onClick={handleSave} size={"compact-sm"}>
{t("Save & Exit")}
</Button>
<Button onClick={handleClose} color="red" size={"compact-sm"}>
<Button onClick={close} color="red" size={"compact-sm"}>
{t("Exit")}
</Button>
</Group>
@@ -202,18 +131,6 @@ export default function ExcalidrawView(props: NodeViewProps) {
<Suspense fallback={null}>
<ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)}
onChange={(elements, _appState, files) => {
const fingerprint = `${elements.length}:${elements.reduce((s, e) => s + (e.version || 0), 0)}:${Object.keys(files).length}`;
if (isInitialLoadRef.current) {
lastFingerprintRef.current = fingerprint;
isInitialLoadRef.current = false;
return;
}
if (fingerprint !== lastFingerprintRef.current) {
lastFingerprintRef.current = fingerprint;
isDirtyRef.current = true;
}
}}
initialData={{
...excalidrawData,
scrollToContent: true,
@@ -1,199 +1,36 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Group,
ScrollArea,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import { IconFileDescription, IconLink, IconWorld } from "@tabler/icons-react";
import React from "react";
import { Button, Group, TextInput } from "@mantine/core";
import { IconLink } from "@tabler/icons-react";
import { useLinkEditorState } from "@/features/editor/components/link/use-link-editor-state.tsx";
import { LinkEditorPanelProps } from "@/features/editor/components/link/types.ts";
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 { IPage } from "@/features/page/types/page.types.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
import clsx from "clsx";
import classes from "./link.module.css";
export const LinkEditorPanel = ({
onSetLink,
initialUrl,
onUnsetLink,
}: LinkEditorPanelProps) => {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const { data: space } = useSpaceQuery(spaceSlug);
const state = useLinkEditorState({ onSetLink, initialUrl });
const [selectedIndex, setSelectedIndex] = useState(0);
const viewportRef = useRef<HTMLDivElement>(null);
const { data: suggestion } = useSearchSuggestionsQuery({
query: state.isSearchQuery ? state.url : "",
includeUsers: false,
includePages: true,
spaceId: space?.id,
limit: state.isSearchQuery ? 10 : 5,
preload: true,
const state = useLinkEditorState({
onSetLink,
initialUrl,
});
const pages: Partial<IPage>[] = suggestion?.pages ?? [];
useEffect(() => {
setSelectedIndex(0);
}, [pages.length]);
const selectPage = useCallback(
(page: Partial<IPage>) => {
const url = buildPageUrl(
page.space?.slug || spaceSlug,
page.slugId,
page.title,
);
onSetLink(url, true);
},
[onSetLink, spaceSlug],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const hasUrlItem = state.url.length > 0 && (state.isValidUrl || state.isSearchQuery);
const total = (hasUrlItem ? 1 : 0) + (state.isValidUrl ? 0 : pages.length);
if (total === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, total - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (hasUrlItem && selectedIndex === 0) {
onSetLink(state.url, false);
} else {
const pageIndex = hasUrlItem ? selectedIndex - 1 : selectedIndex;
if (pageIndex >= 0 && pageIndex < pages.length) {
selectPage(pages[pageIndex]);
}
}
}
},
[pages, selectedIndex, selectPage, state.isValidUrl, state.isSearchQuery, state.url, onSetLink],
);
useEffect(() => {
viewportRef.current
?.querySelector(`[data-item-index="${selectedIndex}"]`)
?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
const showPages = pages.length > 0 && !state.isValidUrl;
const showUrlItem = state.url.length > 0 && (state.isValidUrl || state.isSearchQuery);
const showDropdown = showPages || showUrlItem;
return (
<div>
<form onSubmit={state.handleSubmit}>
<TextInput
leftSection={<IconLink size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />}
classNames={{ input: classes.linkInput }}
placeholder={t("Paste link or search pages")}
value={state.url}
onChange={state.onChange}
onKeyDown={handleKeyDown}
autoFocus
/>
<Group gap="xs" style={{ flex: 1 }} wrap="nowrap">
<TextInput
leftSection={<IconLink size={16} />}
variant="filled"
placeholder={t("Paste link")}
value={state.url}
onChange={state.onChange}
/>
<Button p={"xs"} type="submit" disabled={!state.isValidUrl}>
{t("Save")}
</Button>
</Group>
</form>
{showDropdown && (
<>
{!state.isSearchQuery && !state.isValidUrl && (
<Text c="dimmed" size="xs" fw={600} px="sm" pt={10} pb={4}>
{t("Recents")}
</Text>
)}
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={300}
scrollbars="y"
scrollbarSize={6}
mt={state.url.length > 0 ? 8 : 0}
styles={{ content: { minWidth: 0 } }}
>
{showUrlItem && (
<UnstyledButton
data-item-index={0}
onClick={() => onSetLink(state.url, false)}
className={clsx(classes.searchItem, {
[classes.selectedSearchItem]: selectedIndex === 0,
})}
>
<Group gap={10} wrap="nowrap" align="flex-start">
<span className={classes.pageIcon}>
<IconWorld size={18} stroke={1.5} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate lh={1.3}>
{state.url}
</Text>
<Text size="xs" c="dimmed" lh={1.4}>
{t("Link to web page")}
</Text>
</div>
</Group>
</UnstyledButton>
)}
{!state.isValidUrl && pages.map((page, index) => {
const itemIndex = showUrlItem ? index + 1 : index;
return (
<UnstyledButton
data-item-index={itemIndex}
key={page.id || index}
onClick={() => selectPage(page)}
className={clsx(classes.searchItem, {
[classes.selectedSearchItem]: itemIndex === selectedIndex,
})}
>
<Group gap={10} wrap="nowrap" align="flex-start">
<span className={classes.pageIcon}>
{page.icon || <IconFileDescription size={18} stroke={1.5} />}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<AutoTooltipText size="sm" fw={500} truncate lh={1.3}>
{page.title || t("Untitled")}
</AutoTooltipText>
{page.space?.name && (
<AutoTooltipText size="xs" c="dimmed" truncate lh={1.4}>
{page.space.name}
</AutoTooltipText>
)}
</div>
</Group>
</UnstyledButton>
);
})}
</ScrollArea.Autosize>
</>
)}
{onUnsetLink && (
<UnstyledButton
onClick={onUnsetLink}
className={classes.removeLink}
>
<Text size="sm" c="red">
{t("Remove link")}
</Text>
</UnstyledButton>
)}
</div>
);
};
@@ -0,0 +1,103 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import React, { useCallback, useState } from "react";
import { TextSelection } from "@tiptap/pm/state";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
import { Card } from "@mantine/core";
import { useEditorState } from "@tiptap/react";
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
const [showEdit, setShowEdit] = useState(false);
const shouldShow = useCallback(() => {
return editor.isActive("link");
}, [editor]);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const link = ctx.editor.getAttributes("link");
return {
href: link.href,
};
},
});
const handleEdit = useCallback(() => {
setShowEdit(true);
}, []);
const onSetLink = useCallback(
(url: string) => {
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
})
.run();
setShowEdit(false);
},
[editor],
);
const onUnsetLink = useCallback(() => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
setShowEdit(false);
return null;
}, [editor]);
const onShowEdit = useCallback(() => {
setShowEdit(true);
}, []);
const onHideEdit = useCallback(() => {
setShowEdit(false);
}, []);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`link-menu`}
updateDelay={0}
options={{
onHide: () => {
setShowEdit(false);
},
placement: "bottom",
offset: 5,
// zIndex: 101,
}}
shouldShow={shouldShow}
>
{showEdit ? (
<Card
withBorder
radius="md"
padding="xs"
bg="var(--mantine-color-body)"
>
<LinkEditorPanel
initialUrl={editorState?.href}
onSetLink={onSetLink}
/>
</Card>
) : (
<LinkPreviewPanel
url={editorState?.href}
onClear={onUnsetLink}
onEdit={handleEdit}
/>
)}
</BaseBubbleMenu>
);
}
export default LinkMenu;
@@ -0,0 +1,60 @@
import {
Tooltip,
ActionIcon,
Card,
Divider,
Anchor,
Flex,
} from "@mantine/core";
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "./link.module.css";
export type LinkPreviewPanelProps = {
url: string;
onEdit: () => void;
onClear: () => void;
};
export const LinkPreviewPanel = ({
onClear,
onEdit,
url,
}: LinkPreviewPanelProps) => {
const { t } = useTranslation();
return (
<>
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
<Flex align="center">
<Tooltip label={url}>
<Anchor
href={url}
target="_blank"
rel="noopener noreferrer"
className={classes.link}
>
{url}
</Anchor>
</Tooltip>
<Flex align="center">
<Divider mx={4} orientation="vertical" />
<Tooltip label={t("Edit link")}>
<ActionIcon onClick={onEdit} variant="subtle" color="gray">
<IconPencil size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Remove link")}>
<ActionIcon onClick={onClear} variant="subtle" color="red">
<IconLinkOff size={16} />
</ActionIcon>
</Tooltip>
</Flex>
</Flex>
</Card>
</>
);
};
@@ -1,583 +0,0 @@
import { MarkViewContent, MarkViewProps } from "@tiptap/react";
import { useNavigate, useLocation, useParams } from "react-router-dom";
import {
IconFileDescription,
IconCopy,
IconExternalLink,
IconLinkOff,
IconPencil,
IconWorld,
} from "@tabler/icons-react";
import { useState, useCallback, useRef, useEffect } from "react";
import { notifications } from "@mantine/notifications";
import {
Divider,
Group,
Popover,
Text,
TextInput,
ActionIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import classes from "./link.module.css";
import { useTranslation } from "react-i18next";
import { INTERNAL_LINK_REGEX } from "@/lib/constants";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
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";
export const normalizeUrl = (url: string): string => {
if (!url) return url;
if (url.startsWith("/") || /^(\S+):(\/\/)?\S+$/.test(url)) return url;
return `https://${url}`;
};
const parseInternalLink = (
href: string,
internalAttr?: boolean,
): { isInternal: boolean; slugId: string | null; label: string } => {
if (!href) return { isInternal: !!internalAttr, slugId: null, label: "" };
const match = INTERNAL_LINK_REGEX.exec(href);
if (!match) {
if (internalAttr) return { isInternal: true, slugId: null, label: href };
return { isInternal: false, slugId: null, label: href };
}
const isExternal = match[2] && match[2] !== window.location.host;
const slug = match[5];
const slugId = extractPageSlugId(slug);
const namePart = slug.split("-").slice(0, -1).join("-");
return {
isInternal: !isExternal,
slugId,
label: namePart || slug,
};
};
export default function LinkView(props: MarkViewProps) {
const { mark, editor } = props;
const href = mark.attrs.href as string;
const navigate = useNavigate();
const location = useLocation();
const { shareId, pageSlug } = useParams();
const { t } = useTranslation();
const isShareRoute = location.pathname.startsWith("/share");
const [popoverState, setPopoverState] = useState<
"closed" | "preview" | "edit"
>("closed");
const [linkTitle, setLinkTitle] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [showSearch, setShowSearch] = useState(false);
const lastOpenState = useRef<"preview" | "edit">("preview");
const wrapperRef = useRef<HTMLSpanElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const isEditable = editor.isEditable;
const {
isInternal,
slugId,
label: linkLabel,
} = parseInternalLink(href, mark.attrs.internal);
const isPopoverVisible = popoverState !== "closed";
const activeView = isPopoverVisible ? popoverState : lastOpenState.current;
const { data: linkedPage } = usePageQuery({
pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null,
});
const { data: sharedPageData } = useSharePageQuery({
pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null,
});
const pageTitle = isShareRoute
? sharedPageData?.page?.title
: linkedPage?.title;
const pendingTitleRef = useRef<string | null>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
const getLinkPos = useCallback((): number | null => {
if (!wrapperRef.current) return null;
try {
return editor.view.posAtDOM(wrapperRef.current, 0);
} catch {
return null;
}
}, [editor]);
const handleUpdateLinkTitle = useCallback(
(newTitle: string) => {
if (!newTitle) return;
const pos = getLinkPos();
if (pos === null) return;
const { state } = editor;
const resolved = state.doc.resolve(pos);
const node = resolved.nodeAfter;
if (!node?.isText) return;
const linkMark = node.marks.find(
(m) => m.type.name === "link" && m.attrs.href === href,
);
if (!linkMark || node.text === newTitle) return;
const from = pos;
const to = pos + node.nodeSize;
const { tr } = state;
tr.insertText(newTitle, from, to);
tr.addMark(from, from + newTitle.length, linkMark);
editor.view.dispatch(tr);
},
[editor, href, getLinkPos],
);
const handleEditLink = useCallback(
(url: string, internal?: boolean) => {
const normalizedUrl = internal ? url : normalizeUrl(url);
const pos = getLinkPos();
if (pos === null) {
setPopoverState("closed");
return;
}
const { state } = editor;
const resolved = state.doc.resolve(pos);
const node = resolved.nodeAfter;
if (!node?.isText) {
setPopoverState("closed");
return;
}
const linkMark = node.marks.find(
(m) => m.type.name === "link" && m.attrs.href === href,
);
if (linkMark) {
const from = pos;
const to = pos + node.nodeSize;
const { tr } = state;
tr.removeMark(from, to, linkMark.type);
tr.addMark(
from,
to,
linkMark.type.create({ href: normalizedUrl, internal: !!internal }),
);
editor.view.dispatch(tr);
}
setPopoverState("closed");
},
[editor, href, getLinkPos],
);
useEffect(() => {
if (popoverState === "edit") {
const text = wrapperRef.current?.querySelector("a")?.textContent || "";
setLinkTitle(text);
setLinkUrl(href);
pendingTitleRef.current = null;
requestAnimationFrame(() => titleInputRef.current?.focus());
}
if (popoverState === "closed") {
if (pendingTitleRef.current !== null) {
handleUpdateLinkTitle(pendingTitleRef.current);
pendingTitleRef.current = null;
}
setShowSearch(false);
}
}, [popoverState, href, isInternal, handleUpdateLinkTitle]);
useEffect(() => {
if (popoverState !== "closed") {
lastOpenState.current = popoverState;
}
}, [popoverState]);
useEffect(() => {
if (!isPopoverVisible) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (
wrapperRef.current?.contains(target) ||
dropdownRef.current?.contains(target)
) {
return;
}
setPopoverState("closed");
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setPopoverState("closed");
}
};
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("keydown", handleEscape, true);
return () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("keydown", handleEscape, true);
};
}, [isPopoverVisible]);
const handleNavigate = useCallback(() => {
if (!href) return;
if (isInternal) {
let targetPath = href;
let anchor = "";
try {
const url = new URL(href);
targetPath = url.pathname;
anchor = url.hash.slice(1);
} catch {
if (href.includes("#")) {
[targetPath, anchor] = href.split("#");
}
}
if (anchor) {
const currentPageSlugId = extractPageSlugId(pageSlug);
if (!slugId || currentPageSlugId === slugId) {
const element =
document.querySelector(`[id="${anchor}"]`) ||
document.querySelector(`[data-id="${anchor}"]`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
navigate(`${location.pathname}#${anchor}`, { replace: true });
return;
}
}
}
if (isShareRoute && slugId) {
const sharedUrl = buildSharedPageUrl({
shareId,
pageSlugId: slugId,
pageTitle: pageTitle,
anchorId: anchor || undefined,
});
navigate(sharedUrl);
} else {
navigate(anchor ? `${targetPath}#${anchor}` : targetPath);
}
} else {
window.open(
sanitizeUrl(normalizeUrl(href)),
"_blank",
"noopener,noreferrer",
);
}
}, [
href,
navigate,
location.pathname,
isInternal,
isShareRoute,
slugId,
shareId,
pageTitle,
pageSlug,
]);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isEditable) {
setPopoverState("preview");
} else {
handleNavigate();
}
},
[handleNavigate, isEditable],
);
const handleCopy = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const fullUrl = sanitizeUrl(
isInternal ? `${window.location.origin}${href}` : href,
);
copyToClipboard(fullUrl);
notifications.show({
message: t("Link copied"),
});
setPopoverState("closed");
},
[href, isInternal, t],
);
const handleRemoveLink = useCallback(() => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
setPopoverState("closed");
}, [editor]);
const displayHref = sanitizeUrl(
isInternal
? isShareRoute && slugId
? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle })
: href
: normalizeUrl(href),
);
const linkTitleInput = (
<>
<Text size="xs" fw={600} c="dimmed" mt="sm" mb={4}>
{t("Link title")}
</Text>
<TextInput
ref={titleInputRef}
classNames={{ input: classes.linkInput }}
value={linkTitle}
onChange={(e) => {
const val = e.currentTarget.value;
setLinkTitle(val);
pendingTitleRef.current = val;
const anchor = wrapperRef.current?.querySelector("a");
if (anchor && val) {
const walker = document.createTreeWalker(
anchor,
NodeFilter.SHOW_TEXT,
);
const textNode = walker.nextNode();
if (textNode) {
const view = editor.view as any;
view.domObserver.stop();
textNode.nodeValue = val;
view.domObserver.start();
}
}
}}
onBlur={() => {
if (pendingTitleRef.current !== null) {
handleUpdateLinkTitle(pendingTitleRef.current);
pendingTitleRef.current = null;
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleUpdateLinkTitle(linkTitle);
pendingTitleRef.current = null;
setPopoverState("closed");
}
}}
size="sm"
/>
</>
);
return (
<Popover
opened={isPopoverVisible}
width={activeView === "edit" ? 320 : undefined}
position="bottom"
withArrow
shadow="md"
trapFocus={false}
closeOnClickOutside={false}
>
<Popover.Target>
<span
ref={wrapperRef}
className={classes.linkWrapper}
onClick={handleClick}
>
<a
href={displayHref}
spellCheck={false}
onClick={(e) => e.preventDefault()}
target={isInternal ? undefined : "_blank"}
rel={isInternal ? undefined : "noopener noreferrer"}
>
<MarkViewContent />
</a>
</span>
</Popover.Target>
<Popover.Dropdown
ref={dropdownRef}
p={activeView === "edit" ? "sm" : 6}
onMouseDown={(e) => e.stopPropagation()}
>
{activeView === "edit" ? (
<>
<Text size="xs" fw={600} c="dimmed" mb={4}>
{t("Page or URL")}
</Text>
{isInternal ? (
!showSearch ? (
<>
<UnstyledButton
className={classes.linkChip}
onClick={() => setShowSearch(true)}
>
<IconFileDescription
size={16}
stroke={1.5}
color="var(--mantine-color-dimmed)"
style={{ flexShrink: 0 }}
/>
<Text size="sm" fw={500} truncate>
{pageTitle || linkTitle}
</Text>
</UnstyledButton>
{linkTitleInput}
<Divider my="xs" />
<UnstyledButton
onClick={handleRemoveLink}
className={classes.removeLink}
>
<Group gap={8}>
<IconLinkOff size={16} stroke={1.5} />
<Text size="sm">{t("Remove link")}</Text>
</Group>
</UnstyledButton>
</>
) : (
<LinkEditorPanel
onSetLink={handleEditLink}
onUnsetLink={handleRemoveLink}
/>
)
) : (
<>
<TextInput
leftSection={
<IconWorld
size={16}
stroke={1.5}
color="var(--mantine-color-dimmed)"
/>
}
classNames={{ input: classes.linkInput }}
value={linkUrl}
onChange={(e) => setLinkUrl(e.currentTarget.value)}
onBlur={() => {
if (linkUrl && linkUrl !== href) {
handleEditLink(linkUrl, false);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (linkUrl && linkUrl !== href) {
handleEditLink(linkUrl, false);
}
}
}}
size="sm"
/>
{linkTitleInput}
<Divider my="xs" />
<UnstyledButton
onClick={handleRemoveLink}
className={classes.removeLink}
>
<Group gap={8}>
<IconLinkOff size={16} stroke={1.5} />
<Text size="sm">{t("Remove link")}</Text>
</Group>
</UnstyledButton>
</>
)}
</>
) : (
<Group gap={4} wrap="nowrap">
<Group
component="a"
//@ts-ignore
href={displayHref}
target={isInternal ? undefined : "_blank"}
rel={isInternal ? undefined : "noopener noreferrer"}
gap={6}
wrap="nowrap"
style={{
cursor: "pointer",
maxWidth: 250,
textDecoration: "none",
color: "inherit",
userSelect: "none",
}}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
handleNavigate();
}}
>
{isInternal ? (
<IconFileDescription size={18} color="gray" />
) : (
<IconExternalLink size={18} color="gray" />
)}
<Text size="sm" truncate fw={500}>
{isInternal ? pageTitle || linkLabel : href}
</Text>
</Group>
<Divider orientation="vertical" />
<Tooltip label={t("Edit link")} withArrow withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
setShowSearch(false);
setPopoverState("edit");
}}
>
<IconPencil size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Copy link")} withArrow withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopy(e);
}}
>
<IconCopy size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Remove link")} withArrow withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveLink();
}}
>
<IconLinkOff size={18} />
</ActionIcon>
</Tooltip>
</Group>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -1,102 +1,6 @@
.linkWrapper {
position: relative;
display: inline;
}
.linkInput {
border: 1.5px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: transparent;
&:focus {
border-color: light-dark(
var(--mantine-color-blue-4),
var(--mantine-color-blue-6)
);
box-shadow: 0 0 0 1px
light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-6));
}
}
.pageIcon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--mantine-color-dimmed);
font-size: 16px;
margin-top: 2px;
}
.searchItem {
width: 100%;
padding: 7px 4px;
color: var(--mantine-color-text);
border-radius: var(--mantine-radius-sm);
&:hover {
@mixin light {
background: var(--mantine-color-gray-1);
}
@mixin dark {
background: var(--mantine-color-gray-light);
}
}
}
.selectedSearchItem {
@mixin light {
background: var(--mantine-color-gray-1);
}
@mixin dark {
background: var(--mantine-color-gray-light);
}
}
.linkChip {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border-radius: var(--mantine-radius-sm);
cursor: pointer;
@mixin light {
background: var(--mantine-color-gray-1);
}
@mixin dark {
background: var(--mantine-color-dark-5);
}
&:hover {
@mixin light {
background: var(--mantine-color-gray-2);
}
@mixin dark {
background: var(--mantine-color-dark-4);
}
}
}
.removeLink {
width: 100%;
padding: 4px;
border-radius: var(--mantine-radius-sm);
&:hover {
@mixin light {
background: var(--mantine-color-gray-1);
}
@mixin dark {
background: var(--mantine-color-dark-5);
}
}
}
.link {
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1,5 +1,4 @@
export type LinkEditorPanelProps = {
initialUrl?: string;
onSetLink: (url: string, internal?: boolean) => void;
onUnsetLink?: () => void;
onSetLink: (url: string, openInNewTab?: boolean) => void;
};
@@ -13,16 +13,11 @@ export const useLinkEditorState = ({
const isValidUrl = useMemo(() => /^(\S+):(\/\/)?\S+$/.test(url), [url]);
const isSearchQuery = useMemo(
() => url.length > 0 && !isValidUrl,
[url, isValidUrl],
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (isValidUrl) {
onSetLink(url, false);
onSetLink(url);
}
},
[url, isValidUrl, onSetLink],
@@ -34,6 +29,5 @@ export const useLinkEditorState = ({
onChange,
handleSubmit,
isValidUrl,
isSearchQuery,
};
};
@@ -3,7 +3,6 @@ import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import {
buildPageUrl,
buildSharedPageUrl,
@@ -14,23 +13,17 @@ import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) {
const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const isPageMention = entityType === "page";
const { spaceSlug, pageSlug } = useParams();
const { shareId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const {
data: page,
isLoading,
isError,
} = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
const { data: sharedPage } = useSharePageQuery({
pageId: isPageMention && isShareRoute ? slugId : undefined,
});
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId;
@@ -46,12 +39,10 @@ export default function MentionView(props: NodeViewProps) {
}
};
const sharePageTitle = sharedPage?.page?.title || label;
const shareSlugUrl = buildSharedPageUrl({
shareId,
pageSlugId: slugId,
pageTitle: sharePageTitle,
pageTitle: label,
anchorId,
});
@@ -63,59 +54,21 @@ export default function MentionView(props: NodeViewProps) {
</Text>
)}
{isPageMention && isShareRoute && (
<Anchor
component={Link}
fw={500}
to={shareSlugUrl}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{sharePageTitle}
</span>
</Anchor>
{entityType === "page" && isError && (
<Text component="span" c="dimmed" size="sm">
{label}
</Text>
)}
{isPageMention && !isShareRoute && isError && (
{entityType === "page" && !isError && (
<Anchor
component={Link}
fw={500}
to={buildPageUrl(spaceSlug, slugId, label, anchorId)}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{label}
</span>
</Anchor>
)}
{isPageMention && !isShareRoute && !isError && (
<Anchor
component={Link}
fw={500}
to={buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)}
to={
isShareRoute
? shareSlugUrl
: buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)
}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
@@ -25,9 +25,9 @@ export default function SubpagesView(props: NodeViewProps) {
// Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId);
const { data, isLoading, error } = useGetSidebarPagesQuery(
shareId ? null : { pageId: currentPageId },
);
const { data, isLoading, error } = useGetSidebarPagesQuery({
pageId: currentPageId,
});
const subpages = useMemo(() => {
// If we're in a shared context, use the shared subpages
@@ -86,9 +86,8 @@ import fortran from "highlight.js/lib/languages/fortran";
import haskell from "highlight.js/lib/languages/haskell";
import scala from "highlight.js/lib/languages/scala";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion.ts";
import { ReactNodeViewRenderer, ReactMarkViewRenderer } from "@tiptap/react";
import { ReactNodeViewRenderer } from "@tiptap/react";
import MentionView from "@/features/editor/components/mention/mention-view.tsx";
import LinkView from "@/features/editor/components/link/link-view.tsx";
import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command";
@@ -177,10 +176,6 @@ export const mainExtensions = [
}),
LinkExtension.configure({
openOnClick: false,
}).extend({
addMarkView() {
return ReactMarkViewRenderer(LinkView);
},
}),
Superscript,
SubScript,
@@ -42,7 +42,7 @@ export const useEditorScroll = ({
return;
}
const dom = editor.view.dom.querySelector(`[id="${targetId}"], [data-id="${targetId}"]`);
const dom = editor.view.dom.querySelector(`[id="${targetId}"]`);
if (dom) {
dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
resolve(true);
@@ -50,6 +50,7 @@ import {
handleFileDrop,
handlePaste,
} from "@/features/editor/components/common/editor-paste-handler.tsx";
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
@@ -417,6 +418,7 @@ export default function PageEditor({
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} />
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</div>
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
@@ -98,12 +98,12 @@
a {
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
@mixin light {
border-bottom: 0.07em solid var(--mantine-color-dark-0);
border-bottom: 0.05em solid var(--mantine-color-dark-0);
}
@mixin dark {
border-bottom: 0.07em solid var(--mantine-color-dark-2);
border-bottom: 0.05em solid var(--mantine-color-dark-2);
}
font-weight: 500;
/*font-weight: 500; */
text-decoration: none;
cursor: pointer;
}
@@ -223,13 +223,13 @@
.ProseMirror > h4,
.ProseMirror > h5,
.ProseMirror > h6 {
> .link-btn {
cursor: pointer;
position: relative;
}
> .link-btn > .link-btn-content {
opacity: 0;
position: absolute;
@@ -241,7 +241,7 @@
justify-content: center;
flex-direction: column;
}
&:hover > .link-btn > .link-btn-content {
opacity: 1;
}
@@ -1,4 +1,4 @@
import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-query";
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
searchAttachments,
searchPage,
@@ -32,7 +32,6 @@ export function useSearchSuggestionsQuery(
staleTime: 60 * 1000, // 1min
queryFn: () => searchSuggestions(queryParams),
enabled: preload || !!params.query,
placeholderData: keepPreviousData,
});
}
@@ -113,7 +113,7 @@ export async function getInvitationById(data: {
export async function createWorkspace(
data: ISetupWorkspace,
): Promise<{ workspace: IWorkspace; exchangeToken?: string; requiresEmailVerification?: boolean; emailSignature?: string }> {
): Promise<{ workspace: IWorkspace } & { exchangeToken: string }> {
const req = await api.post("/workspace/create", data);
return req.data;
}
-1
View File
@@ -12,7 +12,6 @@ const APP_ROUTE = {
SELECT_WORKSPACE: "/select",
MFA_CHALLENGE: "/login/mfa",
MFA_SETUP_REQUIRED: "/login/mfa/setup",
VERIFY_EMAIL: "/verify-email",
},
SETTINGS: {
ACCOUNT: {
-1
View File
@@ -107,7 +107,6 @@
"sanitize-filename-ts": "1.0.2",
"socket.io": "^4.8.3",
"stripe": "^17.5.0",
"tlds": "^1.261.0",
"tmp-promise": "^3.0.3",
"tseep": "^1.3.1",
"typesense": "^2.1.0",
-2
View File
@@ -25,7 +25,6 @@ import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
const enterpriseModules = [];
try {
@@ -48,7 +47,6 @@ try {
middleware: { mount: true },
}),
LoggerModule,
NoopAuditModule,
CoreModule,
DatabaseModule,
EnvironmentModule,
@@ -1,142 +0,0 @@
import { containsDomain } from './no-urls.validator';
// containsDomain returns true if value contains a domain-like pattern
// The full NoUrls validator also checks for https:// URLs separately
describe('containsDomain', () => {
describe('bare domains with real TLDs — should block', () => {
it.each([
'example.com',
'example.net',
'example.org',
'example.io',
'example.co',
'example.dev',
'example.app',
'example.me',
'example.info',
'example.tech',
'example.aero',
'example.cloud',
'example.museum',
'example.abc',
'example.uk',
'example.de',
'example.fr',
'example.ru',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('domains with paths — should block', () => {
it.each([
'example.com/reset',
'example.com/reset-password',
'click example.com/page',
'go to example.net/login',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('multi-part domains — should block', () => {
it.each([
'Foo.com.net',
'Foo.com.',
'Foo.mine.net',
'Foo.mine.ne',
'sub.example.com',
'login.example.co.uk',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('domain in sentence — should block', () => {
it.each([
'Reset your password at example.com',
'URGENT click example.com/reset',
'Visit example.org for details',
'go to mysite.io now',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('case insensitive — should block', () => {
it.each(['EXAMPLE.COM', 'Example.Com', 'example.COM'])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('fake TLDs — should allow', () => {
it.each([
'Foo.mine',
'Foo.blarg',
'Foo.qqq',
'Foo.zz',
'Foo.abcd',
'Foo.abcde',
'Foo.abcdef',
'Foo.abcdefg',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('too short suffix — should allow', () => {
it.each(['Foo.a', 'Foo.c', 'A.B'])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('multi-part with fake TLD — should allow', () => {
it.each(['Foo.mine.', 'Foo.mine.n'])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('emails — should allow', () => {
it.each([
'user@example.com',
'admin@company.org',
'test@sub.domain.co.uk',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('normal names — should allow', () => {
it.each([
'John Smith',
'Dr. Smith',
'A. B. Charlie',
'John',
'Mary Jane',
"O'Brien",
'Jean-Pierre',
'José García',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('IP addresses — should allow', () => {
it.each(['192.168.1.1', '10.0.0.1', '127.0.0.1'])(
'allows "%s"',
(value) => {
expect(containsDomain(value)).toBe(false);
},
);
});
describe('edge cases — should allow', () => {
it.each(['', ' ', '.', '..', 'hello', '.com', 'a.b'])(
'allows "%s"',
(value) => {
expect(containsDomain(value)).toBe(false);
},
);
});
});
@@ -1,42 +0,0 @@
import { registerDecorator, ValidationOptions } from 'class-validator';
import * as tlds from 'tlds';
const URL_PATTERN = /https?:\/\//i;
const tldSet = new Set(tlds.map((t) => t.toLowerCase()));
export function containsDomain(value: string): boolean {
const tokens = value.split(/\s+/);
for (const token of tokens) {
if (token.includes('@')) continue;
const segments = token.split('.');
for (let i = 1; i < segments.length; i++) {
const suffix = segments[i].replace(/[^\w].*/g, '');
if (segments[i - 1] && suffix && tldSet.has(suffix.toLowerCase())) {
return true;
}
}
}
return false;
}
export function NoUrls(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'noUrls',
target: object.constructor,
propertyName,
options: {
message: 'Must not contain URLs or domain names',
...validationOptions,
},
validator: {
validate(value: unknown) {
if (typeof value !== 'string') return true;
if (URL_PATTERN.test(value)) return false;
if (containsDomain(value)) return false;
return true;
},
},
});
};
}
@@ -1,4 +1,3 @@
export enum UserTokenType {
FORGOT_PASSWORD = 'forgot-password',
EMAIL_VERIFICATION = 'email-verification',
}
-32
View File
@@ -1,37 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { Workspace } from '@docmost/db/types/entity.types';
import { createHmac } from 'node:crypto';
export function computeEmailSignature(
email: string,
workspaceId: string,
appSecret: string,
): string {
return createHmac('sha256', appSecret)
.update(`${email.toLowerCase()}:${workspaceId}`)
.digest('hex');
}
export function throwIfEmailNotVerified(opts: {
isCloud: boolean;
emailVerifiedAt: Date | null;
email: string;
workspaceId: string;
appSecret: string;
}): void {
if (!opts.isCloud || opts.emailVerifiedAt) return;
const emailSignature = computeEmailSignature(
opts.email,
opts.workspaceId,
opts.appSecret,
);
throw new BadRequestException({
message:
'Please verify your email address. Check your inbox for the verification link.',
emailSignature,
});
}
export function validateSsoEnforcement(workspace: Workspace) {
if (workspace.enforceSso) {
@@ -7,13 +7,11 @@ import {
} from 'class-validator';
import { CreateUserDto } from './create-user.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateAdminUserDto extends CreateUserDto {
@IsNotEmpty()
@MinLength(1)
@MaxLength(50)
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -7,14 +7,12 @@ import {
MinLength,
} from 'class-validator';
import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateUserDto {
@IsOptional()
@MinLength(1)
@MaxLength(50)
@IsString()
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -17,7 +17,6 @@ import {
isUserDisabled,
nanoIdGen,
} from '../../../common/helpers';
import { throwIfEmailNotVerified } from '../auth.util';
import { ChangePasswordDto } from '../dto/change-password.dto';
import { MailService } from '../../../integrations/mail/mail.service';
import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email';
@@ -37,7 +36,6 @@ import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
@Injectable()
export class AuthService {
@@ -48,7 +46,6 @@ export class AuthService {
private userTokenRepo: UserTokenRepo,
private mailService: MailService,
private domainService: DomainService,
private environmentService: EnvironmentService,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -72,14 +69,6 @@ export class AuthService {
throw new UnauthorizedException(errorMessage);
}
throwIfEmailNotVerified({
isCloud: this.environmentService.isCloud(),
emailVerifiedAt: user.emailVerifiedAt,
email: user.email,
workspaceId,
appSecret: this.environmentService.getAppSecret(),
});
user.lastLoginAt = new Date();
await this.userRepo.updateLastLogin(user.id, workspaceId);
@@ -258,14 +247,6 @@ export class AuthService {
template: emailTemplate,
});
if (this.environmentService.isCloud() && !user.emailVerifiedAt) {
await this.userRepo.updateUser(
{ emailVerifiedAt: new Date() },
user.id,
workspace.id,
);
}
// Check if user has MFA enabled or workspace enforces MFA
const userHasMfa = user?.['mfa']?.isEnabled || false;
const workspaceEnforcesMfa = workspace.enforceMfa || false;
+11
View File
@@ -20,6 +20,10 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd
import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
import {
AUDIT_SERVICE,
NoopAuditService,
} from '../integrations/audit/audit.service';
import { ClsMiddleware } from 'nestjs-cls';
@Module({
@@ -39,6 +43,13 @@ import { ClsMiddleware } from 'nestjs-cls';
NotificationModule,
WatcherModule,
],
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
},
],
exports: [AUDIT_SERVICE],
})
export class CoreModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
@@ -12,7 +12,6 @@ import {
MinLength,
} from 'class-validator';
import { UserRole } from '../../../common/helpers/types/permission';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class InviteUserDto {
@IsArray()
@@ -45,7 +44,6 @@ export class AcceptInviteDto extends InvitationIdDto {
@MinLength(2)
@MaxLength(60)
@IsString()
@NoUrls()
name: string;
@MinLength(8)
@@ -244,7 +244,7 @@ export class WorkspaceService {
await this.billingQueue.add(
QueueJob.WELCOME_EMAIL,
{ userId: user.id },
{ delay: 30 * 60 * 1000 }, // 30m
{ delay: 60 * 1000 }, // 1m
);
} catch (err) {
this.logger.error(err);
@@ -1,14 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
@Global()
@Module({
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
},
],
exports: [AUDIT_SERVICE],
})
export class NoopAuditModule {}
@@ -10,7 +10,7 @@ import {
validateSync,
} from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { IsISO6391 } from '../../common/validators/is-iso6391';
import { IsISO6391 } from '../../common/validator/is-iso6391';
export class EnvironmentVariables {
@IsNotEmpty()
@@ -291,7 +291,6 @@ export class ExportService {
prosemirrorJson,
slugIdToPath,
currentPagePath,
baseUrl,
);
if (includeAttachments) {
@@ -62,7 +62,6 @@ export function replaceInternalLinks(
prosemirrorJson: any,
slugIdToPath: Record<string, string>,
currentPagePath: string,
baseUrl?: string,
) {
const doc = jsonToNode(prosemirrorJson);
@@ -77,10 +76,6 @@ export function replaceInternalLinks(
const localPath = slugIdToPath[slugId];
if (!localPath) {
if (baseUrl && mark.attrs.href.startsWith('/')) {
//@ts-expect-error
mark.attrs.href = `${baseUrl}${mark.attrs.href}`;
}
continue;
}
@@ -25,7 +25,6 @@ import {
buildAttachmentCandidates,
collectMarkdownAndHtmlFiles,
encodeFilePath,
extractNotionPartialId,
readDocmostMetadata,
stripNotionID,
} from '../utils/import.utils';
@@ -161,17 +160,10 @@ export class FileImportTaskService {
fileTask: FileTask;
}): Promise<void> {
const { extractDir, fileTask } = opts;
const isNotion = fileTask.source === FileImportSource.Notion;
const allFiles = await collectMarkdownAndHtmlFiles(extractDir);
const attachmentCandidates = await buildAttachmentCandidates(extractDir);
const docmostMetadata = await readDocmostMetadata(extractDir);
const space = await this.db
.selectFrom('spaces')
.select(['slug'])
.where('id', '=', fileTask.spaceId)
.executeTakeFirst();
const pagesMap = new Map<string, ImportPageNode>();
for (const absPath of allFiles) {
@@ -232,17 +224,7 @@ export class FileImportTaskService {
}
// For each folder with content, create a placeholder page if no corresponding .md or .html exists
// Process folders with partial UUIDs first so they claim their specific files
// before plain folders (without partial UUIDs) take whatever remains.
const sortedFolders = isNotion
? [...foldersWithContent].sort((a, b) => {
const aHasPartial = extractNotionPartialId(path.basename(a)) ? 0 : 1;
const bHasPartial = extractNotionPartialId(path.basename(b)) ? 0 : 1;
return aHasPartial - bHasPartial;
})
: [...foldersWithContent];
sortedFolders.forEach((folderPath) => {
foldersWithContent.forEach((folderPath) => {
if (
skipRootFolder &&
folderPath?.toLowerCase() === skipRootFolder?.toLowerCase()
@@ -255,54 +237,18 @@ export class FileImportTaskService {
if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) {
const folderName = path.basename(folderPath);
const parentDir = path.dirname(folderPath);
// Notion no longer adds UUIDs to folder names, but still adds them to files.
// For duplicate names, Notion adds a partial UUID "{first4}-{last4}" to the folder.
let matched = false;
if (isNotion) {
const partialId = extractNotionPartialId(folderName);
const strippedFolderName = stripNotionID(folderName);
const isSameDir = (fileDir: string) =>
fileDir === parentDir || (parentDir === '.' && !fileDir.includes('/'));
for (const [filePath, page] of pagesMap.entries()) {
if (!isSameDir(path.dirname(filePath))) continue;
if (page.name !== strippedFolderName) continue;
if (partialId) {
// Match partial UUID against the full UUID in the filename
const fileBase = path.basename(filePath, path.extname(filePath));
const fullIdMatch = fileBase.match(/[a-f0-9]{32}$/i);
if (!fullIdMatch) continue;
const fullId = fullIdMatch[0].toLowerCase();
if (!fullId.startsWith(partialId.prefix) || !fullId.endsWith(partialId.suffix)) {
continue;
}
}
pagesMap.delete(filePath);
page.filePath = mdPath;
pagesMap.set(mdPath, page);
matched = true;
break;
}
}
if (!matched) {
const encodedMdPath = encodeFilePath(mdPath);
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
pagesMap.set(mdPath, {
id: v7(),
slugId: generateSlugId(),
name: stripNotionID(folderName),
content: '',
parentPageId: null,
fileExtension: '.md',
filePath: mdPath,
icon: placeholderMetadata?.icon ?? null,
});
}
const encodedMdPath = encodeFilePath(mdPath);
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
pagesMap.set(mdPath, {
id: v7(),
slugId: generateSlugId(),
name: stripNotionID(folderName),
content: '',
parentPageId: null,
fileExtension: '.md',
filePath: mdPath,
icon: placeholderMetadata?.icon ?? null,
});
}
});
@@ -512,7 +458,6 @@ export class FileImportTaskService {
creatorId: fileTask.creatorId,
sourcePageId: page.id,
workspaceId: fileTask.workspaceId,
spaceSlug: space?.slug,
});
const pmState = getProsemirrorContent(
@@ -4,8 +4,6 @@ import * as path from 'path';
import { v7 } from 'uuid';
import { InsertableBacklink } from '@docmost/db/types/entity.types';
import { Cheerio, CheerioAPI, load } from 'cheerio';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import slugify = require('@sindresorhus/slugify');
// Check if text contains Unicode characters (for emojis/icons)
function isUnicodeCharacter(text: string): boolean {
@@ -24,7 +22,6 @@ export async function formatImportHtml(opts: {
workspaceId: string;
pageDir?: string;
attachmentCandidates?: string[];
spaceSlug?: string;
}): Promise<{
html: string;
backlinks: InsertableBacklink[];
@@ -64,7 +61,6 @@ export async function formatImportHtml(opts: {
creatorId,
sourcePageId,
workspaceId,
opts.spaceSlug,
);
return {
@@ -320,7 +316,6 @@ export async function rewriteInternalLinksToMentionHtml(
creatorId: string,
sourcePageId: string,
workspaceId: string,
spaceSlug?: string,
): Promise<InsertableBacklink[]> {
const normalize = (p: string) => p.replace(/\\/g, '/');
const backlinks: InsertableBacklink[] = [];
@@ -344,37 +339,19 @@ export async function rewriteInternalLinksToMentionHtml(
);
const meta = filePathToPageMetaMap.get(resolved);
if (!meta) return;
const linkText = $a.text().trim();
const titleMatch =
linkText === meta.title ||
linkText === meta.title?.trim();
if (titleMatch) {
const mentionId = v7();
const $mention = $('<span>')
.attr({
'data-type': 'mention',
'data-id': mentionId,
'data-entity-type': 'page',
'data-entity-id': meta.id,
'data-label': meta.title,
'data-slug-id': meta.slugId,
'data-creator-id': creatorId,
})
.text(meta.title);
$a.replaceWith($mention);
} else {
const titleSlug = slugify(meta.title?.substring(0, 70) || 'untitled');
const pageSlug = `${titleSlug}-${meta.slugId}`;
const internalHref = spaceSlug
? `/s/${spaceSlug}/p/${pageSlug}`
: `/p/${pageSlug}`;
$a.attr('href', internalHref);
$a.attr('data-internal', 'true');
}
const mentionId = v7();
const $mention = $('<span>')
.attr({
'data-type': 'mention',
'data-id': mentionId,
'data-entity-type': 'page',
'data-entity-id': meta.id,
'data-label': meta.title,
'data-slug-id': meta.slugId,
'data-creator-id': creatorId,
})
.text(meta.title);
$a.replaceWith($mention);
backlinks.push({ sourcePageId, targetPageId: meta.id, workspaceId });
});
@@ -81,25 +81,7 @@ export async function collectMarkdownAndHtmlFiles(
export function stripNotionID(fileName: string): string {
// Handle optional separator (space or dash) + 32 alphanumeric chars at end
const notionIdPattern = /[ -]?[a-z0-9]{32}$/i;
// Handle partial UUID format used for duplicate names: "Name abcd-ef12"
const partialIdPattern = / [a-f0-9]{4}-[a-f0-9]{4}$/i;
return fileName
.replace(notionIdPattern, '')
.replace(partialIdPattern, '')
.trim();
}
/**
* Extract a partial Notion UUID suffix from a folder name.
* Notion adds "{first4}-{last4}" when multiple pages share the same title.
* e.g. "Cool 324d-35ab" → { prefix: "324d", suffix: "35ab" }
*/
export function extractNotionPartialId(
folderName: string,
): { prefix: string; suffix: string } | null {
const match = folderName.match(/ ([a-f0-9]{4})-([a-f0-9]{4})$/i);
if (!match) return null;
return { prefix: match[1].toLowerCase(), suffix: match[2].toLowerCase() };
return fileName.replace(notionIdPattern, '').trim();
}
export function encodeFilePath(filePath: string): string {
@@ -1,7 +1,7 @@
import { Section, Text } from '@react-email/components';
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
@@ -23,7 +23,19 @@ export const CommentCreateEmail = ({
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
@@ -1,7 +1,7 @@
import { Section, Text } from '@react-email/components';
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
@@ -23,7 +23,19 @@ export const CommentMentionEmail = ({
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
@@ -1,7 +1,7 @@
import { Section, Text } from '@react-email/components';
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
@@ -23,7 +23,19 @@ export const CommentResolvedEmail = ({
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
@@ -1,7 +1,7 @@
import { Section, Text } from '@react-email/components';
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
inviteLink: string;
@@ -17,7 +17,19 @@ export const InvitationEmail = ({ inviteLink }: Props) => {
Please click the button below to accept this invitation.
</Text>
</Section>
<EmailButton href={inviteLink}>Accept Invite</EmailButton>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={inviteLink} style={button}>
Accept Invite
</Button>
</Section>
</MailBody>
);
};
@@ -1,7 +1,7 @@
import { Section, Text } from '@react-email/components';
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
@@ -19,7 +19,19 @@ export const PageMentionEmail = ({ actorName, pageTitle, pageUrl }: Props) => {
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
@@ -1,7 +1,7 @@
import { Section, Text } from '@react-email/components';
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
@@ -25,7 +25,19 @@ export const PermissionGrantedEmail = ({
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
@@ -1,4 +1,4 @@
import { button as buttonStyle, container, footer, h1, logo, main } from '../css/styles';
import { container, footer, h1, logo, main } from '../css/styles';
import {
Body,
Container,
@@ -35,47 +35,6 @@ export function MailHeader() {
);
}
interface EmailButtonProps {
href: string;
children: React.ReactNode;
}
export function EmailButton({ href, children }: EmailButtonProps) {
return (
<table
role="presentation"
cellPadding="0"
cellSpacing="0"
style={{ margin: '0 0 15px 15px' }}
>
<tr>
<td
style={{
backgroundColor: buttonStyle.backgroundColor,
borderRadius: buttonStyle.borderRadius,
textAlign: 'center' as const,
}}
>
<a
href={href}
target="_blank"
style={{
color: buttonStyle.color,
fontFamily: buttonStyle.fontFamily,
fontSize: buttonStyle.fontSize,
textDecoration: 'none',
display: 'inline-block',
padding: '8px 16px',
}}
>
{children}
</a>
</td>
</tr>
</table>
);
}
export function MailFooter() {
return (
<Section style={footer}>
-1
View File
@@ -67,7 +67,6 @@ async function bootstrap() {
'/api/sso/google',
'/api/workspace/create',
'/api/workspace/joined',
'/api/workspace/find-by-email',
];
if (
+8 -62
View File
@@ -1,7 +1,12 @@
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
import type { ResizableNodeViewDirection } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "./media-utils";
import {
normalizeFileUrl,
applyAlignment,
createPlaceholderView,
setupMediaLoading,
} from "./media-utils";
export type DrawioResizeOptions = {
enabled: boolean;
@@ -205,22 +210,7 @@ export const Drawio = Node.create<DrawioOptions>({
const { node, getPos, HTMLAttributes, editor } = props;
if (!node.attrs.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
return createPlaceholderView(this.options.view, props);
}
const el = document.createElement("img");
@@ -291,54 +281,10 @@ export const Drawio = Node.create<DrawioOptions>({
},
});
const dom = nodeView.dom as HTMLElement;
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
dom.style.background =
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.onload = () => {
dom.style.pointerEvents = "";
dom.style.background = "";
};
setupMediaLoading(nodeView.dom as HTMLElement, el, node);
return nodeView;
};
},
});
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
+8 -62
View File
@@ -1,7 +1,12 @@
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
import type { ResizableNodeViewDirection } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "./media-utils";
import {
normalizeFileUrl,
applyAlignment,
createPlaceholderView,
setupMediaLoading,
} from "./media-utils";
export type ExcalidrawResizeOptions = {
enabled: boolean;
@@ -205,22 +210,7 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
const { node, getPos, HTMLAttributes, editor } = props;
if (!node.attrs.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
return createPlaceholderView(this.options.view, props);
}
const el = document.createElement("img");
@@ -291,54 +281,10 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
},
});
const dom = nodeView.dom as HTMLElement;
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
dom.style.background =
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.onload = () => {
dom.style.pointerEvents = "";
dom.style.background = "";
};
setupMediaLoading(nodeView.dom as HTMLElement, el, node);
return nodeView;
};
},
});
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
+8 -66
View File
@@ -6,7 +6,12 @@ import {
Range,
ResizableNodeView,
} from "@tiptap/core";
import { normalizeFileUrl } from "../media-utils";
import {
normalizeFileUrl,
applyAlignment,
createPlaceholderView,
setupMediaLoading,
} from "../media-utils";
import type { ResizableNodeViewDirection } from "@tiptap/core";
export type ImageResizeOptions = {
@@ -216,25 +221,8 @@ export const TiptapImage = Image.extend<ImageOptions>({
return (props) => {
const { node, getPos, HTMLAttributes, editor } = props;
// If no src yet (placeholder/uploading), use React view for loading UI
if (!HTMLAttributes.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
// When the node gets a src, return false from update to force rebuild
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
return createPlaceholderView(this.options.view, props);
}
// Has src — use ResizableNodeView
@@ -331,56 +319,10 @@ export const TiptapImage = Image.extend<ImageOptions>({
},
});
const dom = nodeView.dom as HTMLElement;
// Apply initial alignment
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
// Defer conversion until we can measure the container
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
dom.style.background =
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.onload = () => {
dom.style.pointerEvents = "";
dom.style.background = "";
};
setupMediaLoading(nodeView.dom as HTMLElement, el, node);
return nodeView;
};
},
});
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
-13
View File
@@ -6,19 +6,6 @@ import { EditorView } from "@tiptap/pm/view";
export const LinkExtension = TiptapLink.extend({
inclusive: false,
addAttributes() {
return {
...this.parent?.(),
internal: {
default: false,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-internal') === 'true',
renderHTML: (attributes) =>
attributes.internal ? { 'data-internal': 'true' } : {},
},
};
},
parseHTML() {
return [
{
@@ -1,4 +1,5 @@
import { Editor } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
export function normalizeFileUrl(src: string): string {
if (src && src.startsWith("/files/")) {
@@ -7,6 +8,78 @@ export function normalizeFileUrl(src: string): string {
return src || "";
}
export function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
export function createPlaceholderView(viewComponent: any, props: any) {
const { node, editor } = props;
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(viewComponent);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode: any, decorations: any, innerDecorations: any) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
}
export function setupMediaLoading(
dom: HTMLElement,
el: HTMLElement,
node: any,
loadEvent: "load" | "loadedmetadata" = "load",
) {
applyAlignment(dom, node.attrs.align || "center");
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(containerWidth * (pctValue / 100));
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
dom.style.pointerEvents = "none";
dom.style.background =
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.addEventListener(
loadEvent,
() => {
dom.style.pointerEvents = "";
dom.style.background = "";
},
{ once: true },
);
}
export type UploadFn = (
file: File,
editor: Editor,
+8 -62
View File
@@ -1,6 +1,11 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { Range, Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
import { normalizeFileUrl } from "../media-utils";
import {
normalizeFileUrl,
applyAlignment,
createPlaceholderView,
setupMediaLoading,
} from "../media-utils";
import type { ResizableNodeViewDirection } from "@tiptap/core";
export type VideoResizeOptions = {
@@ -205,22 +210,7 @@ export const TiptapVideo = Node.create<VideoOptions>({
const { node, getPos, HTMLAttributes, editor } = props;
if (!node.attrs.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
return createPlaceholderView(this.options.view, props);
}
const el = document.createElement("video");
@@ -299,54 +289,10 @@ export const TiptapVideo = Node.create<VideoOptions>({
},
});
const dom = nodeView.dom as HTMLElement;
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while video loads from server
dom.style.pointerEvents = "none";
dom.style.background =
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.onloadedmetadata = () => {
dom.style.pointerEvents = "";
dom.style.background = "";
};
setupMediaLoading(nodeView.dom as HTMLElement, el, node, "loadedmetadata");
return nodeView;
};
},
});
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
-9
View File
@@ -681,9 +681,6 @@ importers:
stripe:
specifier: ^17.5.0
version: 17.5.0
tlds:
specifier: ^1.261.0
version: 1.261.0
tmp-promise:
specifier: ^3.0.3
version: 3.0.3
@@ -9840,10 +9837,6 @@ packages:
tiptap-extension-global-drag-handle@0.1.18:
resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==}
tlds@1.261.0:
resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==}
hasBin: true
tldts-core@6.1.72:
resolution: {integrity: sha512-FW3H9aCaGTJ8l8RVCR3EX8GxsxDbQXuwetwwgXA2chYdsX+NY1ytCBl61narjjehWmCw92tc1AxlcY3668CU8g==}
@@ -21152,8 +21145,6 @@ snapshots:
tiptap-extension-global-drag-handle@0.1.18: {}
tlds@1.261.0: {}
tldts-core@6.1.72: {}
tldts@6.1.72: