mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25fce8b049 | |||
| 8522844673 | |||
| f8dc9845a7 | |||
| 4dfed2b2af | |||
| 44e592763d | |||
| 90488a95b1 | |||
| 9f39987404 | |||
| 16ec218ba7 | |||
| 608783b5cf | |||
| 5f5f1484db | |||
| f4082171ec | |||
| 6792a191b1 | |||
| e51a93221c | |||
| e856c8eb69 | |||
| c2c165528b | |||
| 9fa2b9636c | |||
| 29388636bf | |||
| f80004817c | |||
| ac79a185de | |||
| 27a9c0ebe4 | |||
| 81ffa6f459 | |||
| 5364702b69 |
@@ -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": "^7.17.0",
|
"@mantine/core": "^8.1.3",
|
||||||
"@mantine/form": "^7.17.0",
|
"@mantine/form": "^8.1.3",
|
||||||
"@mantine/hooks": "^7.17.0",
|
"@mantine/hooks": "^8.1.3",
|
||||||
"@mantine/modals": "^7.17.0",
|
"@mantine/modals": "^8.1.3",
|
||||||
"@mantine/notifications": "^7.17.0",
|
"@mantine/notifications": "^8.1.3",
|
||||||
"@mantine/spotlight": "^7.17.0",
|
"@mantine/spotlight": "^8.1.3",
|
||||||
"@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,7 +222,9 @@
|
|||||||
"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.",
|
||||||
@@ -389,5 +391,16 @@
|
|||||||
"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,9 +1,21 @@
|
|||||||
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";
|
||||||
@@ -19,6 +31,7 @@ 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;
|
||||||
@@ -101,6 +114,44 @@ 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,7 +117,8 @@ 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}
|
||||||
@@ -129,7 +130,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.currency.toUpperCase()} / {billing.interval}
|
||||||
</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,14 +12,18 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Flex,
|
Flex,
|
||||||
Switch,
|
Switch,
|
||||||
|
Alert,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
import { IconCheck, IconInfoCircle } 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,
|
||||||
@@ -36,39 +40,65 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstPlan = plans[0];
|
// Check if any plan is tiered
|
||||||
|
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
|
// Set initial tier value if not set and we have tiered plans
|
||||||
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
|
if (hasTieredPlans && !selectedTierValue && firstTieredPlan) {
|
||||||
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
|
setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedTierValue) {
|
// For tiered plans, ensure we have a selected tier
|
||||||
|
if (hasTieredPlans && !selectedTierValue) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectData = firstPlan.pricingTiers
|
const selectData = firstTieredPlan?.pricingTiers
|
||||||
.filter((tier) => !tier.custom)
|
?.filter((tier) => !tier.custom)
|
||||||
.map((tier, index) => {
|
.map((tier, index) => {
|
||||||
const prevMaxUsers =
|
const prevMaxUsers =
|
||||||
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
|
index > 0 ? firstTieredPlan.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"
|
||||||
@@ -79,6 +109,7 @@ export default function BillingPlans() {
|
|||||||
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">
|
||||||
@@ -102,16 +133,28 @@ 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) => {
|
||||||
const tieredPlan = plan;
|
let price;
|
||||||
const planSelectedTier =
|
let displayPrice;
|
||||||
tieredPlan.pricingTiers.find(
|
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
|
||||||
(tier) => tier.upTo.toString() === selectedTierValue,
|
|
||||||
) || tieredPlan.pricingTiers[0];
|
|
||||||
|
|
||||||
const price = isAnnual
|
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.yearly
|
||||||
: planSelectedTier.monthly;
|
: planSelectedTier.monthly;
|
||||||
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
|
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
|
||||||
@@ -143,25 +186,27 @@ 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">
|
||||||
${isAnnual ? (price / 12).toFixed(0) : price}
|
${displayPrice}
|
||||||
</Title>
|
</Title>
|
||||||
<Text size="lg" c="dimmed">
|
<Text size="lg" c="dimmed">
|
||||||
per {isAnnual ? "month" : "month"}
|
{plan.billingScheme === 'per_unit'
|
||||||
|
? `per user/month`
|
||||||
|
: `per month`}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{isAnnual && (
|
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Billed annually
|
{isAnnual ? "Billed annually" : "Billed monthly"}
|
||||||
|
</Text>
|
||||||
|
{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>
|
||||||
Upgrade
|
Subscribe
|
||||||
</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,6 +12,12 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
.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,9 +1,8 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { useMemo } from "react";
|
import React, { useMemo, useCallback } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
AspectRatio,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
FocusTrap,
|
FocusTrap,
|
||||||
@@ -14,7 +13,8 @@ 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, zodResolver } from "@mantine/form";
|
import { useForm } 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,6 +22,8 @@ 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
|
||||||
@@ -33,7 +35,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 } = node.attrs;
|
const { src, provider, height: nodeHeight } = node.attrs;
|
||||||
|
|
||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
if (src) {
|
if (src) {
|
||||||
@@ -49,6 +51,10 @@ 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;
|
||||||
@@ -77,17 +83,25 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
{embedUrl ? (
|
{embedUrl ? (
|
||||||
<>
|
<ResizableWrapper
|
||||||
<AspectRatio ratio={16 / 9}>
|
initialHeight={nodeHeight || 480}
|
||||||
|
minHeight={200}
|
||||||
|
maxHeight={1200}
|
||||||
|
onResize={handleResize}
|
||||||
|
isEditable={editor.isEditable}
|
||||||
|
className={clsx(classes.embedWrapper, {
|
||||||
|
"ProseMirror-selectednode": selected,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<iframe
|
<iframe
|
||||||
|
className={classes.embedIframe}
|
||||||
src={embedUrl}
|
src={embedUrl}
|
||||||
allow="encrypted-media"
|
allow="encrypted-media"
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
></iframe>
|
/>
|
||||||
</AspectRatio>
|
</ResizableWrapper>
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<Popover
|
<Popover
|
||||||
width={300}
|
width={300}
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
type SearchAndReplaceAtomType = {
|
||||||
|
isOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
|
||||||
|
isOpen: false,
|
||||||
|
});
|
||||||
+312
@@ -0,0 +1,312 @@
|
|||||||
|
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;
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
.findDialog{
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.findDialog div[data-position="right"].mantine-Input-section {
|
||||||
|
justify-content: right;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
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,8 +12,11 @@ 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 => {
|
||||||
@@ -45,6 +48,10 @@ 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}
|
||||||
@@ -60,6 +67,9 @@ 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}
|
||||||
@@ -103,6 +113,17 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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,7 +11,6 @@ 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";
|
||||||
@@ -25,6 +24,7 @@ import {
|
|||||||
MathInline,
|
MathInline,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
TiptapImage,
|
TiptapImage,
|
||||||
Callout,
|
Callout,
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
|
SearchAndReplace,
|
||||||
Mention,
|
Mention,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
@@ -217,6 +218,22 @@ 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,10 +1,5 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import React, {
|
import React, { useEffect, useMemo, useRef, useState } from "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 {
|
||||||
@@ -44,6 +39,7 @@ 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";
|
||||||
@@ -130,7 +126,15 @@ 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();
|
refetchCollabToken().then((result) => {
|
||||||
|
if (result.data?.token) {
|
||||||
|
remote.disconnect();
|
||||||
|
setTimeout(() => {
|
||||||
|
remote.configuration.token = result.data.token;
|
||||||
|
remote.connect();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onStatus: (status) => {
|
onStatus: (status) => {
|
||||||
@@ -156,6 +160,21 @@ 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;
|
||||||
@@ -198,6 +217,10 @@ 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) {
|
||||||
@@ -350,6 +373,11 @@ 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,4 +71,12 @@
|
|||||||
[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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.search-result{
|
||||||
|
background: #ffff65;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-current{
|
||||||
|
background: #ffc266 !important;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
@@ -9,5 +9,6 @@
|
|||||||
@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";
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/* 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,6 +4,7 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
& table {
|
& table {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
min-width: 700px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +67,54 @@
|
|||||||
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,8 +10,11 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
|
import {
|
||||||
import { useDebouncedCallback } from "@mantine/hooks";
|
updatePageData,
|
||||||
|
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";
|
||||||
@@ -40,7 +43,8 @@ export function TitleEditor({
|
|||||||
editable,
|
editable,
|
||||||
}: TitleEditorProps) {
|
}: TitleEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
|
const { mutateAsync: updateTitlePageMutationAsync } =
|
||||||
|
useUpdateTitlePageMutation();
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
@@ -108,7 +112,12 @@ export function TitleEditor({
|
|||||||
spaceId: page.spaceId,
|
spaceId: page.spaceId,
|
||||||
entity: ["pages"],
|
entity: ["pages"],
|
||||||
id: page.id,
|
id: page.id,
|
||||||
payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon },
|
payload: {
|
||||||
|
title: page.title,
|
||||||
|
slugId: page.slugId,
|
||||||
|
parentPageId: page.parentPageId,
|
||||||
|
icon: page.icon,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (page.title !== titleEditor.getText()) return;
|
if (page.title !== titleEditor.getText()) return;
|
||||||
@@ -152,12 +161,18 @@ 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) return;
|
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
|
||||||
|
return;
|
||||||
|
|
||||||
const { key } = event;
|
const { key } = event;
|
||||||
const { $head } = titleEditor.state.selection;
|
const { $head } = titleEditor.state.selection;
|
||||||
@@ -172,5 +187,16 @@ export function TitleEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
|
return (
|
||||||
|
<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 { copyPageToSpace } from "@/features/page/services/page-service.ts";
|
import { duplicatePage } 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 copyPageToSpace({
|
const copiedPage = await duplicatePage({
|
||||||
pageId,
|
pageId,
|
||||||
spaceId: targetSpace.id,
|
spaceId: targetSpace.id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
IconList,
|
IconList,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
|
IconSearch,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -16,7 +17,12 @@ 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 { useClipboard, useDisclosure } from "@mantine/hooks";
|
import {
|
||||||
|
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";
|
||||||
@@ -32,6 +38,7 @@ 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";
|
||||||
@@ -46,6 +53,26 @@ 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 copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
|
export async function duplicatePage(data: ICopyPageToSpace): Promise<IPage> {
|
||||||
const req = await api.post<IPage>("/pages/copy-to-space", data);
|
const req = await api.post<IPage>("/pages/duplicate", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
import {
|
||||||
|
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 {
|
||||||
@@ -66,6 +72,7 @@ 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;
|
||||||
@@ -90,8 +97,14 @@ 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(rootElement, sizeRef);
|
const mergedRef = useMergedRef((element) => {
|
||||||
|
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),
|
||||||
@@ -199,16 +212,17 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
}
|
}
|
||||||
}, [currentPage?.id]);
|
}, [currentPage?.id]);
|
||||||
|
|
||||||
|
// Clean up tree API on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (treeApiRef.current) {
|
return () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
setTreeApi(treeApiRef.current);
|
setTreeApi(null);
|
||||||
}
|
};
|
||||||
}, [treeApiRef.current]);
|
}, [setTreeApi]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={mergedRef} className={classes.treeContainer}>
|
<div ref={mergedRef} className={classes.treeContainer}>
|
||||||
{rootElement.current && (
|
{isRootReady && rootElement.current && (
|
||||||
<Tree
|
<Tree
|
||||||
data={data.filter((node) => node?.spaceId === spaceId)}
|
data={data.filter((node) => node?.spaceId === spaceId)}
|
||||||
disableDrag={readOnly}
|
disableDrag={readOnly}
|
||||||
@@ -217,7 +231,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
{...controllers}
|
{...controllers}
|
||||||
width={width}
|
width={width}
|
||||||
height={rootElement.current.clientHeight}
|
height={rootElement.current.clientHeight}
|
||||||
ref={treeApiRef}
|
ref={(ref) => {
|
||||||
|
treeApiRef.current = ref;
|
||||||
|
if (ref) {
|
||||||
|
//@ts-ignore
|
||||||
|
setTreeApi(ref);
|
||||||
|
}
|
||||||
|
}}
|
||||||
openByDefault={false}
|
openByDefault={false}
|
||||||
disableMultiSelection={true}
|
disableMultiSelection={true}
|
||||||
className={classes.tree}
|
className={classes.tree}
|
||||||
@@ -383,7 +403,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} />
|
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} />
|
||||||
|
|
||||||
{!tree.props.disableEdit && (
|
{!tree.props.disableEdit && (
|
||||||
<CreateNode
|
<CreateNode
|
||||||
@@ -436,13 +456,16 @@ 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 }: NodeMenuProps) {
|
function NodeMenu({ node, treeApi, spaceId }: 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 [
|
||||||
@@ -461,6 +484,68 @@ function NodeMenu({ node, treeApi }: 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}>
|
||||||
@@ -505,6 +590,17 @@ function NodeMenu({ node, treeApi }: 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) => {
|
||||||
@@ -524,7 +620,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
openCopyPageModal();
|
openCopyPageModal();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("Copy")}
|
{t("Copy to space")}
|
||||||
</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,6 +26,9 @@ 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>
|
||||||
);
|
);
|
||||||
@@ -47,6 +50,7 @@ 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",
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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,
|
||||||
@@ -22,6 +21,7 @@ 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,9 +46,11 @@ export const tiptapExtensions = [
|
|||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
}),
|
}),
|
||||||
Comment,
|
Comment,
|
||||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||||
TaskList,
|
TaskList,
|
||||||
TaskItem,
|
TaskItem.configure({
|
||||||
|
nested: true,
|
||||||
|
}),
|
||||||
Underline,
|
Underline,
|
||||||
LinkExtension,
|
LinkExtension,
|
||||||
Superscript,
|
Superscript,
|
||||||
@@ -64,9 +66,9 @@ export const tiptapExtensions = [
|
|||||||
DetailsContent,
|
DetailsContent,
|
||||||
DetailsSummary,
|
DetailsSummary,
|
||||||
Table,
|
Table,
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
TableCell,
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
Youtube,
|
Youtube,
|
||||||
TiptapImage,
|
TiptapImage,
|
||||||
TiptapVideo,
|
TiptapVideo,
|
||||||
@@ -76,7 +78,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,6 +46,10 @@ 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}`);
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export class AuthController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
return this.authService.getCollabToken(user.id, workspace.id);
|
return this.authService.getCollabToken(user, 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 { UserToken, Workspace } from '@docmost/db/types/entity.types';
|
import { User, 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';
|
||||||
@@ -222,9 +222,9 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCollabToken(userId: string, workspaceId: string) {
|
async getCollabToken(user: User, workspaceId: string) {
|
||||||
const token = await this.tokenService.generateCollabToken(
|
const token = await this.tokenService.generateCollabToken(
|
||||||
userId,
|
user,
|
||||||
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.deletedAt) {
|
if (user.deactivatedAt || user.deletedAt) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,12 +35,13 @@ export class TokenService {
|
|||||||
return this.jwtService.sign(payload);
|
return this.jwtService.sign(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateCollabToken(
|
async generateCollabToken(user: User, workspaceId: string): Promise<string> {
|
||||||
userId: string,
|
if (user.deactivatedAt || user.deletedAt) {
|
||||||
workspaceId: string,
|
throw new ForbiddenException();
|
||||||
): Promise<string> {
|
}
|
||||||
|
|
||||||
const payload: JwtCollabPayload = {
|
const payload: JwtCollabPayload = {
|
||||||
sub: userId,
|
sub: user.id,
|
||||||
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.deletedAt) {
|
if (!user || user.deactivatedAt || user.deletedAt) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,13 +1,13 @@
|
|||||||
import { IsString, IsNotEmpty } from 'class-validator';
|
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class CopyPageToSpaceDto {
|
export class DuplicatePageDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
spaceId: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CopyPageMapEntry = {
|
export type CopyPageMapEntry = {
|
||||||
@@ -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 { CopyPageToSpaceDto } from './dto/copy-page.dto';
|
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('pages')
|
@Controller('pages')
|
||||||
@@ -146,7 +146,6 @@ 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(
|
||||||
@@ -155,6 +154,10 @@ 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();
|
||||||
@@ -239,19 +242,15 @@ export class PageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('copy-to-space')
|
@Post('duplicate')
|
||||||
async copyPageToSpace(
|
async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) {
|
||||||
@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) {
|
|
||||||
throw new BadRequestException('Page is already in this space');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// If spaceId is provided, it's a copy to different space
|
||||||
|
if (dto.spaceId) {
|
||||||
const abilities = await Promise.all([
|
const abilities = await Promise.all([
|
||||||
this.spaceAbility.createForUser(user, copiedPage.spaceId),
|
this.spaceAbility.createForUser(user, copiedPage.spaceId),
|
||||||
this.spaceAbility.createForUser(user, dto.spaceId),
|
this.spaceAbility.createForUser(user, dto.spaceId),
|
||||||
@@ -265,7 +264,19 @@ export class PageController {
|
|||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.pageService.copyPageToSpace(copiedPage, dto.spaceId, user);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ 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 { CopyPageMapEntry, ICopyPageAttachment } from '../dto/copy-page.dto';
|
import {
|
||||||
|
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';
|
||||||
|
|
||||||
@@ -258,11 +261,52 @@ export class PageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async copyPageToSpace(rootPage: Page, spaceId: string, authUser: User) {
|
async duplicatePage(
|
||||||
//TODO:
|
rootPage: Page,
|
||||||
// i. maintain internal links within copied pages
|
targetSpaceId: string | undefined,
|
||||||
|
authUser: User,
|
||||||
|
) {
|
||||||
|
const spaceId = targetSpaceId || rootPage.spaceId;
|
||||||
|
const isDuplicateInSameSpace =
|
||||||
|
!targetSpaceId || targetSpaceId === rootPage.spaceId;
|
||||||
|
|
||||||
const nextPosition = await this.nextPagePosition(spaceId);
|
let nextPosition: string;
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -326,12 +370,38 @@ 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: page.title,
|
title: title,
|
||||||
icon: page.icon,
|
icon: page.icon,
|
||||||
content: prosemirrorJson,
|
content: prosemirrorJson,
|
||||||
textContent: jsonToText(prosemirrorJson),
|
textContent: jsonToText(prosemirrorJson),
|
||||||
@@ -401,9 +471,16 @@ export class PageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newPageId = pageMap.get(rootPage.id).newPageId;
|
const newPageId = pageMap.get(rootPage.id).newPageId;
|
||||||
return await this.pageRepo.findById(newPageId, {
|
const duplicatedPage = 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', 'avatarUrl'])
|
.select(['id', 'name', 'email', '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)
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 19197d2610...49a16ab3e0
@@ -14,10 +14,14 @@ 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,
|
||||||
@@ -41,7 +45,14 @@ 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.
|
||||||
@@ -74,30 +85,16 @@ export class ImportAttachmentService {
|
|||||||
|
|
||||||
const apiFilePath = `/api/files/${attachmentId}/${fileNameWithExt}`;
|
const apiFilePath = `/api/files/${attachmentId}/${fileNameWithExt}`;
|
||||||
|
|
||||||
attachmentTasks.push(
|
attachmentTasks.push(() => this.uploadWithRetry({
|
||||||
(async () => {
|
abs,
|
||||||
const fileStream = createReadStream(abs);
|
storageFilePath,
|
||||||
await this.storageService.uploadStream(storageFilePath, fileStream);
|
attachmentId,
|
||||||
const stat = await fs.stat(abs);
|
fileNameWithExt,
|
||||||
|
ext,
|
||||||
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,
|
pageId,
|
||||||
spaceId: fileTask.spaceId,
|
fileTask,
|
||||||
})
|
uploadStats,
|
||||||
.execute();
|
}));
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
attachmentId,
|
attachmentId,
|
||||||
@@ -292,12 +289,113 @@ export class ImportAttachmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wait for all uploads & DB inserts
|
// wait for all uploads & DB inserts
|
||||||
|
uploadStats.total = attachmentTasks.length;
|
||||||
|
|
||||||
|
if (uploadStats.total > 0) {
|
||||||
|
this.logger.debug(`Starting upload of ${uploadStats.total} attachments...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(attachmentTasks);
|
await Promise.all(
|
||||||
|
attachmentTasks.map(task => limit(task))
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.log('Import attachment upload error', 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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ 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,6 +35,42 @@ 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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { SearchAndReplace } from './search-and-replace'
|
||||||
|
export * from './search-and-replace'
|
||||||
|
export default SearchAndReplace
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
/***
|
||||||
|
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;
|
||||||
@@ -3,4 +3,35 @@ 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(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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,2 +1,3 @@
|
|||||||
export * from "./row";
|
export * from "./row";
|
||||||
export * from "./cell";
|
export * from "./cell";
|
||||||
|
export * from "./header";
|
||||||
|
|||||||
Generated
+70
-53
@@ -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: ^7.17.0
|
specifier: ^8.1.3
|
||||||
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)
|
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)
|
||||||
'@mantine/form':
|
'@mantine/form':
|
||||||
specifier: ^7.17.0
|
specifier: ^8.1.3
|
||||||
version: 7.17.0(react@18.3.1)
|
version: 8.1.3(react@18.3.1)
|
||||||
'@mantine/hooks':
|
'@mantine/hooks':
|
||||||
specifier: ^7.17.0
|
specifier: ^8.1.3
|
||||||
version: 7.17.0(react@18.3.1)
|
version: 8.1.3(react@18.3.1)
|
||||||
'@mantine/modals':
|
'@mantine/modals':
|
||||||
specifier: ^7.17.0
|
specifier: ^8.1.3
|
||||||
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)
|
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)
|
||||||
'@mantine/notifications':
|
'@mantine/notifications':
|
||||||
specifier: ^7.17.0
|
specifier: ^8.1.3
|
||||||
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)
|
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)
|
||||||
'@mantine/spotlight':
|
'@mantine/spotlight':
|
||||||
specifier: ^7.17.0
|
specifier: ^8.1.3
|
||||||
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)
|
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)
|
||||||
'@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,6 +534,9 @@ 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
|
||||||
@@ -2503,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@7.17.0':
|
'@mantine/core@8.1.3':
|
||||||
resolution: {integrity: sha512-AU5UFewUNzBCUXIq5Jk6q402TEri7atZW61qHW6P0GufJ2W/JxGHRvgmHOVHTVIcuWQRCt9SBSqZoZ/vHs9LhA==}
|
resolution: {integrity: sha512-2WOPC8GSN3MApet0MccSn6LaXRhcP6SVtZnbuHoqJ/atrfK7kLE66ILr4OXov7JAj1ASJ4Xk0bOXmu5fBExAvQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@mantine/hooks': 7.17.0
|
'@mantine/hooks': 8.1.3
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
react-dom: ^18.x || ^19.x
|
react-dom: ^18.x || ^19.x
|
||||||
|
|
||||||
'@mantine/form@7.17.0':
|
'@mantine/form@8.1.3':
|
||||||
resolution: {integrity: sha512-LONdeb+wL8h9fvyQ339ZFLxqrvYff+b+H+kginZhnr45OBTZDLXNVAt/YoKVFEkynF9WDJjdBVrXKcOZvPgmrA==}
|
resolution: {integrity: sha512-OoSVv2cyjKRZ+C4Rw63VsnO3qjKGZHJkd6DSJTVRQHXfDr10hxmC5yXgxGKsxGQ+xFd4ZCdtzPUU2BoWbHfZAA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
|
|
||||||
'@mantine/hooks@7.17.0':
|
'@mantine/hooks@8.1.3':
|
||||||
resolution: {integrity: sha512-vo3K49mLy1nJ8LQNb5KDbJgnX0xwt3Y8JOF3ythjB5LEFMptdLSSgulu64zj+QHtzvffFCsMb05DbTLLpVP/JQ==}
|
resolution: {integrity: sha512-yL4SbyYjrkmtIhscswajNz9RL0iO2+V8CMtOi0KISch2rPNvTAJNumFuZaXgj4UHeDc0JQYSmcZ+EW8NGm7xcQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
|
|
||||||
'@mantine/modals@7.17.0':
|
'@mantine/modals@8.1.3':
|
||||||
resolution: {integrity: sha512-4sfiFxIxMxfm2RH4jXMN+cr8tFS5AexXG4TY7TRN/ySdkiWtFVvDe5l2/KRWWeWwDUb7wQhht8Ompj5KtexlEA==}
|
resolution: {integrity: sha512-PTLquO7OuYHrbezhjqf1fNwxU1NKZJmNYDOll6RHp6FPQ80xCVWQqVFsj3R8XsLluu2b5ygTYi+avWrUr1GvGg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@mantine/core': 7.17.0
|
'@mantine/core': 8.1.3
|
||||||
'@mantine/hooks': 7.17.0
|
'@mantine/hooks': 8.1.3
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
react-dom: ^18.x || ^19.x
|
react-dom: ^18.x || ^19.x
|
||||||
|
|
||||||
'@mantine/notifications@7.17.0':
|
'@mantine/notifications@8.1.3':
|
||||||
resolution: {integrity: sha512-xejr1WW02NrrrE4HPDoownILJubcjLLwCDeTk907ZeeHKBEPut7RukEq6gLzOZBhNhKdPM+vCM7GcbXdaLZq/Q==}
|
resolution: {integrity: sha512-Xy6f/l1yLTo77hz8X80sOuY+HW80e1rn8ucygx9TAexK5+XtyriOv26TQ3EJ6Ej5jlchtZRFEUJ4tJGRWjGCNg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@mantine/core': 7.17.0
|
'@mantine/core': 8.1.3
|
||||||
'@mantine/hooks': 7.17.0
|
'@mantine/hooks': 8.1.3
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
react-dom: ^18.x || ^19.x
|
react-dom: ^18.x || ^19.x
|
||||||
|
|
||||||
'@mantine/spotlight@7.17.0':
|
'@mantine/spotlight@8.1.3':
|
||||||
resolution: {integrity: sha512-T7xfXxyDg2fxf7qvKwBozQ8HBnTQ2GRCIIoeYdAoiHoFQUS7NbBAnqrjdr5iYZpJqyLRXn8uFI7DX1Zdzd6/PQ==}
|
resolution: {integrity: sha512-GhJbSoUdcALGSMLC/zjVVncRDyvxwxjtlzFeHLuY0Dgkgj+60x3tnzAulDrqYVhLMk7fGyex22VV/Xwl7mG1+Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@mantine/core': 7.17.0
|
'@mantine/core': 8.1.3
|
||||||
'@mantine/hooks': 7.17.0
|
'@mantine/hooks': 8.1.3
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
react-dom: ^18.x || ^19.x
|
react-dom: ^18.x || ^19.x
|
||||||
|
|
||||||
'@mantine/store@7.17.0':
|
'@mantine/store@8.1.3':
|
||||||
resolution: {integrity: sha512-nhWRYRLqvAjrD/ApKCXxuHyTWg2b5dC06Z5gmO8udj4pBgndNf9nmCl+Of90H6bgOa56moJA7UQyXoF1SfxqVg==}
|
resolution: {integrity: sha512-rO72LfSJqSNCwufqJxTWiHMyOR6sR3mqAcnBcw/f5aTvyOYoHZzlm4q4+TL8/2vYGRVsr9YM2Ez6HQ1vk/RR8g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
|
|
||||||
@@ -7637,6 +7640,10 @@ 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'}
|
||||||
@@ -8265,8 +8272,8 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
react-textarea-autosize@8.5.6:
|
react-textarea-autosize@8.5.9:
|
||||||
resolution: {integrity: sha512-aT3ioKXMa8f6zHYGebhbdMD2L00tKeRX1zuVuDx9YQK/JLLRSaSxq3ugECEmUB9z2kvk6bFSIoRHLkkUv0RJiw==}
|
resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==}
|
||||||
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
|
||||||
@@ -9567,6 +9574,10 @@ 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'}
|
||||||
@@ -12228,55 +12239,55 @@ snapshots:
|
|||||||
|
|
||||||
'@lukeed/ms@2.0.2': {}
|
'@lukeed/ms@2.0.2': {}
|
||||||
|
|
||||||
'@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/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)':
|
||||||
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': 7.17.0(react@18.3.1)
|
'@mantine/hooks': 8.1.3(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.6(@types/react@18.3.12)(react@18.3.1)
|
react-textarea-autosize: 8.5.9(@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@7.17.0(react@18.3.1)':
|
'@mantine/form@8.1.3(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@7.17.0(react@18.3.1)':
|
'@mantine/hooks@8.1.3(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
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)':
|
'@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)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@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/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': 7.17.0(react@18.3.1)
|
'@mantine/hooks': 8.1.3(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@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@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)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@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/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': 7.17.0(react@18.3.1)
|
'@mantine/hooks': 8.1.3(react@18.3.1)
|
||||||
'@mantine/store': 7.17.0(react@18.3.1)
|
'@mantine/store': 8.1.3(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@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@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)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@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/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': 7.17.0(react@18.3.1)
|
'@mantine/hooks': 8.1.3(react@18.3.1)
|
||||||
'@mantine/store': 7.17.0(react@18.3.1)
|
'@mantine/store': 8.1.3(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@7.17.0(react@18.3.1)':
|
'@mantine/store@8.1.3(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
@@ -18193,6 +18204,10 @@ 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
|
||||||
@@ -18849,7 +18864,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.12
|
'@types/react': 18.3.12
|
||||||
|
|
||||||
react-textarea-autosize@8.5.6(@types/react@18.3.12)(react@18.3.1):
|
react-textarea-autosize@8.5.9(@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
|
||||||
@@ -20183,6 +20198,8 @@ 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:
|
||||||
|
|||||||
Reference in New Issue
Block a user