Compare commits

..

1 Commits

Author SHA1 Message Date
Philipinho 62a2eb61ea custom domain support (cloud) 2025-06-28 19:05:03 -07:00
58 changed files with 288 additions and 2286 deletions
+6 -6
View File
@@ -16,12 +16,12 @@
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-864353b", "@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.1.3", "@mantine/core": "^7.17.0",
"@mantine/form": "^8.1.3", "@mantine/form": "^7.17.0",
"@mantine/hooks": "^8.1.3", "@mantine/hooks": "^7.17.0",
"@mantine/modals": "^8.1.3", "@mantine/modals": "^7.17.0",
"@mantine/notifications": "^8.1.3", "@mantine/notifications": "^7.17.0",
"@mantine/spotlight": "^8.1.3", "@mantine/spotlight": "^7.17.0",
"@tabler/icons-react": "^3.34.0", "@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.10.3", "@tiptap/extension-character-count": "^2.10.3",
@@ -222,9 +222,7 @@
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.", "Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
"Invite link": "Invite link", "Invite link": "Invite link",
"Copy": "Copy", "Copy": "Copy",
"Copy to space": "Copy to space",
"Copied": "Copied", "Copied": "Copied",
"Duplicate": "Duplicate",
"Select a user": "Select a user", "Select a user": "Select a user",
"Select a group": "Select a group", "Select a group": "Select a group",
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.", "Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
@@ -391,16 +389,5 @@
"Failed to share page": "Failed to share page", "Failed to share page": "Failed to share page",
"Copy page": "Copy page", "Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.", "Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully", "Page copied successfully": "Page copied successfully"
"Page duplicated successfully": "Page duplicated successfully",
"Find": "Find",
"Not found": "Not found",
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
"Next match (Enter)": "Next match (Enter)",
"Match case (Alt+C)": "Match case (Alt+C)",
"Replace": "Replace",
"Close (Escape)": "Close (Escape)",
"Replace (Enter)": "Replace (Enter)",
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
"Replace all": "Replace all"
} }
@@ -1,21 +1,9 @@
import { Group, Menu, UnstyledButton, Text } from "@mantine/core";
import { import {
Group,
Menu,
UnstyledButton,
Text,
useMantineColorScheme,
} from "@mantine/core";
import {
IconBrightnessFilled,
IconBrush, IconBrush,
IconCheck,
IconChevronDown, IconChevronDown,
IconChevronRight,
IconDeviceDesktop,
IconLogout, IconLogout,
IconMoon,
IconSettings, IconSettings,
IconSun,
IconUserCircle, IconUserCircle,
IconUsers, IconUsers,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -31,7 +19,6 @@ export default function TopMenu() {
const { t } = useTranslation(); const { t } = useTranslation();
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const { logout } = useAuth(); const { logout } = useAuth();
const { colorScheme, setColorScheme } = useMantineColorScheme();
const user = currentUser?.user; const user = currentUser?.user;
const workspace = currentUser?.workspace; const workspace = currentUser?.workspace;
@@ -88,7 +75,7 @@ export default function TopMenu() {
name={user.name} name={user.name}
/> />
<div style={{ width: 190 }}> <div style={{width: 190}}>
<Text size="sm" fw={500} lineClamp={1}> <Text size="sm" fw={500} lineClamp={1}>
{user.name} {user.name}
</Text> </Text>
@@ -114,44 +101,6 @@ export default function TopMenu() {
{t("My preferences")} {t("My preferences")}
</Menu.Item> </Menu.Item>
<Menu.Sub>
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
{t("Theme")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<Menu.Item
onClick={() => setColorScheme("light")}
leftSection={<IconSun size={16} />}
rightSection={
colorScheme === "light" ? <IconCheck size={16} /> : null
}
>
{t("Light")}
</Menu.Item>
<Menu.Item
onClick={() => setColorScheme("dark")}
leftSection={<IconMoon size={16} />}
rightSection={
colorScheme === "dark" ? <IconCheck size={16} /> : null
}
>
{t("Dark")}
</Menu.Item>
<Menu.Item
onClick={() => setColorScheme("auto")}
leftSection={<IconDeviceDesktop size={16} />}
rightSection={
colorScheme === "auto" ? <IconCheck size={16} /> : null
}
>
{t("System settings")}
</Menu.Item>
</Menu.Sub.Dropdown>
</Menu.Sub>
<Menu.Divider /> <Menu.Divider />
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}> <Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
@@ -117,8 +117,7 @@ export default function BillingDetails() {
{billing.billingScheme === "tiered" && ( {billing.billingScheme === "tiered" && (
<> <>
<Text fw={700} fz="lg"> <Text fw={700} fz="lg">
${billing.amount / 100} {billing.currency.toUpperCase()} /{" "} ${billing.amount / 100} {billing.currency.toUpperCase()}
{billing.interval}
</Text> </Text>
<Text c="dimmed" fz="sm"> <Text c="dimmed" fz="sm">
per {billing.interval} per {billing.interval}
@@ -130,7 +129,7 @@ export default function BillingDetails() {
<> <>
<Text fw={700} fz="lg"> <Text fw={700} fz="lg">
{(billing.amount / 100) * billing.quantity}{" "} {(billing.amount / 100) * billing.quantity}{" "}
{billing.currency.toUpperCase()} / {billing.interval} {billing.currency.toUpperCase()}
</Text> </Text>
<Text c="dimmed" fz="sm"> <Text c="dimmed" fz="sm">
${billing.amount / 100} /user/{billing.interval} ${billing.amount / 100} /user/{billing.interval}
@@ -12,18 +12,14 @@ import {
Badge, Badge,
Flex, Flex,
Switch, Switch,
Alert,
} from "@mantine/core"; } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";
import { IconCheck, IconInfoCircle } from "@tabler/icons-react"; import { IconCheck } from "@tabler/icons-react";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts"; import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts"; import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
export default function BillingPlans() { export default function BillingPlans() {
const { data: plans } = useBillingPlans(); const { data: plans } = useBillingPlans();
const workspace = useAtomValue(workspaceAtom);
const [isAnnual, setIsAnnual] = useState(true); const [isAnnual, setIsAnnual] = useState(true);
const [selectedTierValue, setSelectedTierValue] = useState<string | null>( const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
null, null,
@@ -40,76 +36,49 @@ export default function BillingPlans() {
} }
}; };
// TODO: remove by July 30.
// Check if workspace was created between June 28 and July 14, 2025
const showTieredPricingNotice = (() => {
if (!workspace?.createdAt) return false;
const createdDate = new Date(workspace.createdAt);
const startDate = new Date('2025-06-20');
const endDate = new Date('2025-07-14');
return createdDate >= startDate && createdDate <= endDate;
})();
if (!plans || plans.length === 0) { if (!plans || plans.length === 0) {
return null; return null;
} }
// Check if any plan is tiered const firstPlan = plans[0];
const hasTieredPlans = plans.some(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
const firstTieredPlan = plans.find(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
// Set initial tier value if not set and we have tiered plans // Set initial tier value if not set
if (hasTieredPlans && !selectedTierValue && firstTieredPlan) { if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString()); setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
return null; return null;
} }
// For tiered plans, ensure we have a selected tier if (!selectedTierValue) {
if (hasTieredPlans && !selectedTierValue) {
return null; return null;
} }
const selectData = firstTieredPlan?.pricingTiers const selectData = firstPlan.pricingTiers
?.filter((tier) => !tier.custom) .filter((tier) => !tier.custom)
.map((tier, index) => { .map((tier, index) => {
const prevMaxUsers = const prevMaxUsers =
index > 0 ? firstTieredPlan.pricingTiers[index - 1].upTo : 0; index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
return { return {
value: tier.upTo.toString(), value: tier.upTo.toString(),
label: `${prevMaxUsers + 1}-${tier.upTo} users`, label: `${prevMaxUsers + 1}-${tier.upTo} users`,
}; };
}) || []; });
return ( return (
<Container size="xl" py="xl"> <Container size="xl" py="xl">
{/* Tiered pricing notice for eligible workspaces */}
{showTieredPricingNotice && !hasTieredPlans && (
<Alert
icon={<IconInfoCircle size={16} />}
title="Want the old tiered pricing?"
color="blue"
mb="lg"
>
Contact support to switch back to our tiered pricing model.
</Alert>
)}
{/* Controls Section */} {/* Controls Section */}
<Stack gap="xl" mb="md"> <Stack gap="xl" mb="md">
{/* Team Size and Billing Controls */} {/* Team Size and Billing Controls */}
<Group justify="center" align="center" gap="sm"> <Group justify="center" align="center" gap="sm">
{hasTieredPlans && ( <Select
<Select label="Team size"
label="Team size" description="Select the number of users"
description="Select the number of users" value={selectedTierValue}
value={selectedTierValue} onChange={setSelectedTierValue}
onChange={setSelectedTierValue} data={selectData}
data={selectData} w={250}
w={250} size="md"
size="md" allowDeselect={false}
allowDeselect={false} />
/>
)}
<Group justify="center" align="start"> <Group justify="center" align="start">
<Flex justify="center" gap="md" align="center"> <Flex justify="center" gap="md" align="center">
@@ -133,29 +102,17 @@ export default function BillingPlans() {
{/* Plans Grid */} {/* Plans Grid */}
<Group justify="center" gap="lg" align="stretch"> <Group justify="center" gap="lg" align="stretch">
{plans.map((plan, index) => { {plans.map((plan, index) => {
let price; const tieredPlan = plan;
let displayPrice; const planSelectedTier =
tieredPlan.pricingTiers.find(
(tier) => tier.upTo.toString() === selectedTierValue,
) || tieredPlan.pricingTiers[0];
const price = isAnnual
? planSelectedTier.yearly
: planSelectedTier.monthly;
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId; const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
if (plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0) {
// Tiered billing logic
const planSelectedTier =
plan.pricingTiers.find(
(tier) => tier.upTo.toString() === selectedTierValue,
) || plan.pricingTiers[0];
price = isAnnual
? planSelectedTier.yearly
: planSelectedTier.monthly;
displayPrice = isAnnual ? (price / 12).toFixed(0) : price;
} else {
// Per-unit billing logic
const monthlyPrice = parseFloat(plan.price?.monthly || '0');
const yearlyPrice = parseFloat(plan.price?.yearly || '0');
price = isAnnual ? yearlyPrice : monthlyPrice;
displayPrice = isAnnual ? (yearlyPrice / 12).toFixed(0) : monthlyPrice;
}
return ( return (
<Card <Card
key={plan.name} key={plan.name}
@@ -186,27 +143,25 @@ export default function BillingPlans() {
<Stack gap="xs"> <Stack gap="xs">
<Group align="baseline" gap="xs"> <Group align="baseline" gap="xs">
<Title order={1} size="h1"> <Title order={1} size="h1">
${displayPrice} ${isAnnual ? (price / 12).toFixed(0) : price}
</Title> </Title>
<Text size="lg" c="dimmed"> <Text size="lg" c="dimmed">
{plan.billingScheme === 'per_unit' per {isAnnual ? "month" : "month"}
? `per user/month`
: `per month`}
</Text> </Text>
</Group> </Group>
<Text size="sm" c="dimmed"> {isAnnual && (
{isAnnual ? "Billed annually" : "Billed monthly"} <Text size="sm" c="dimmed">
</Text> Billed annually
{plan.billingScheme === 'tiered' && plan.pricingTiers && (
<Text size="md" fw={500}>
For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users
</Text> </Text>
)} )}
<Text size="md" fw={500}>
For {planSelectedTier.upTo} users
</Text>
</Stack> </Stack>
{/* CTA Button */} {/* CTA Button */}
<Button onClick={() => handleCheckout(priceId)} fullWidth> <Button onClick={() => handleCheckout(priceId)} fullWidth>
Subscribe Upgrade
</Button> </Button>
{/* Features */} {/* Features */}
@@ -53,7 +53,7 @@ export interface IBillingPlan {
}; };
features: string[]; features: string[];
billingScheme: string | null; billingScheme: string | null;
pricingTiers?: PricingTier[]; pricingTiers: PricingTier[];
} }
interface PricingTier { interface PricingTier {
@@ -12,12 +12,6 @@
padding: 8px; padding: 8px;
background: var(--mantine-color-gray-light); background: var(--mantine-color-gray-light);
cursor: pointer; cursor: pointer;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
-ms-word-break: break-word;
max-width: 100%;
box-sizing: border-box;
} }
.commentEditor { .commentEditor {
@@ -1,96 +0,0 @@
.wrapper {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 8px;
}
.resizing {
user-select: none;
cursor: ns-resize;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
background: transparent;
}
.resizeHandleBottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 24px;
cursor: ns-resize;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
touch-action: none;
-webkit-user-select: none;
user-select: none;
@mixin light {
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05));
}
@mixin dark {
background: linear-gradient(
to bottom,
transparent,
rgba(255, 255, 255, 0.05)
);
}
&:hover {
@mixin light {
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));
}
@mixin dark {
background: linear-gradient(
to bottom,
transparent,
rgba(255, 255, 255, 0.1)
);
}
}
}
.wrapper:hover .resizeHandleBottom,
.resizing .resizeHandleBottom {
opacity: 1;
}
.resizeBar {
width: 50px;
height: 4px;
border-radius: 2px;
transition: background-color 0.2s ease;
@mixin light {
background-color: var(--mantine-color-gray-5);
}
@mixin dark {
background-color: var(--mantine-color-gray-6);
}
}
.resizeHandleBottom:hover .resizeBar,
.resizing .resizeBar {
@mixin light {
background-color: var(--mantine-color-gray-7);
}
@mixin dark {
background-color: var(--mantine-color-gray-4);
}
}
@@ -1,112 +0,0 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import clsx from "clsx";
import classes from "./resizable-wrapper.module.css";
interface ResizableWrapperProps {
children: ReactNode;
initialHeight?: number;
minHeight?: number;
maxHeight?: number;
onResize?: (height: number) => void;
isEditable?: boolean;
className?: string;
showHandles?: "always" | "hover";
direction?: "vertical" | "horizontal" | "both";
}
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
children,
initialHeight = 480,
minHeight = 200,
maxHeight = 1200,
onResize,
isEditable = true,
className,
showHandles = "hover",
direction = "vertical",
}) => {
const [resizeParams, setResizeParams] = useState<{
initialSize: number;
initialClientY: number;
initialClientX: number;
} | null>(null);
const [currentHeight, setCurrentHeight] = useState(initialHeight);
const [isHovered, setIsHovered] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!resizeParams) return;
const handleMouseMove = (e: MouseEvent) => {
if (!wrapperRef.current) return;
if (direction === "vertical" || direction === "both") {
const deltaY = e.clientY - resizeParams.initialClientY;
const newHeight = Math.min(
Math.max(resizeParams.initialSize + deltaY, minHeight),
maxHeight
);
setCurrentHeight(newHeight);
wrapperRef.current.style.height = `${newHeight}px`;
}
};
const handleMouseUp = () => {
setResizeParams(null);
if (onResize && currentHeight !== initialHeight) {
onResize(currentHeight);
}
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setResizeParams({
initialSize: currentHeight,
initialClientY: e.clientY,
initialClientX: e.clientX,
});
document.body.style.cursor = "ns-resize";
document.body.style.userSelect = "none";
}, [currentHeight]);
const shouldShowHandles =
isEditable &&
(showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams)));
return (
<div
ref={wrapperRef}
className={clsx(classes.wrapper, className, {
[classes.resizing]: !!resizeParams,
})}
style={{ height: currentHeight }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{children}
{!!resizeParams && <div className={classes.overlay} />}
{shouldShowHandles && direction === "vertical" && (
<div
className={classes.resizeHandleBottom}
onMouseDown={handleResizeStart}
>
<div className={classes.resizeBar} />
</div>
)}
</div>
);
};
@@ -1,16 +0,0 @@
.embedWrapper {
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}
.embedIframe {
width: 100%;
height: 100%;
border: none;
border-radius: 8px;
}
@@ -1,8 +1,9 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import React, { useMemo, useCallback } from "react"; import { useMemo } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { import {
ActionIcon, ActionIcon,
AspectRatio,
Button, Button,
Card, Card,
FocusTrap, FocusTrap,
@@ -13,8 +14,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { IconEdit } from "@tabler/icons-react"; import { IconEdit } from "@tabler/icons-react";
import { z } from "zod"; import { z } from "zod";
import { useForm } from "@mantine/form"; import { useForm, zodResolver } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import i18n from "i18next"; import i18n from "i18next";
@@ -22,8 +22,6 @@ import {
getEmbedProviderById, getEmbedProviderById,
getEmbedUrlAndProvider, getEmbedUrlAndProvider,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { ResizableWrapper } from "../common/resizable-wrapper";
import classes from "./embed-view.module.css";
const schema = z.object({ const schema = z.object({
url: z url: z
@@ -35,7 +33,7 @@ const schema = z.object({
export default function EmbedView(props: NodeViewProps) { export default function EmbedView(props: NodeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { node, selected, updateAttributes, editor } = props; const { node, selected, updateAttributes, editor } = props;
const { src, provider, height: nodeHeight } = node.attrs; const { src, provider } = node.attrs;
const embedUrl = useMemo(() => { const embedUrl = useMemo(() => {
if (src) { if (src) {
@@ -51,10 +49,6 @@ export default function EmbedView(props: NodeViewProps) {
validate: zodResolver(schema), validate: zodResolver(schema),
}); });
const handleResize = useCallback((newHeight: number) => {
updateAttributes({ height: newHeight });
}, [updateAttributes]);
async function onSubmit(data: { url: string }) { async function onSubmit(data: { url: string }) {
if (!editor.isEditable) { if (!editor.isEditable) {
return; return;
@@ -83,25 +77,17 @@ export default function EmbedView(props: NodeViewProps) {
return ( return (
<NodeViewWrapper> <NodeViewWrapper>
{embedUrl ? ( {embedUrl ? (
<ResizableWrapper <>
initialHeight={nodeHeight || 480} <AspectRatio ratio={16 / 9}>
minHeight={200} <iframe
maxHeight={1200} src={embedUrl}
onResize={handleResize} allow="encrypted-media"
isEditable={editor.isEditable} sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
className={clsx(classes.embedWrapper, { allowFullScreen
"ProseMirror-selectednode": selected, frameBorder="0"
})} ></iframe>
> </AspectRatio>
<iframe </>
className={classes.embedIframe}
src={embedUrl}
allow="encrypted-media"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
allowFullScreen
frameBorder="0"
/>
</ResizableWrapper>
) : ( ) : (
<Popover <Popover
width={300} width={300}
@@ -1,9 +0,0 @@
import { atom } from "jotai";
type SearchAndReplaceAtomType = {
isOpen: boolean;
};
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
isOpen: false,
});
@@ -1,312 +0,0 @@
import {
ActionIcon,
Button,
Dialog,
Flex,
Input,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import {
IconArrowNarrowDown,
IconArrowNarrowUp,
IconLetterCase,
IconReplace,
IconSearch,
IconX,
} from "@tabler/icons-react";
import { useEditor } from "@tiptap/react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { getHotkeyHandler, useToggle } from "@mantine/hooks";
import { useLocation } from "react-router-dom";
import classes from "./search-replace.module.css";
interface PageFindDialogDialogProps {
editor: ReturnType<typeof useEditor>;
editable?: boolean;
}
function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialogProps) {
const { t } = useTranslation();
const [searchText, setSearchText] = useState("");
const [replaceText, setReplaceText] = useState("");
const [pageFindState, setPageFindState] = useAtom(searchAndReplaceStateAtom);
const inputRef = useRef(null);
const [replaceButton, replaceButtonToggle] = useToggle([
{ isReplaceShow: false, color: "gray" },
{ isReplaceShow: true, color: "blue" },
]);
const [caseSensitive, caseSensitiveToggle] = useToggle([
{ isCaseSensitive: false, color: "gray" },
{ isCaseSensitive: true, color: "blue" },
]);
const searchInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(event.target.value);
};
const replaceInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
setReplaceText(event.target.value);
};
const closeDialog = () => {
setSearchText("");
setReplaceText("");
setPageFindState({ isOpen: false });
// Reset replace button state when closing
if (replaceButton.isReplaceShow) {
replaceButtonToggle();
}
// Clear search term in editor
if (editor) {
editor.commands.setSearchTerm("");
}
};
const goToSelection = () => {
if (!editor) return;
const { results, resultIndex } = editor.storage.searchAndReplace;
const position: Range = results[resultIndex];
if (!position) return;
// @ts-ignore
editor.commands.setTextSelection(position);
const element = document.querySelector(".search-result-current");
if (element)
element.scrollIntoView({ behavior: "smooth", block: "center" });
editor.commands.setTextSelection(0);
};
const next = () => {
editor.commands.nextSearchResult();
goToSelection();
};
const previous = () => {
editor.commands.previousSearchResult();
goToSelection();
};
const replace = () => {
editor.commands.setReplaceTerm(replaceText);
editor.commands.replace();
goToSelection();
};
const replaceAll = () => {
editor.commands.setReplaceTerm(replaceText);
editor.commands.replaceAll();
};
useEffect(() => {
editor.commands.setSearchTerm(searchText);
editor.commands.resetIndex();
editor.commands.selectCurrentItem();
}, [searchText]);
const handleOpenEvent = (e) => {
setPageFindState({ isOpen: true });
const selectedText = editor.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to,
);
if (selectedText !== "") {
setSearchText(selectedText);
}
inputRef.current?.focus();
inputRef.current?.select();
};
const handleCloseEvent = (e) => {
closeDialog();
};
useEffect(() => {
!pageFindState.isOpen && closeDialog();
document.addEventListener("openFindDialogFromEditor", handleOpenEvent);
document.addEventListener("closeFindDialogFromEditor", handleCloseEvent);
return () => {
document.removeEventListener("openFindDialogFromEditor", handleOpenEvent);
document.removeEventListener(
"closeFindDialogFromEditor",
handleCloseEvent,
);
};
}, [pageFindState.isOpen]);
useEffect(() => {
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
editor.commands.resetIndex();
goToSelection();
}, [caseSensitive]);
const resultsCount = useMemo(
() =>
searchText.trim() === ""
? ""
: editor?.storage?.searchAndReplace?.results.length > 0
? editor?.storage?.searchAndReplace?.resultIndex +
1 +
"/" +
editor?.storage?.searchAndReplace?.results.length
: t("Not found"),
[
searchText,
editor?.storage?.searchAndReplace?.resultIndex,
editor?.storage?.searchAndReplace?.results.length,
],
);
const location = useLocation();
useEffect(() => {
closeDialog();
}, [location]);
return (
<Dialog
className={classes.findDialog}
opened={pageFindState.isOpen}
size="lg"
radius="md"
w={"auto"}
position={{ top: 90, right: 50 }}
withBorder
transitionProps={{ transition: "slide-down" }}
>
<Stack gap="xs">
<Flex align="center" gap="xs">
<Input
ref={inputRef}
placeholder={t("Find")}
leftSection={<IconSearch size={16} />}
rightSection={
<Text size="xs" ta="right">
{resultsCount}
</Text>
}
rightSectionWidth="70"
rightSectionPointerEvents="all"
size="xs"
w={220}
onChange={searchInputEvent}
value={searchText}
autoFocus
onKeyDown={getHotkeyHandler([
["Enter", next],
["shift+Enter", previous],
["alt+C", caseSensitiveToggle],
//@ts-ignore
...(editable ? [["alt+R", replaceButtonToggle]] : []),
])}
/>
<ActionIcon.Group>
<Tooltip label={t("Previous match (Shift+Enter)")}>
<ActionIcon variant="subtle" color="gray" onClick={previous}>
<IconArrowNarrowUp
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
<Tooltip label={t("Next match (Enter)")}>
<ActionIcon variant="subtle" color="gray" onClick={next}>
<IconArrowNarrowDown
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
<Tooltip label={t("Match case (Alt+C)")}>
<ActionIcon
variant="subtle"
color={caseSensitive.color}
onClick={() => caseSensitiveToggle()}
>
<IconLetterCase
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
{editable && (
<Tooltip label={t("Replace")}>
<ActionIcon
variant="subtle"
color={replaceButton.color}
onClick={() => replaceButtonToggle()}
>
<IconReplace
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
)}
<Tooltip label={t("Close (Escape)")}>
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}>
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</Flex>
{replaceButton.isReplaceShow && editable && (
<Flex align="center" gap="xs">
<Input
placeholder={t("Replace")}
leftSection={<IconReplace size={16} />}
rightSection={<div></div>}
rightSectionPointerEvents="all"
size="xs"
w={180}
autoFocus
onChange={replaceInputEvent}
value={replaceText}
onKeyDown={getHotkeyHandler([
["Enter", replace],
["ctrl+alt+Enter", replaceAll],
])}
/>
<ActionIcon.Group>
<Tooltip label={t("Replace (Enter)")}>
<Button
size="xs"
variant="subtle"
color="gray"
onClick={replace}
>
{t("Replace")}
</Button>
</Tooltip>
<Tooltip label={t("Replace all (Ctrl+Alt+Enter)")}>
<Button
size="xs"
variant="subtle"
color="gray"
onClick={replaceAll}
>
{t("Replace all")}
</Button>
</Tooltip>
</ActionIcon.Group>
</Flex>
)}
</Stack>
</Dialog>
);
}
export default SearchAndReplaceDialog;
@@ -1,10 +0,0 @@
.findDialog{
@media print {
display: none;
}
}
.findDialog div[data-position="right"].mantine-Input-section {
justify-content: right;
padding-right: 8px;
}
@@ -1,145 +0,0 @@
import React, { FC } from "react";
import { IconCheck, IconPalette } from "@tabler/icons-react";
import {
ActionIcon,
ColorSwatch,
Popover,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { useTranslation } from "react-i18next";
export interface TableColorItem {
name: string;
color: string;
}
interface TableBackgroundColorProps {
editor: ReturnType<typeof useEditor>;
}
const TABLE_COLORS: TableColorItem[] = [
{ name: "Default", color: "" },
{ name: "Blue", color: "#b4d5ff" },
{ name: "Green", color: "#acf5d2" },
{ name: "Yellow", color: "#fef1b4" },
{ name: "Red", color: "#ffbead" },
{ name: "Pink", color: "#ffc7fe" },
{ name: "Gray", color: "#eaecef" },
{ name: "Purple", color: "#c1b7f2" },
];
export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
editor,
}) => {
const { t } = useTranslation();
const [opened, setOpened] = React.useState(false);
const setTableCellBackground = (color: string, colorName: string) => {
editor
.chain()
.focus()
.updateAttributes("tableCell", {
backgroundColor: color || null,
backgroundColorName: color ? colorName : null
})
.updateAttributes("tableHeader", {
backgroundColor: color || null,
backgroundColorName: color ? colorName : null
})
.run();
setOpened(false);
};
// Get current cell's background color
const getCurrentColor = () => {
if (editor.isActive("tableCell")) {
const attrs = editor.getAttributes("tableCell");
return attrs.backgroundColor || "";
}
if (editor.isActive("tableHeader")) {
const attrs = editor.getAttributes("tableHeader");
return attrs.backgroundColor || "";
}
return "";
};
const currentColor = getCurrentColor();
return (
<Popover
width={200}
position="bottom"
opened={opened}
onChange={setOpened}
withArrow
transitionProps={{ transition: "pop" }}
>
<Popover.Target>
<Tooltip label={t("Background color")} withArrow>
<ActionIcon
variant="default"
size="lg"
aria-label={t("Background color")}
onClick={() => setOpened(!opened)}
>
<IconPalette size={18} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t("Background color")}
</Text>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "8px",
}}
>
{TABLE_COLORS.map((item, index) => (
<UnstyledButton
key={index}
onClick={() => setTableCellBackground(item.color, item.name)}
style={{
position: "relative",
width: "24px",
height: "24px",
}}
title={t(item.name)}
>
<ColorSwatch
color={item.color || "#ffffff"}
size={24}
style={{
border: item.color === "" ? "1px solid #e5e7eb" : undefined,
cursor: "pointer",
}}
>
{currentColor === item.color && (
<IconCheck
size={18}
style={{
color:
item.color === "" || item.color.startsWith("#F")
? "#000000"
: "#ffffff",
}}
/>
)}
</ColorSwatch>
</UnstyledButton>
))}
</div>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
@@ -12,11 +12,8 @@ import {
IconColumnRemove, IconColumnRemove,
IconRowRemove, IconRowRemove,
IconSquareToggle, IconSquareToggle,
IconTableRow,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment";
export const TableCellMenu = React.memo( export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => { ({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@@ -48,10 +45,6 @@ export const TableCellMenu = React.memo(
editor.chain().focus().deleteRow().run(); editor.chain().focus().deleteRow().run();
}, [editor]); }, [editor]);
const toggleHeaderCell = useCallback(() => {
editor.chain().focus().toggleHeaderCell().run();
}, [editor]);
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
@@ -67,9 +60,6 @@ export const TableCellMenu = React.memo(
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<ActionIcon.Group> <ActionIcon.Group>
<TableBackgroundColor editor={editor} />
<TableTextAlignment editor={editor} />
<Tooltip position="top" label={t("Merge cells")}> <Tooltip position="top" label={t("Merge cells")}>
<ActionIcon <ActionIcon
onClick={mergeCells} onClick={mergeCells}
@@ -113,17 +103,6 @@ export const TableCellMenu = React.memo(
<IconRowRemove size={18} /> <IconRowRemove size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Toggle header cell")}>
<ActionIcon
onClick={toggleHeaderCell}
variant="default"
size="lg"
aria-label={t("Toggle header cell")}
>
<IconTableRow size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
</BaseBubbleMenu> </BaseBubbleMenu>
); );
@@ -1,109 +0,0 @@
import React, { FC } from "react";
import {
IconAlignCenter,
IconAlignLeft,
IconAlignRight,
IconCheck,
} from "@tabler/icons-react";
import {
ActionIcon,
Button,
Popover,
rem,
ScrollArea,
Tooltip,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { useTranslation } from "react-i18next";
interface TableTextAlignmentProps {
editor: ReturnType<typeof useEditor>;
}
interface AlignmentItem {
name: string;
icon: React.ElementType;
command: () => void;
isActive: () => boolean;
value: string;
}
export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
const { t } = useTranslation();
const [opened, setOpened] = React.useState(false);
const items: AlignmentItem[] = [
{
name: "Align left",
value: "left",
isActive: () => editor.isActive({ textAlign: "left" }),
command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft,
},
{
name: "Align center",
value: "center",
isActive: () => editor.isActive({ textAlign: "center" }),
command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter,
},
{
name: "Align right",
value: "right",
isActive: () => editor.isActive({ textAlign: "right" }),
command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight,
},
];
const activeItem = items.find((item) => item.isActive()) || items[0];
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom"
withArrow
transitionProps={{ transition: 'pop' }}
>
<Popover.Target>
<Tooltip label={t("Text alignment")} withArrow>
<ActionIcon
variant="default"
size="lg"
aria-label={t("Text alignment")}
onClick={() => setOpened(!opened)}
>
<activeItem.icon size={18} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<ScrollArea.Autosize type="scroll" mah={300}>
<Button.Group orientation="vertical">
{items.map((item, index) => (
<Button
key={index}
variant="default"
leftSection={<item.icon size={16} />}
rightSection={
item.isActive() && <IconCheck size={16} />
}
justify="left"
fullWidth
onClick={() => {
item.command();
setOpened(false);
}}
style={{ border: "none" }}
>
{t(item.name)}
</Button>
))}
</Button.Group>
</ScrollArea.Autosize>
</Popover.Dropdown>
</Popover>
);
};
@@ -11,6 +11,7 @@ import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style"; import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color"; import { Color } from "@tiptap/extension-color";
import Table from "@tiptap/extension-table"; import Table from "@tiptap/extension-table";
import TableHeader from "@tiptap/extension-table-header";
import SlashCommand from "@/features/editor/extensions/slash-command"; import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration } from "@tiptap/extension-collaboration"; import { Collaboration } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor"; import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
@@ -24,7 +25,6 @@ import {
MathInline, MathInline,
TableCell, TableCell,
TableRow, TableRow,
TableHeader,
TrailingNode, TrailingNode,
TiptapImage, TiptapImage,
Callout, Callout,
@@ -36,7 +36,6 @@ import {
Drawio, Drawio,
Excalidraw, Excalidraw,
Embed, Embed,
SearchAndReplace,
Mention, Mention,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
@@ -218,22 +217,6 @@ export const mainExtensions = [
CharacterCount.configure({ CharacterCount.configure({
wordCounter: (text) => countWords(text), wordCounter: (text) => countWords(text),
}), }),
SearchAndReplace.extend({
addKeyboardShortcuts() {
return {
'Mod-f': () => {
const event = new CustomEvent("openFindDialogFromEditor", {});
document.dispatchEvent(event);
return true;
},
'Escape': () => {
const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event);
return true;
},
}
},
}).configure(),
] as any; ] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
@@ -1,5 +1,10 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, {
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { IndexeddbPersistence } from "y-indexeddb"; import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs"; import * as Y from "yjs";
import { import {
@@ -39,7 +44,6 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu"; import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks"; import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts"; import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
@@ -126,15 +130,7 @@ export default function PageEditor({
const now = Date.now().valueOf() / 1000; const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp; const isTokenExpired = now >= payload.exp;
if (isTokenExpired) { if (isTokenExpired) {
refetchCollabToken().then((result) => { refetchCollabToken();
if (result.data?.token) {
remote.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
remote.connect();
}, 100);
}
});
} }
}, },
onStatus: (status) => { onStatus: (status) => {
@@ -160,21 +156,6 @@ export default function PageEditor({
}; };
}, [pageId]); }, [pageId]);
/*
useEffect(() => {
// Handle token updates by reconnecting with new token
if (providersRef.current?.remote && collabQuery?.token) {
const currentToken = providersRef.current.remote.configuration.token;
if (currentToken !== collabQuery.token) {
// Token has changed, need to reconnect with new token
providersRef.current.remote.disconnect();
providersRef.current.remote.configuration.token = collabQuery.token;
providersRef.current.remote.connect();
}
}
}, [collabQuery?.token]);
*/
// Only connect/disconnect on tab/idle, not destroy // Only connect/disconnect on tab/idle, not destroy
useEffect(() => { useEffect(() => {
if (!providersReady || !providersRef.current) return; if (!providersReady || !providersRef.current) return;
@@ -217,10 +198,6 @@ export default function PageEditor({
scrollMargin: 80, scrollMargin: 80,
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => { keydown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
event.preventDefault();
return true;
}
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command"); const slashCommand = document.querySelector("#slash-command");
if (slashCommand) { if (slashCommand) {
@@ -373,11 +350,6 @@ export default function PageEditor({
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<div ref={menuContainerRef}> <div ref={menuContainerRef}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
{editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && editor.isEditable && ( {editor && editor.isEditable && (
<div> <div>
<EditorBubbleMenu editor={editor} /> <EditorBubbleMenu editor={editor} />
@@ -71,12 +71,4 @@
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{ [data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
transform: rotateZ(90deg); transform: rotateZ(90deg);
} }
}
[data-type="details"]:has(.search-result) > [data-type="detailsContainer"] > [data-type="detailsContent"]{
display: block;
}
[data-type="details"]:has(.search-result) > [data-type="detailsButton"] .ProseMirror-icon{
transform: rotateZ(90deg);
}
}
@@ -1,9 +0,0 @@
.search-result{
background: #ffff65;
color: #212529;
}
.search-result-current{
background: #ffc266 !important;
color: #212529;
}
@@ -9,6 +9,5 @@
@import "./media.css"; @import "./media.css";
@import "./code.css"; @import "./code.css";
@import "./print.css"; @import "./print.css";
@import "./find.css";
@import "./mention.css"; @import "./mention.css";
@import "./ordered-list.css";
@@ -1,34 +0,0 @@
/* Ordered list type cycling based on nesting depth */
ol,
ol ol ol ol,
ol ol ol ol ol ol ol,
ol ol ol ol ol ol ol ol ol ol {
list-style-type: decimal;
}
ol ol,
ol ol ol ol ol,
ol ol ol ol ol ol ol ol,
ol ol ol ol ol ol ol ol ol ol ol {
list-style-type: lower-alpha;
}
ol ol ol,
ol ol ol ol ol ol,
ol ol ol ol ol ol ol ol ol,
ol ol ol ol ol ol ol ol ol ol ol ol {
list-style-type: lower-roman;
}
ol {
list-style-position: outside;
margin-left: 0.25rem;
}
/* Nested list spacing */
ol ol,
ol ul,
ul ol {
margin-top: 0.1rem;
margin-bottom: 0.1rem;
}
@@ -4,7 +4,6 @@
overflow-x: auto; overflow-x: auto;
& table { & table {
overflow-x: hidden; overflow-x: hidden;
min-width: 700px !important;
} }
} }
@@ -39,8 +38,8 @@
th { th {
background-color: light-dark( background-color: light-dark(
var(--mantine-color-gray-1), var(--mantine-color-gray-1),
var(--mantine-color-dark-5) var(--mantine-color-dark-5)
); );
font-weight: bold; font-weight: bold;
text-align: left; text-align: left;
@@ -67,54 +66,8 @@
position: absolute; position: absolute;
z-index: 2; z-index: 2;
} }
} }
} }
/* Table cell background colors with dark mode support */
.ProseMirror {
table {
@mixin dark {
/* Blue */
td[data-background-color="#b4d5ff"],
th[data-background-color="#b4d5ff"] {
background-color: #1a3a5c !important;
}
/* Green */
td[data-background-color="#acf5d2"],
th[data-background-color="#acf5d2"] {
background-color: #1a4d3a !important;
}
/* Yellow */
td[data-background-color="#fef1b4"],
th[data-background-color="#fef1b4"] {
background-color: #7c5014 !important;
}
/* Red */
td[data-background-color="#ffbead"],
th[data-background-color="#ffbead"] {
background-color: #5c2a23 !important;
}
/* Pink */
td[data-background-color="#ffc7fe"],
th[data-background-color="#ffc7fe"] {
background-color: #4d2a4d !important;
}
/* Gray */
td[data-background-color="#eaecef"],
th[data-background-color="#eaecef"] {
background-color: #2a2e33 !important;
}
/* Purple */
td[data-background-color="#c1b7f2"],
th[data-background-color="#c1b7f2"] {
background-color: #3a2f5c !important;
}
}
}
}
@@ -10,11 +10,8 @@ import {
pageEditorAtom, pageEditorAtom,
titleEditorAtom, titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms"; } from "@/features/editor/atoms/editor-atoms";
import { import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
updatePageData, import { useDebouncedCallback } from "@mantine/hooks";
useUpdateTitlePageMutation,
} from "@/features/page/queries/page-query";
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { History } from "@tiptap/extension-history"; import { History } from "@tiptap/extension-history";
@@ -43,8 +40,7 @@ export function TitleEditor({
editable, editable,
}: TitleEditorProps) { }: TitleEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { mutateAsync: updateTitlePageMutationAsync } = const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
useUpdateTitlePageMutation();
const pageEditor = useAtomValue(pageEditorAtom); const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom);
const emit = useQueryEmit(); const emit = useQueryEmit();
@@ -112,12 +108,7 @@ export function TitleEditor({
spaceId: page.spaceId, spaceId: page.spaceId,
entity: ["pages"], entity: ["pages"],
id: page.id, id: page.id,
payload: { payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon },
title: page.title,
slugId: page.slugId,
parentPageId: page.parentPageId,
icon: page.icon,
},
}; };
if (page.title !== titleEditor.getText()) return; if (page.title !== titleEditor.getText()) return;
@@ -161,19 +152,13 @@ export function TitleEditor({
} }
}, [userPageEditMode, titleEditor, editable]); }, [userPageEditMode, titleEditor, editable]);
const openSearchDialog = () => {
const event = new CustomEvent("openFindDialogFromEditor", {});
document.dispatchEvent(event);
};
function handleTitleKeyDown(event: any) { function handleTitleKeyDown(event: any) {
if (!titleEditor || !pageEditor || event.shiftKey) return; if (!titleEditor || !pageEditor || event.shiftKey) return;
// Prevent focus shift when IME composition is active // Prevent focus shift when IME composition is active
// `keyCode === 229` is added to support Safari where `isComposing` may not be reliable // `keyCode === 229` is added to support Safari where `isComposing` may not be reliable
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
return;
const { key } = event; const { key } = event;
const { $head } = titleEditor.state.selection; const { $head } = titleEditor.state.selection;
@@ -187,16 +172,5 @@ export function TitleEditor({
} }
} }
return ( return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
<EditorContent
editor={titleEditor}
onKeyDown={(event) => {
// First handle the search hotkey
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
// Then handle other key events
handleTitleKeyDown(event);
}}
/>
);
} }
@@ -1,5 +1,5 @@
import { Modal, Button, Group, Text } from "@mantine/core"; import { Modal, Button, Group, Text } from "@mantine/core";
import { duplicatePage } from "@/features/page/services/page-service.ts"; import { copyPageToSpace } from "@/features/page/services/page-service.ts";
import { useState } from "react"; import { useState } from "react";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -30,7 +30,7 @@ export default function CopyPageModal({
if (!targetSpace) return; if (!targetSpace) return;
try { try {
const copiedPage = await duplicatePage({ const copiedPage = await copyPageToSpace({
pageId, pageId,
spaceId: targetSpace.id, spaceId: targetSpace.id,
}); });
@@ -9,7 +9,6 @@ import {
IconList, IconList,
IconMessage, IconMessage,
IconPrinter, IconPrinter,
IconSearch,
IconTrash, IconTrash,
IconWifiOff, IconWifiOff,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -17,12 +16,7 @@ import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { import { useClipboard, useDisclosure } from "@mantine/hooks";
getHotkeyHandler,
useClipboard,
useDisclosure,
useHotkeys,
} from "@mantine/hooks";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -38,7 +32,6 @@ import {
pageEditorAtom, pageEditorAtom,
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts"; } from "@/features/editor/atoms/editor-atoms.ts";
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
import { formattedDate, timeAgo } from "@/lib/time.ts"; import { formattedDate, timeAgo } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx"; import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx";
@@ -53,26 +46,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const toggleAside = useToggleAside(); const toggleAside = useToggleAside();
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom); const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
useHotkeys(
[
[
"mod+F",
() => {
const event = new CustomEvent("openFindDialogFromEditor", {});
document.dispatchEvent(event);
},
],
[
"Escape",
() => {
const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event);
},
],
],
[],
);
return ( return (
<> <>
{yjsConnectionStatus === "disconnected" && ( {yjsConnectionStatus === "disconnected" && (
@@ -42,8 +42,8 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
await api.post<void>("/pages/move-to-space", data); await api.post<void>("/pages/move-to-space", data);
} }
export async function duplicatePage(data: ICopyPageToSpace): Promise<IPage> { export async function copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
const req = await api.post<IPage>("/pages/duplicate", data); const req = await api.post<IPage>("/pages/copy-to-space", data);
return req.data; return req.data;
} }
@@ -1,10 +1,4 @@
import { import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
NodeApi,
NodeRendererProps,
Tree,
TreeApi,
SimpleTree,
} from "react-arborist";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { import {
@@ -72,7 +66,6 @@ import MovePageModal from "../../components/move-page-modal.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import CopyPageModal from "../../components/copy-page-modal.tsx"; import CopyPageModal from "../../components/copy-page-modal.tsx";
import { duplicatePage } from "../../services/page-service.ts";
interface SpaceTreeProps { interface SpaceTreeProps {
spaceId: string; spaceId: string;
@@ -97,14 +90,8 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>(); const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom); const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
const rootElement = useRef<HTMLDivElement>(); const rootElement = useRef<HTMLDivElement>();
const [isRootReady, setIsRootReady] = useState(false);
const { ref: sizeRef, width, height } = useElementSize(); const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef((element) => { const mergedRef = useMergedRef(rootElement, sizeRef);
rootElement.current = element;
if (element && !isRootReady) {
setIsRootReady(true);
}
}, sizeRef);
const [isDataLoaded, setIsDataLoaded] = useState(false); const [isDataLoaded, setIsDataLoaded] = useState(false);
const { data: currentPage } = usePageQuery({ const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug), pageId: extractPageSlugId(pageSlug),
@@ -212,17 +199,16 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
} }
}, [currentPage?.id]); }, [currentPage?.id]);
// Clean up tree API on unmount
useEffect(() => { useEffect(() => {
return () => { if (treeApiRef.current) {
// @ts-ignore // @ts-ignore
setTreeApi(null); setTreeApi(treeApiRef.current);
}; }
}, [setTreeApi]); }, [treeApiRef.current]);
return ( return (
<div ref={mergedRef} className={classes.treeContainer}> <div ref={mergedRef} className={classes.treeContainer}>
{isRootReady && rootElement.current && ( {rootElement.current && (
<Tree <Tree
data={data.filter((node) => node?.spaceId === spaceId)} data={data.filter((node) => node?.spaceId === spaceId)}
disableDrag={readOnly} disableDrag={readOnly}
@@ -231,13 +217,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
{...controllers} {...controllers}
width={width} width={width}
height={rootElement.current.clientHeight} height={rootElement.current.clientHeight}
ref={(ref) => { ref={treeApiRef}
treeApiRef.current = ref;
if (ref) {
//@ts-ignore
setTreeApi(ref);
}
}}
openByDefault={false} openByDefault={false}
disableMultiSelection={true} disableMultiSelection={true}
className={classes.tree} className={classes.tree}
@@ -403,7 +383,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
<span className={classes.text}>{node.data.name || t("untitled")}</span> <span className={classes.text}>{node.data.name || t("untitled")}</span>
<div className={classes.actions}> <div className={classes.actions}>
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} /> <NodeMenu node={node} treeApi={tree} />
{!tree.props.disableEdit && ( {!tree.props.disableEdit && (
<CreateNode <CreateNode
@@ -456,16 +436,13 @@ function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
interface NodeMenuProps { interface NodeMenuProps {
node: NodeApi<SpaceTreeNode>; node: NodeApi<SpaceTreeNode>;
treeApi: TreeApi<SpaceTreeNode>; treeApi: TreeApi<SpaceTreeNode>;
spaceId: string;
} }
function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) { function NodeMenu({ node, treeApi }: NodeMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const clipboard = useClipboard({ timeout: 500 }); const clipboard = useClipboard({ timeout: 500 });
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { openDeleteModal } = useDeletePageModal(); const { openDeleteModal } = useDeletePageModal();
const [data, setData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
const [exportOpened, { open: openExportModal, close: closeExportModal }] = const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false); useDisclosure(false);
const [ const [
@@ -484,68 +461,6 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
notifications.show({ message: t("Link copied") }); notifications.show({ message: t("Link copied") });
}; };
const handleDuplicatePage = async () => {
try {
const duplicatedPage = await duplicatePage({
pageId: node.id,
});
// Find the index of the current node
const parentId =
node.parent?.id === "__REACT_ARBORIST_INTERNAL_ROOT__"
? null
: node.parent?.id;
const siblings = parentId ? node.parent.children : treeApi?.props.data;
const currentIndex =
siblings?.findIndex((sibling) => sibling.id === node.id) || 0;
const newIndex = currentIndex + 1;
// Add the duplicated page to the tree
const treeNodeData: SpaceTreeNode = {
id: duplicatedPage.id,
slugId: duplicatedPage.slugId,
name: duplicatedPage.title,
position: duplicatedPage.position,
spaceId: duplicatedPage.spaceId,
parentPageId: duplicatedPage.parentPageId,
icon: duplicatedPage.icon,
hasChildren: duplicatedPage.hasChildren,
children: [],
};
// Update local tree
const simpleTree = new SimpleTree(data);
simpleTree.create({
parentId,
index: newIndex,
data: treeNodeData,
});
setData(simpleTree.data);
// Emit socket event
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: spaceId,
payload: {
parentId,
index: newIndex,
data: treeNodeData,
},
});
}, 50);
notifications.show({
message: t("Page duplicated successfully"),
});
} catch (err) {
notifications.show({
message: err.response?.data.message || "An error occurred",
color: "red",
});
}
};
return ( return (
<> <>
<Menu shadow="md" width={200}> <Menu shadow="md" width={200}>
@@ -590,17 +505,6 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
{!(treeApi.props.disableEdit as boolean) && ( {!(treeApi.props.disableEdit as boolean) && (
<> <>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicatePage();
}}
>
{t("Duplicate")}
</Menu.Item>
<Menu.Item <Menu.Item
leftSection={<IconArrowRight size={16} />} leftSection={<IconArrowRight size={16} />}
onClick={(e) => { onClick={(e) => {
@@ -620,7 +524,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
openCopyPageModal(); openCopyPageModal();
}} }}
> >
{t("Copy to space")} {t("Copy")}
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
@@ -49,7 +49,7 @@ export interface IMovePageToSpace {
export interface ICopyPageToSpace { export interface ICopyPageToSpace {
pageId: string; pageId: string;
spaceId?: string; spaceId: string;
} }
export interface SidebarPagesParams { export interface SidebarPagesParams {
@@ -26,9 +26,6 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
{option["type"] === "group" && <IconGroupCircle />} {option["type"] === "group" && <IconGroupCircle />}
<div> <div>
<Text size="sm" lineClamp={1}>{option.label}</Text> <Text size="sm" lineClamp={1}>{option.label}</Text>
{option["type"] === "user" && option["email"] && (
<Text size="xs" c="dimmed" lineClamp={1}>{option["email"]}</Text>
)}
</div> </div>
</Group> </Group>
); );
@@ -50,7 +47,6 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
const userItems = suggestion?.users.map((user: IUser) => ({ const userItems = suggestion?.users.map((user: IUser) => ({
value: `user-${user.id}`, value: `user-${user.id}`,
label: user.name, label: user.name,
email: user.email,
avatarUrl: user.avatarUrl, avatarUrl: user.avatarUrl,
type: "user", type: "user",
})); }));
-1
View File
@@ -36,7 +36,6 @@ if (isCloud() && isPostHogEnabled) {
api_host: getPostHogHost(), api_host: getPostHogHost(),
defaults: "2025-05-24", defaults: "2025-05-24",
disable_session_recording: true, disable_session_recording: true,
capture_pageleave: false,
}); });
} }
+1 -1
View File
@@ -71,7 +71,6 @@
"nestjs-kysely": "^1.2.0", "nestjs-kysely": "^1.2.0",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.3",
"openid-client": "^5.7.1", "openid-client": "^5.7.1",
"p-limit": "^6.2.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.16.0", "pg": "^8.16.0",
@@ -83,6 +82,7 @@
"sanitize-filename-ts": "^1.0.2", "sanitize-filename-ts": "^1.0.2",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"stripe": "^17.5.0", "stripe": "^17.5.0",
"tld-extract": "^2.1.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"ws": "^8.18.2", "ws": "^8.18.2",
"yauzl": "^3.2.0" "yauzl": "^3.2.0"
@@ -11,6 +11,7 @@ import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color'; import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube'; import { Youtube } from '@tiptap/extension-youtube';
import Table from '@tiptap/extension-table'; import Table from '@tiptap/extension-table';
import TableHeader from '@tiptap/extension-table-header';
import { import {
Callout, Callout,
Comment, Comment,
@@ -21,7 +22,6 @@ import {
LinkExtension, LinkExtension,
MathBlock, MathBlock,
MathInline, MathInline,
TableHeader,
TableCell, TableCell,
TableRow, TableRow,
TiptapImage, TiptapImage,
@@ -31,7 +31,7 @@ import {
Drawio, Drawio,
Excalidraw, Excalidraw,
Embed, Embed,
Mention, Mention
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML } from '../common/helpers/prosemirror/html'; import { generateHTML } from '../common/helpers/prosemirror/html';
@@ -46,11 +46,9 @@ export const tiptapExtensions = [
codeBlock: false, codeBlock: false,
}), }),
Comment, Comment,
TextAlign.configure({ types: ['heading', 'paragraph'] }), TextAlign.configure({ types: ["heading", "paragraph"] }),
TaskList, TaskList,
TaskItem.configure({ TaskItem,
nested: true,
}),
Underline, Underline,
LinkExtension, LinkExtension,
Superscript, Superscript,
@@ -66,9 +64,9 @@ export const tiptapExtensions = [
DetailsContent, DetailsContent,
DetailsSummary, DetailsSummary,
Table, Table,
TableCell,
TableRow,
TableHeader, TableHeader,
TableRow,
TableCell,
Youtube, Youtube,
TiptapImage, TiptapImage,
TiptapVideo, TiptapVideo,
@@ -78,7 +76,7 @@ export const tiptapExtensions = [
Drawio, Drawio,
Excalidraw, Excalidraw,
Embed, Embed,
Mention, Mention
] as any; ] as any;
export function jsonToHtml(tiptapJson: any) { export function jsonToHtml(tiptapJson: any) {
@@ -46,10 +46,6 @@ export class AuthenticationExtension implements Extension {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
if (user.deactivatedAt || user.deletedAt) {
throw new UnauthorizedException();
}
const page = await this.pageRepo.findById(pageId); const page = await this.pageRepo.findById(pageId);
if (!page) { if (!page) {
this.logger.warn(`Page not found: ${pageId}`); this.logger.warn(`Page not found: ${pageId}`);
@@ -1,4 +1,4 @@
import { Injectable, NestMiddleware, NotFoundException } from '@nestjs/common'; import { Injectable, NestMiddleware } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify'; import { FastifyRequest, FastifyReply } from 'fastify';
import { EnvironmentService } from '../../integrations/environment/environment.service'; import { EnvironmentService } from '../../integrations/environment/environment.service';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
@@ -27,8 +27,19 @@ export class DomainMiddleware implements NestMiddleware {
(req as any).workspace = workspace; (req as any).workspace = workspace;
} else if (this.environmentService.isCloud()) { } else if (this.environmentService.isCloud()) {
const header = req.headers.host; const header = req.headers.host;
const subdomain = header.split('.')[0];
// First, try to find workspace by custom domain
const workspaceByCustomDomain =
await this.workspaceRepo.findByCustomDomain(header);
if (workspaceByCustomDomain) {
(req as any).workspaceId = workspaceByCustomDomain.id;
(req as any).workspace = workspaceByCustomDomain;
return next();
}
// Fall back to subdomain logic
const subdomain = header.split('.')[0];
const workspace = await this.workspaceRepo.findByHostname(subdomain); const workspace = await this.workspaceRepo.findByHostname(subdomain);
if (!workspace) { if (!workspace) {
+1 -1
View File
@@ -108,7 +108,7 @@ export class AuthController {
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
return this.authService.getCollabToken(user, workspace.id); return this.authService.getCollabToken(user.id, workspace.id);
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -22,7 +22,7 @@ import { ForgotPasswordDto } from '../dto/forgot-password.dto';
import ForgotPasswordEmail from '@docmost/transactional/emails/forgot-password-email'; import ForgotPasswordEmail from '@docmost/transactional/emails/forgot-password-email';
import { UserTokenRepo } from '@docmost/db/repos/user-token/user-token.repo'; import { UserTokenRepo } from '@docmost/db/repos/user-token/user-token.repo';
import { PasswordResetDto } from '../dto/password-reset.dto'; import { PasswordResetDto } from '../dto/password-reset.dto';
import { User, UserToken, Workspace } from '@docmost/db/types/entity.types'; import { UserToken, Workspace } from '@docmost/db/types/entity.types';
import { UserTokenType } from '../auth.constants'; import { UserTokenType } from '../auth.constants';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
@@ -134,7 +134,7 @@ export class AuthService {
const token = nanoIdGen(16); const token = nanoIdGen(16);
const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`; const resetLink = `${this.domainService.getUrl(workspace.hostname, workspace.customDomain)}/password-reset?token=${token}`;
await this.userTokenRepo.insertUserToken({ await this.userTokenRepo.insertUserToken({
token: token, token: token,
@@ -222,9 +222,9 @@ export class AuthService {
} }
} }
async getCollabToken(user: User, workspaceId: string) { async getCollabToken(userId: string, workspaceId: string) {
const token = await this.tokenService.generateCollabToken( const token = await this.tokenService.generateCollabToken(
user, userId,
workspaceId, workspaceId,
); );
return { token }; return { token };
@@ -22,7 +22,7 @@ export class TokenService {
) {} ) {}
async generateAccessToken(user: User): Promise<string> { async generateAccessToken(user: User): Promise<string> {
if (user.deactivatedAt || user.deletedAt) { if (user.deletedAt) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
@@ -35,13 +35,12 @@ export class TokenService {
return this.jwtService.sign(payload); return this.jwtService.sign(payload);
} }
async generateCollabToken(user: User, workspaceId: string): Promise<string> { async generateCollabToken(
if (user.deactivatedAt || user.deletedAt) { userId: string,
throw new ForbiddenException(); workspaceId: string,
} ): Promise<string> {
const payload: JwtCollabPayload = { const payload: JwtCollabPayload = {
sub: user.id, sub: userId,
workspaceId, workspaceId,
type: JwtType.COLLAB, type: JwtType.COLLAB,
}; };
@@ -42,7 +42,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
} }
const user = await this.userRepo.findById(payload.sub, payload.workspaceId); const user = await this.userRepo.findById(payload.sub, payload.workspaceId);
if (!user || user.deactivatedAt || user.deletedAt) { if (!user || user.deletedAt) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
@@ -1,13 +1,13 @@
import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; import { IsString, IsNotEmpty } from 'class-validator';
export class DuplicatePageDto { export class CopyPageToSpaceDto {
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
pageId: string; pageId: string;
@IsOptional() @IsNotEmpty()
@IsString() @IsString()
spaceId?: string; spaceId: string;
} }
export type CopyPageMapEntry = { export type CopyPageMapEntry = {
+24 -35
View File
@@ -28,7 +28,7 @@ import {
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RecentPageDto } from './dto/recent-page.dto'; import { RecentPageDto } from './dto/recent-page.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto'; import { CopyPageToSpaceDto } from './dto/copy-page.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('pages') @Controller('pages')
@@ -146,6 +146,7 @@ export class PageController {
return this.pageService.getRecentPages(user.id, pagination); return this.pageService.getRecentPages(user.id, pagination);
} }
// TODO: scope to workspaces
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/history') @Post('/history')
async getPageHistory( async getPageHistory(
@@ -154,10 +155,6 @@ export class PageController {
@AuthUser() user: User, @AuthUser() user: User,
) { ) {
const page = await this.pageRepo.findById(dto.pageId); const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId); const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
@@ -242,41 +239,33 @@ export class PageController {
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('duplicate') @Post('copy-to-space')
async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) { async copyPageToSpace(
@Body() dto: CopyPageToSpaceDto,
@AuthUser() user: User,
) {
const copiedPage = await this.pageRepo.findById(dto.pageId); const copiedPage = await this.pageRepo.findById(dto.pageId);
if (!copiedPage) { if (!copiedPage) {
throw new NotFoundException('Page to copy not found'); throw new NotFoundException('Page to copy not found');
} }
if (copiedPage.spaceId === dto.spaceId) {
// If spaceId is provided, it's a copy to different space throw new BadRequestException('Page is already in this space');
if (dto.spaceId) {
const abilities = await Promise.all([
this.spaceAbility.createForUser(user, copiedPage.spaceId),
this.spaceAbility.createForUser(user, dto.spaceId),
]);
if (
abilities.some((ability) =>
ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
)
) {
throw new ForbiddenException();
}
return this.pageService.duplicatePage(copiedPage, dto.spaceId, user);
} else {
// If no spaceId, it's a duplicate in same space
const ability = await this.spaceAbility.createForUser(
user,
copiedPage.spaceId,
);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageService.duplicatePage(copiedPage, undefined, user);
} }
const abilities = await Promise.all([
this.spaceAbility.createForUser(user, copiedPage.spaceId),
this.spaceAbility.createForUser(user, dto.spaceId),
]);
if (
abilities.some((ability) =>
ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
)
) {
throw new ForbiddenException();
}
return this.pageService.copyPageToSpace(copiedPage, dto.spaceId, user);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -31,10 +31,7 @@ import {
removeMarkTypeFromDoc, removeMarkTypeFromDoc,
} from '../../../common/helpers/prosemirror/utils'; } from '../../../common/helpers/prosemirror/utils';
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util'; import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
import { import { CopyPageMapEntry, ICopyPageAttachment } from '../dto/copy-page.dto';
CopyPageMapEntry,
ICopyPageAttachment,
} from '../dto/duplicate-page.dto';
import { Node as PMNode } from '@tiptap/pm/model'; import { Node as PMNode } from '@tiptap/pm/model';
import { StorageService } from '../../../integrations/storage/storage.service'; import { StorageService } from '../../../integrations/storage/storage.service';
@@ -261,52 +258,11 @@ export class PageService {
}); });
} }
async duplicatePage( async copyPageToSpace(rootPage: Page, spaceId: string, authUser: User) {
rootPage: Page, //TODO:
targetSpaceId: string | undefined, // i. maintain internal links within copied pages
authUser: User,
) {
const spaceId = targetSpaceId || rootPage.spaceId;
const isDuplicateInSameSpace =
!targetSpaceId || targetSpaceId === rootPage.spaceId;
let nextPosition: string; const nextPosition = await this.nextPagePosition(spaceId);
if (isDuplicateInSameSpace) {
// For duplicate in same space, position right after the original page
let siblingQuery = this.db
.selectFrom('pages')
.select(['position'])
.where('spaceId', '=', rootPage.spaceId)
.where('position', '>', rootPage.position);
if (rootPage.parentPageId) {
siblingQuery = siblingQuery.where(
'parentPageId',
'=',
rootPage.parentPageId,
);
} else {
siblingQuery = siblingQuery.where('parentPageId', 'is', null);
}
const nextSibling = await siblingQuery
.orderBy('position', 'asc')
.limit(1)
.executeTakeFirst();
if (nextSibling) {
nextPosition = generateJitteredKeyBetween(
rootPage.position,
nextSibling.position,
);
} else {
nextPosition = generateJitteredKeyBetween(rootPage.position, null);
}
} else {
// For copy to different space, position at the end
nextPosition = await this.nextPagePosition(spaceId);
}
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, { const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: true, includeContent: true,
@@ -370,38 +326,12 @@ export class PageService {
}); });
} }
// Update internal page links in mention nodes
prosemirrorDoc.descendants((node: PMNode) => {
if (
node.type.name === 'mention' &&
node.attrs.entityType === 'page'
) {
const referencedPageId = node.attrs.entityId;
// Check if the referenced page is within the pages being copied
if (referencedPageId && pageMap.has(referencedPageId)) {
const mappedPage = pageMap.get(referencedPageId);
//@ts-ignore
node.attrs.entityId = mappedPage.newPageId;
//@ts-ignore
node.attrs.slugId = mappedPage.newSlugId;
}
}
});
const prosemirrorJson = prosemirrorDoc.toJSON(); const prosemirrorJson = prosemirrorDoc.toJSON();
// Add "Copy of " prefix to the root page title only for duplicates in same space
let title = page.title;
if (isDuplicateInSameSpace && page.id === rootPage.id) {
const originalTitle = page.title || 'Untitled';
title = `Copy of ${originalTitle}`;
}
return { return {
id: pageFromMap.newPageId, id: pageFromMap.newPageId,
slugId: pageFromMap.newSlugId, slugId: pageFromMap.newSlugId,
title: title, title: page.title,
icon: page.icon, icon: page.icon,
content: prosemirrorJson, content: prosemirrorJson,
textContent: jsonToText(prosemirrorJson), textContent: jsonToText(prosemirrorJson),
@@ -471,16 +401,9 @@ export class PageService {
} }
const newPageId = pageMap.get(rootPage.id).newPageId; const newPageId = pageMap.get(rootPage.id).newPageId;
const duplicatedPage = await this.pageRepo.findById(newPageId, { return await this.pageRepo.findById(newPageId, {
includeSpace: true, includeSpace: true,
}); });
const hasChildren = pages.length > 1;
return {
...duplicatedPage,
hasChildren,
};
} }
async movePage(dto: MovePageDto, movedPage: Page) { async movePage(dto: MovePageDto, movedPage: Page) {
@@ -140,7 +140,7 @@ export class SearchService {
if (suggestion.includeUsers) { if (suggestion.includeUsers) {
users = await this.db users = await this.db
.selectFrom('users') .selectFrom('users')
.select(['id', 'name', 'email', 'avatarUrl']) .select(['id', 'name', 'avatarUrl'])
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`)) .where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
@@ -171,7 +171,7 @@ export class WorkspaceInvitationService {
invitation.email, invitation.email,
invitation.token, invitation.token,
authUser.name, authUser.name,
workspace.hostname, workspace,
); );
}); });
} }
@@ -317,7 +317,7 @@ export class WorkspaceInvitationService {
invitation.email, invitation.email,
invitation.token, invitation.token,
invitedByUser.name, invitedByUser.name,
workspace.hostname, workspace,
); );
} }
@@ -340,17 +340,17 @@ export class WorkspaceInvitationService {
return this.buildInviteLink({ return this.buildInviteLink({
invitationId, invitationId,
inviteToken: token.token, inviteToken: token.token,
hostname: workspace.hostname, workspace: workspace,
}); });
} }
async buildInviteLink(opts: { async buildInviteLink(opts: {
invitationId: string; invitationId: string;
inviteToken: string; inviteToken: string;
hostname?: string; workspace: Workspace;
}): Promise<string> { }): Promise<string> {
const { invitationId, inviteToken, hostname } = opts; const { invitationId, inviteToken, workspace } = opts;
return `${this.domainService.getUrl(hostname)}/invites/${invitationId}?token=${inviteToken}`; return `${this.domainService.getUrl(workspace.hostname, workspace.customDomain)}/invites/${invitationId}?token=${inviteToken}`;
} }
async sendInvitationMail( async sendInvitationMail(
@@ -358,12 +358,12 @@ export class WorkspaceInvitationService {
inviteeEmail: string, inviteeEmail: string,
inviteToken: string, inviteToken: string,
invitedByName: string, invitedByName: string,
hostname?: string, workspace: Workspace,
): Promise<void> { ): Promise<void> {
const inviteLink = await this.buildInviteLink({ const inviteLink = await this.buildInviteLink({
invitationId, invitationId,
inviteToken, inviteToken,
hostname, workspace,
}); });
const emailTemplate = InvitationEmail({ const emailTemplate = InvitationEmail({
@@ -83,6 +83,14 @@ export class WorkspaceRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
async findByCustomDomain(domain: string): Promise<Workspace> {
return await this.db
.selectFrom('workspaces')
.selectAll()
.where(sql`LOWER(custom_domain)`, '=', sql`LOWER(${domain})`)
.executeTakeFirst();
}
async hostnameExists( async hostnameExists(
hostname: string, hostname: string,
trx?: KyselyTransaction, trx?: KyselyTransaction,
@@ -5,10 +5,13 @@ import { EnvironmentService } from './environment.service';
export class DomainService { export class DomainService {
constructor(private environmentService: EnvironmentService) {} constructor(private environmentService: EnvironmentService) {}
getUrl(hostname?: string): string { getUrl(hostname?: string, customDomain?: string): string {
if (!this.environmentService.isCloud()) { if (!this.environmentService.isCloud()) {
return this.environmentService.getAppUrl(); return this.environmentService.getAppUrl();
} }
if (customDomain) {
return customDomain;
}
const domain = this.environmentService.getSubdomainHost(); const domain = this.environmentService.getSubdomainHost();
if (!hostname || !domain) { if (!hostname || !domain) {
@@ -68,6 +68,10 @@ export class EnvironmentVariables {
) )
@ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase()) @ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase())
SUBDOMAIN_HOST: string; SUBDOMAIN_HOST: string;
@IsOptional()
@ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase())
APP_IP: string;
} }
export function validate(config: Record<string, any>) { export function validate(config: Record<string, any>) {
@@ -14,14 +14,10 @@ import { AttachmentType } from '../../../core/attachment/attachment.constants';
import { unwrapFromParagraph } from '../utils/import-formatter'; import { unwrapFromParagraph } from '../utils/import-formatter';
import { resolveRelativeAttachmentPath } from '../utils/import.utils'; import { resolveRelativeAttachmentPath } from '../utils/import.utils';
import { load } from 'cheerio'; import { load } from 'cheerio';
import pLimit from 'p-limit';
@Injectable() @Injectable()
export class ImportAttachmentService { export class ImportAttachmentService {
private readonly logger = new Logger(ImportAttachmentService.name); private readonly logger = new Logger(ImportAttachmentService.name);
private readonly CONCURRENT_UPLOADS = 3;
private readonly MAX_RETRIES = 2;
private readonly RETRY_DELAY = 2000;
constructor( constructor(
private readonly storageService: StorageService, private readonly storageService: StorageService,
@@ -45,14 +41,7 @@ export class ImportAttachmentService {
attachmentCandidates, attachmentCandidates,
} = opts; } = opts;
const attachmentTasks: (() => Promise<void>)[] = []; const attachmentTasks: Promise<void>[] = [];
const limit = pLimit(this.CONCURRENT_UPLOADS);
const uploadStats = {
total: 0,
completed: 0,
failed: 0,
failedFiles: [] as string[],
};
/** /**
* Cache keyed by the *relative* path that appears in the HTML. * Cache keyed by the *relative* path that appears in the HTML.
@@ -85,16 +74,30 @@ export class ImportAttachmentService {
const apiFilePath = `/api/files/${attachmentId}/${fileNameWithExt}`; const apiFilePath = `/api/files/${attachmentId}/${fileNameWithExt}`;
attachmentTasks.push(() => this.uploadWithRetry({ attachmentTasks.push(
abs, (async () => {
storageFilePath, const fileStream = createReadStream(abs);
attachmentId, await this.storageService.uploadStream(storageFilePath, fileStream);
fileNameWithExt, const stat = await fs.stat(abs);
ext,
pageId, await this.db
fileTask, .insertInto('attachments')
uploadStats, .values({
})); id: attachmentId,
filePath: storageFilePath,
fileName: fileNameWithExt,
fileSize: stat.size,
mimeType: getMimeType(fileNameWithExt),
type: 'file',
fileExt: ext,
creatorId: fileTask.creatorId,
workspaceId: fileTask.workspaceId,
pageId,
spaceId: fileTask.spaceId,
})
.execute();
})(),
);
return { return {
attachmentId, attachmentId,
@@ -289,113 +292,12 @@ export class ImportAttachmentService {
} }
// wait for all uploads & DB inserts // wait for all uploads & DB inserts
uploadStats.total = attachmentTasks.length; try {
await Promise.all(attachmentTasks);
if (uploadStats.total > 0) { } catch (err) {
this.logger.debug(`Starting upload of ${uploadStats.total} attachments...`); this.logger.log('Import attachment upload error', err);
try {
await Promise.all(
attachmentTasks.map(task => limit(task))
);
} catch (err) {
this.logger.error('Import attachment upload error', err);
}
this.logger.debug(
`Upload completed: ${uploadStats.completed}/${uploadStats.total} successful, ${uploadStats.failed} failed`
);
if (uploadStats.failed > 0) {
this.logger.warn(
`Failed to upload ${uploadStats.failed} files:`,
uploadStats.failedFiles
);
}
} }
return $.root().html() || ''; return $.root().html() || '';
} }
private async uploadWithRetry(opts: {
abs: string;
storageFilePath: string;
attachmentId: string;
fileNameWithExt: string;
ext: string;
pageId: string;
fileTask: FileTask;
uploadStats: {
total: number;
completed: number;
failed: number;
failedFiles: string[];
};
}): Promise<void> {
const {
abs,
storageFilePath,
attachmentId,
fileNameWithExt,
ext,
pageId,
fileTask,
uploadStats,
} = opts;
let lastError: Error;
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
try {
const fileStream = createReadStream(abs);
await this.storageService.uploadStream(storageFilePath, fileStream);
const stat = await fs.stat(abs);
await this.db
.insertInto('attachments')
.values({
id: attachmentId,
filePath: storageFilePath,
fileName: fileNameWithExt,
fileSize: stat.size,
mimeType: getMimeType(fileNameWithExt),
type: 'file',
fileExt: ext,
creatorId: fileTask.creatorId,
workspaceId: fileTask.workspaceId,
pageId,
spaceId: fileTask.spaceId,
})
.execute();
uploadStats.completed++;
if (uploadStats.completed % 10 === 0) {
this.logger.debug(
`Upload progress: ${uploadStats.completed}/${uploadStats.total}`
);
}
return;
} catch (error) {
lastError = error as Error;
this.logger.warn(
`Upload attempt ${attempt}/${this.MAX_RETRIES} failed for ${fileNameWithExt}: ${error instanceof Error ? error.message : String(error)}`
);
if (attempt < this.MAX_RETRIES) {
await new Promise(resolve =>
setTimeout(resolve, this.RETRY_DELAY * attempt)
);
}
}
}
uploadStats.failed++;
uploadStats.failedFiles.push(fileNameWithExt);
this.logger.error(
`Failed to upload ${fileNameWithExt} after ${this.MAX_RETRIES} attempts:`,
lastError
);
}
} }
-1
View File
@@ -17,5 +17,4 @@ export * from "./lib/excalidraw";
export * from "./lib/embed"; export * from "./lib/embed";
export * from "./lib/mention"; export * from "./lib/mention";
export * from "./lib/markdown"; export * from "./lib/markdown";
export * from "./lib/search-and-replace";
export * from "./lib/embed-provider"; export * from "./lib/embed-provider";
@@ -35,42 +35,6 @@ export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
return true; return true;
} }
}, },
"Mod-a": () => {
if (this.editor.isActive("codeBlock")) {
const { state } = this.editor;
const { $from } = state.selection;
let codeBlockNode = null;
let codeBlockPos = null;
let depth = 0;
for (depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type.name === "codeBlock") {
codeBlockNode = node;
codeBlockPos = $from.start(depth) - 1;
break;
}
}
if (codeBlockNode && codeBlockPos !== null) {
const codeBlockStart = codeBlockPos;
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
const contentStart = codeBlockStart + 1;
const contentEnd = codeBlockEnd - 1;
this.editor.commands.setTextSelection({
from: contentStart,
to: contentEnd,
});
return true;
}
}
return false;
},
}; };
}, },
@@ -1,3 +0,0 @@
import { SearchAndReplace } from './search-and-replace'
export * from './search-and-replace'
export default SearchAndReplace
@@ -1,455 +0,0 @@
/***
MIT License
Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***/
import { Extension, Range, type Dispatch } from "@tiptap/core";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import {
Plugin,
PluginKey,
type EditorState,
type Transaction,
} from "@tiptap/pm/state";
import { Node as PMNode, Mark } from "@tiptap/pm/model";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
search: {
/**
* @description Set search term in extension.
*/
setSearchTerm: (searchTerm: string) => ReturnType;
/**
* @description Set replace term in extension.
*/
setReplaceTerm: (replaceTerm: string) => ReturnType;
/**
* @description Set case sensitivity in extension.
*/
setCaseSensitive: (caseSensitive: boolean) => ReturnType;
/**
* @description Reset current search result to first instance.
*/
resetIndex: () => ReturnType;
/**
* @description Find next instance of search result.
*/
nextSearchResult: () => ReturnType;
/**
* @description Find previous instance of search result.
*/
previousSearchResult: () => ReturnType;
/**
* @description Replace first instance of search result with given replace term.
*/
replace: () => ReturnType;
/**
* @description Replace all instances of search result with given replace term.
*/
replaceAll: () => ReturnType;
/**
* @description Find selected instance of search result.
*/
selectCurrentItem: () => ReturnType;
};
}
}
interface TextNodesWithPosition {
text: string;
pos: number;
}
const getRegex = (
s: string,
disableRegex: boolean,
caseSensitive: boolean,
): RegExp => {
return RegExp(
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s,
caseSensitive ? "gu" : "gui",
);
};
interface ProcessedSearches {
decorationsToReturn: DecorationSet;
results: Range[];
}
function processSearches(
doc: PMNode,
searchTerm: RegExp,
searchResultClass: string,
resultIndex: number,
): ProcessedSearches {
const decorations: Decoration[] = [];
const results: Range[] = [];
let textNodesWithPosition: TextNodesWithPosition[] = [];
let index = 0;
if (!searchTerm) {
return {
decorationsToReturn: DecorationSet.empty,
results: [],
};
}
doc?.descendants((node, pos) => {
if (node.isText) {
if (textNodesWithPosition[index]) {
textNodesWithPosition[index] = {
text: textNodesWithPosition[index].text + node.text,
pos: textNodesWithPosition[index].pos,
};
} else {
textNodesWithPosition[index] = {
text: `${node.text}`,
pos,
};
}
} else {
index += 1;
}
});
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
for (const element of textNodesWithPosition) {
const { text, pos } = element;
const matches = Array.from(text.matchAll(searchTerm)).filter(
([matchText]) => matchText.trim(),
);
for (const m of matches) {
if (m[0] === "") break;
if (m.index !== undefined) {
results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
}
}
for (let i = 0; i < results.length; i += 1) {
const r = results[i];
const className =
i === resultIndex
? `${searchResultClass} ${searchResultClass}-current`
: searchResultClass;
const decoration: Decoration = Decoration.inline(r.from, r.to, {
class: className,
});
decorations.push(decoration);
}
return {
decorationsToReturn: DecorationSet.create(doc, decorations),
results,
};
}
const replace = (
replaceTerm: string,
results: Range[],
resultIndex: number,
{ state, dispatch }: { state: EditorState; dispatch: Dispatch },
) => {
const firstResult = results[resultIndex];
if (!firstResult) return;
const { from, to } = results[resultIndex];
if (dispatch) {
const tr = state.tr;
// Get all marks that span the text being replaced
const marksSet = new Set<Mark>();
state.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) {
node.marks.forEach(mark => marksSet.add(mark));
}
});
const marks = Array.from(marksSet);
// Delete the old text and insert new text with preserved marks
tr.delete(from, to);
tr.insert(from, state.schema.text(replaceTerm, marks));
dispatch(tr);
}
};
const replaceAll = (
replaceTerm: string,
results: Range[],
{ tr, dispatch }: { tr: Transaction; dispatch: Dispatch },
) => {
const resultsCopy = results.slice();
if (!resultsCopy.length) return;
// Process replacements in reverse order to avoid position shifting issues
for (let i = resultsCopy.length - 1; i >= 0; i -= 1) {
const { from, to } = resultsCopy[i];
// Get all marks that span the text being replaced
const marksSet = new Set<Mark>();
tr.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) {
node.marks.forEach(mark => marksSet.add(mark));
}
});
const marks = Array.from(marksSet);
// Delete and insert with preserved marks
tr.delete(from, to);
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
}
dispatch(tr);
};
export const searchAndReplacePluginKey = new PluginKey(
"searchAndReplacePlugin",
);
export interface SearchAndReplaceOptions {
searchResultClass: string;
disableRegex: boolean;
}
export interface SearchAndReplaceStorage {
searchTerm: string;
replaceTerm: string;
results: Range[];
lastSearchTerm: string;
caseSensitive: boolean;
lastCaseSensitive: boolean;
resultIndex: number;
lastResultIndex: number;
}
export const SearchAndReplace = Extension.create<
SearchAndReplaceOptions,
SearchAndReplaceStorage
>({
name: "searchAndReplace",
addOptions() {
return {
searchResultClass: "search-result",
disableRegex: true,
};
},
addStorage() {
return {
searchTerm: "",
replaceTerm: "",
results: [],
lastSearchTerm: "",
caseSensitive: false,
lastCaseSensitive: false,
resultIndex: 0,
lastResultIndex: 0,
};
},
addCommands() {
return {
setSearchTerm:
(searchTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.searchTerm = searchTerm;
return false;
},
setReplaceTerm:
(replaceTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.replaceTerm = replaceTerm;
return false;
},
setCaseSensitive:
(caseSensitive: boolean) =>
({ editor }) => {
editor.storage.searchAndReplace.caseSensitive = caseSensitive;
return false;
},
resetIndex:
() =>
({ editor }) => {
editor.storage.searchAndReplace.resultIndex = 0;
return false;
},
nextSearchResult:
() =>
({ editor }) => {
const { results, resultIndex } = editor.storage.searchAndReplace;
const nextIndex = resultIndex + 1;
if (results[nextIndex]) {
editor.storage.searchAndReplace.resultIndex = nextIndex;
} else {
editor.storage.searchAndReplace.resultIndex = 0;
}
return false;
},
previousSearchResult:
() =>
({ editor }) => {
const { results, resultIndex } = editor.storage.searchAndReplace;
const prevIndex = resultIndex - 1;
if (results[prevIndex]) {
editor.storage.searchAndReplace.resultIndex = prevIndex;
} else {
editor.storage.searchAndReplace.resultIndex = results.length - 1;
}
return false;
},
replace:
() =>
({ editor, state, dispatch }) => {
const { replaceTerm, results, resultIndex } =
editor.storage.searchAndReplace;
replace(replaceTerm, results, resultIndex, { state, dispatch });
// After replace, adjust index if needed
// The results will be recalculated by the plugin, but we need to ensure
// the index doesn't exceed the new bounds
setTimeout(() => {
const newResultsLength = editor.storage.searchAndReplace.results.length;
if (newResultsLength > 0 && editor.storage.searchAndReplace.resultIndex >= newResultsLength) {
// Keep the same position if possible, otherwise go to the last result
editor.storage.searchAndReplace.resultIndex = Math.min(resultIndex, newResultsLength - 1);
}
}, 0);
return false;
},
replaceAll:
() =>
({ editor, tr, dispatch }) => {
const { replaceTerm, results } = editor.storage.searchAndReplace;
replaceAll(replaceTerm, results, { tr, dispatch });
return false;
},
selectCurrentItem:
() =>
({ editor }) => {
const { results } = editor.storage.searchAndReplace;
for (let i = 0; i < results.length; i++) {
if (
results[i].from == editor.state.selection.from &&
results[i].to == editor.state.selection.to
) {
editor.storage.searchAndReplace.resultIndex = i;
}
}
return false;
},
};
},
addProseMirrorPlugins() {
const editor = this.editor;
const { searchResultClass, disableRegex } = this.options;
const setLastSearchTerm = (t: string) =>
(editor.storage.searchAndReplace.lastSearchTerm = t);
const setLastCaseSensitive = (t: boolean) =>
(editor.storage.searchAndReplace.lastCaseSensitive = t);
const setLastResultIndex = (t: number) =>
(editor.storage.searchAndReplace.lastResultIndex = t);
return [
new Plugin({
key: searchAndReplacePluginKey,
state: {
init: () => DecorationSet.empty,
apply({ doc, docChanged }, oldState) {
const {
searchTerm,
lastSearchTerm,
caseSensitive,
lastCaseSensitive,
resultIndex,
lastResultIndex,
} = editor.storage.searchAndReplace;
if (
!docChanged &&
lastSearchTerm === searchTerm &&
lastCaseSensitive === caseSensitive &&
lastResultIndex === resultIndex
)
return oldState;
setLastSearchTerm(searchTerm);
setLastCaseSensitive(caseSensitive);
setLastResultIndex(resultIndex);
if (!searchTerm) {
editor.storage.searchAndReplace.results = [];
return DecorationSet.empty;
}
const { decorationsToReturn, results } = processSearches(
doc,
getRegex(searchTerm, disableRegex, caseSensitive),
searchResultClass,
resultIndex,
);
editor.storage.searchAndReplace.results = results;
return decorationsToReturn;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}),
];
},
});
export default SearchAndReplace;
-31
View File
@@ -3,35 +3,4 @@ import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell";
export const TableCell = TiptapTableCell.extend({ export const TableCell = TiptapTableCell.extend({
name: "tableCell", name: "tableCell",
content: "paragraph+", content: "paragraph+",
addAttributes() {
return {
...this.parent?.(),
backgroundColor: {
default: null,
parseHTML: (element) => element.style.backgroundColor || null,
renderHTML: (attributes) => {
if (!attributes.backgroundColor) {
return {};
}
return {
style: `background-color: ${attributes.backgroundColor}`,
'data-background-color': attributes.backgroundColor,
};
},
},
backgroundColorName: {
default: null,
parseHTML: (element) => element.getAttribute('data-background-color-name') || null,
renderHTML: (attributes) => {
if (!attributes.backgroundColorName) {
return {};
}
return {
'data-background-color-name': attributes.backgroundColorName.toLowerCase(),
};
},
},
};
},
}); });
@@ -1,37 +0,0 @@
import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table-header";
export const TableHeader = TiptapTableHeader.extend({
name: "tableHeader",
content: "paragraph+",
addAttributes() {
return {
...this.parent?.(),
backgroundColor: {
default: null,
parseHTML: (element) => element.style.backgroundColor || null,
renderHTML: (attributes) => {
if (!attributes.backgroundColor) {
return {};
}
return {
style: `background-color: ${attributes.backgroundColor}`,
'data-background-color': attributes.backgroundColor,
};
},
},
backgroundColorName: {
default: null,
parseHTML: (element) => element.getAttribute('data-background-color-name') || null,
renderHTML: (attributes) => {
if (!attributes.backgroundColorName) {
return {};
}
return {
'data-background-color-name': attributes.backgroundColorName.toLowerCase(),
};
},
},
};
},
});
@@ -1,3 +1,2 @@
export * from "./row"; export * from "./row";
export * from "./cell"; export * from "./cell";
export * from "./header";
+61 -70
View File
@@ -222,23 +222,23 @@ importers:
specifier: 0.18.0-864353b specifier: 0.18.0-864353b
version: 0.18.0-864353b(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 0.18.0-864353b(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mantine/core': '@mantine/core':
specifier: ^8.1.3 specifier: ^7.17.0
version: 8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mantine/form': '@mantine/form':
specifier: ^8.1.3 specifier: ^7.17.0
version: 8.1.3(react@18.3.1) version: 7.17.0(react@18.3.1)
'@mantine/hooks': '@mantine/hooks':
specifier: ^8.1.3 specifier: ^7.17.0
version: 8.1.3(react@18.3.1) version: 7.17.0(react@18.3.1)
'@mantine/modals': '@mantine/modals':
specifier: ^8.1.3 specifier: ^7.17.0
version: 8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mantine/notifications': '@mantine/notifications':
specifier: ^8.1.3 specifier: ^7.17.0
version: 8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mantine/spotlight': '@mantine/spotlight':
specifier: ^8.1.3 specifier: ^7.17.0
version: 8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tabler/icons-react': '@tabler/icons-react':
specifier: ^3.34.0 specifier: ^3.34.0
version: 3.34.0(react@18.3.1) version: 3.34.0(react@18.3.1)
@@ -534,9 +534,6 @@ importers:
openid-client: openid-client:
specifier: ^5.7.1 specifier: ^5.7.1
version: 5.7.1 version: 5.7.1
p-limit:
specifier: ^6.2.0
version: 6.2.0
passport-google-oauth20: passport-google-oauth20:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
@@ -570,6 +567,9 @@ importers:
stripe: stripe:
specifier: ^17.5.0 specifier: ^17.5.0
version: 17.5.0 version: 17.5.0
tld-extract:
specifier: ^2.1.0
version: 2.1.0
tmp-promise: tmp-promise:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
@@ -2506,49 +2506,49 @@ packages:
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'} engines: {node: '>=8'}
'@mantine/core@8.1.3': '@mantine/core@7.17.0':
resolution: {integrity: sha512-2WOPC8GSN3MApet0MccSn6LaXRhcP6SVtZnbuHoqJ/atrfK7kLE66ILr4OXov7JAj1ASJ4Xk0bOXmu5fBExAvQ==} resolution: {integrity: sha512-AU5UFewUNzBCUXIq5Jk6q402TEri7atZW61qHW6P0GufJ2W/JxGHRvgmHOVHTVIcuWQRCt9SBSqZoZ/vHs9LhA==}
peerDependencies: peerDependencies:
'@mantine/hooks': 8.1.3 '@mantine/hooks': 7.17.0
react: ^18.x || ^19.x react: ^18.x || ^19.x
react-dom: ^18.x || ^19.x react-dom: ^18.x || ^19.x
'@mantine/form@8.1.3': '@mantine/form@7.17.0':
resolution: {integrity: sha512-OoSVv2cyjKRZ+C4Rw63VsnO3qjKGZHJkd6DSJTVRQHXfDr10hxmC5yXgxGKsxGQ+xFd4ZCdtzPUU2BoWbHfZAA==} resolution: {integrity: sha512-LONdeb+wL8h9fvyQ339ZFLxqrvYff+b+H+kginZhnr45OBTZDLXNVAt/YoKVFEkynF9WDJjdBVrXKcOZvPgmrA==}
peerDependencies: peerDependencies:
react: ^18.x || ^19.x react: ^18.x || ^19.x
'@mantine/hooks@8.1.3': '@mantine/hooks@7.17.0':
resolution: {integrity: sha512-yL4SbyYjrkmtIhscswajNz9RL0iO2+V8CMtOi0KISch2rPNvTAJNumFuZaXgj4UHeDc0JQYSmcZ+EW8NGm7xcQ==} resolution: {integrity: sha512-vo3K49mLy1nJ8LQNb5KDbJgnX0xwt3Y8JOF3ythjB5LEFMptdLSSgulu64zj+QHtzvffFCsMb05DbTLLpVP/JQ==}
peerDependencies: peerDependencies:
react: ^18.x || ^19.x react: ^18.x || ^19.x
'@mantine/modals@8.1.3': '@mantine/modals@7.17.0':
resolution: {integrity: sha512-PTLquO7OuYHrbezhjqf1fNwxU1NKZJmNYDOll6RHp6FPQ80xCVWQqVFsj3R8XsLluu2b5ygTYi+avWrUr1GvGg==} resolution: {integrity: sha512-4sfiFxIxMxfm2RH4jXMN+cr8tFS5AexXG4TY7TRN/ySdkiWtFVvDe5l2/KRWWeWwDUb7wQhht8Ompj5KtexlEA==}
peerDependencies: peerDependencies:
'@mantine/core': 8.1.3 '@mantine/core': 7.17.0
'@mantine/hooks': 8.1.3 '@mantine/hooks': 7.17.0
react: ^18.x || ^19.x react: ^18.x || ^19.x
react-dom: ^18.x || ^19.x react-dom: ^18.x || ^19.x
'@mantine/notifications@8.1.3': '@mantine/notifications@7.17.0':
resolution: {integrity: sha512-Xy6f/l1yLTo77hz8X80sOuY+HW80e1rn8ucygx9TAexK5+XtyriOv26TQ3EJ6Ej5jlchtZRFEUJ4tJGRWjGCNg==} resolution: {integrity: sha512-xejr1WW02NrrrE4HPDoownILJubcjLLwCDeTk907ZeeHKBEPut7RukEq6gLzOZBhNhKdPM+vCM7GcbXdaLZq/Q==}
peerDependencies: peerDependencies:
'@mantine/core': 8.1.3 '@mantine/core': 7.17.0
'@mantine/hooks': 8.1.3 '@mantine/hooks': 7.17.0
react: ^18.x || ^19.x react: ^18.x || ^19.x
react-dom: ^18.x || ^19.x react-dom: ^18.x || ^19.x
'@mantine/spotlight@8.1.3': '@mantine/spotlight@7.17.0':
resolution: {integrity: sha512-GhJbSoUdcALGSMLC/zjVVncRDyvxwxjtlzFeHLuY0Dgkgj+60x3tnzAulDrqYVhLMk7fGyex22VV/Xwl7mG1+Q==} resolution: {integrity: sha512-T7xfXxyDg2fxf7qvKwBozQ8HBnTQ2GRCIIoeYdAoiHoFQUS7NbBAnqrjdr5iYZpJqyLRXn8uFI7DX1Zdzd6/PQ==}
peerDependencies: peerDependencies:
'@mantine/core': 8.1.3 '@mantine/core': 7.17.0
'@mantine/hooks': 8.1.3 '@mantine/hooks': 7.17.0
react: ^18.x || ^19.x react: ^18.x || ^19.x
react-dom: ^18.x || ^19.x react-dom: ^18.x || ^19.x
'@mantine/store@8.1.3': '@mantine/store@7.17.0':
resolution: {integrity: sha512-rO72LfSJqSNCwufqJxTWiHMyOR6sR3mqAcnBcw/f5aTvyOYoHZzlm4q4+TL8/2vYGRVsr9YM2Ez6HQ1vk/RR8g==} resolution: {integrity: sha512-nhWRYRLqvAjrD/ApKCXxuHyTWg2b5dC06Z5gmO8udj4pBgndNf9nmCl+Of90H6bgOa56moJA7UQyXoF1SfxqVg==}
peerDependencies: peerDependencies:
react: ^18.x || ^19.x react: ^18.x || ^19.x
@@ -7640,10 +7640,6 @@ packages:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
p-limit@6.2.0:
resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==}
engines: {node: '>=18'}
p-locate@4.1.0: p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -8272,8 +8268,8 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
react-textarea-autosize@8.5.9: react-textarea-autosize@8.5.6:
resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==} resolution: {integrity: sha512-aT3ioKXMa8f6zHYGebhbdMD2L00tKeRX1zuVuDx9YQK/JLLRSaSxq3ugECEmUB9z2kvk6bFSIoRHLkkUv0RJiw==}
engines: {node: '>=10'} engines: {node: '>=10'}
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -8871,6 +8867,9 @@ packages:
tiptap-extension-global-drag-handle@0.1.18: tiptap-extension-global-drag-handle@0.1.18:
resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==} resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==}
tld-extract@2.1.0:
resolution: {integrity: sha512-Y9QHWIoDQPJJVm3/pOC7kOfOj7vsNSVZl4JGoEHb605FiwZgIfzSMyU0HC0wYw5Cx8435vaG1yGZtIm1yiQGOw==}
tldts-core@6.1.72: tldts-core@6.1.72:
resolution: {integrity: sha512-FW3H9aCaGTJ8l8RVCR3EX8GxsxDbQXuwetwwgXA2chYdsX+NY1ytCBl61narjjehWmCw92tc1AxlcY3668CU8g==} resolution: {integrity: sha512-FW3H9aCaGTJ8l8RVCR3EX8GxsxDbQXuwetwwgXA2chYdsX+NY1ytCBl61narjjehWmCw92tc1AxlcY3668CU8g==}
@@ -9574,10 +9573,6 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
yocto-queue@1.2.1:
resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
engines: {node: '>=12.20'}
yoctocolors-cjs@2.1.2: yoctocolors-cjs@2.1.2:
resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -12239,55 +12234,55 @@ snapshots:
'@lukeed/ms@2.0.2': {} '@lukeed/ms@2.0.2': {}
'@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': '@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mantine/hooks': 8.1.3(react@18.3.1) '@mantine/hooks': 7.17.0(react@18.3.1)
clsx: 2.1.1 clsx: 2.1.1
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
react-number-format: 5.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-number-format: 5.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-remove-scroll: 2.6.3(@types/react@18.3.12)(react@18.3.1) react-remove-scroll: 2.6.3(@types/react@18.3.12)(react@18.3.1)
react-textarea-autosize: 8.5.9(@types/react@18.3.12)(react@18.3.1) react-textarea-autosize: 8.5.6(@types/react@18.3.12)(react@18.3.1)
type-fest: 4.28.1 type-fest: 4.28.1
transitivePeerDependencies: transitivePeerDependencies:
- '@types/react' - '@types/react'
'@mantine/form@8.1.3(react@18.3.1)': '@mantine/form@7.17.0(react@18.3.1)':
dependencies: dependencies:
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
klona: 2.0.6 klona: 2.0.6
react: 18.3.1 react: 18.3.1
'@mantine/hooks@8.1.3(react@18.3.1)': '@mantine/hooks@7.17.0(react@18.3.1)':
dependencies: dependencies:
react: 18.3.1 react: 18.3.1
'@mantine/modals@8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': '@mantine/modals@7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@mantine/core': 8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mantine/core': 7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mantine/hooks': 8.1.3(react@18.3.1) '@mantine/hooks': 7.17.0(react@18.3.1)
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
'@mantine/notifications@8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': '@mantine/notifications@7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@mantine/core': 8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mantine/core': 7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mantine/hooks': 8.1.3(react@18.3.1) '@mantine/hooks': 7.17.0(react@18.3.1)
'@mantine/store': 8.1.3(react@18.3.1) '@mantine/store': 7.17.0(react@18.3.1)
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mantine/spotlight@8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': '@mantine/spotlight@7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@mantine/core': 8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mantine/core': 7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mantine/hooks': 8.1.3(react@18.3.1) '@mantine/hooks': 7.17.0(react@18.3.1)
'@mantine/store': 8.1.3(react@18.3.1) '@mantine/store': 7.17.0(react@18.3.1)
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
'@mantine/store@8.1.3(react@18.3.1)': '@mantine/store@7.17.0(react@18.3.1)':
dependencies: dependencies:
react: 18.3.1 react: 18.3.1
@@ -18204,10 +18199,6 @@ snapshots:
dependencies: dependencies:
yocto-queue: 0.1.0 yocto-queue: 0.1.0
p-limit@6.2.0:
dependencies:
yocto-queue: 1.2.1
p-locate@4.1.0: p-locate@4.1.0:
dependencies: dependencies:
p-limit: 2.3.0 p-limit: 2.3.0
@@ -18864,7 +18855,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.3.12 '@types/react': 18.3.12
react-textarea-autosize@8.5.9(@types/react@18.3.12)(react@18.3.1): react-textarea-autosize@8.5.6(@types/react@18.3.12)(react@18.3.1):
dependencies: dependencies:
'@babel/runtime': 7.25.6 '@babel/runtime': 7.25.6
react: 18.3.1 react: 18.3.1
@@ -19553,6 +19544,8 @@ snapshots:
tiptap-extension-global-drag-handle@0.1.18: {} tiptap-extension-global-drag-handle@0.1.18: {}
tld-extract@2.1.0: {}
tldts-core@6.1.72: {} tldts-core@6.1.72: {}
tldts@6.1.72: tldts@6.1.72:
@@ -20198,8 +20191,6 @@ snapshots:
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
yocto-queue@1.2.1: {}
yoctocolors-cjs@2.1.2: {} yoctocolors-cjs@2.1.2: {}
zeed-dom@0.15.1: zeed-dom@0.15.1: