Compare commits

..

1 Commits

Author SHA1 Message Date
f9bd5e7b57 feat: ukrainian language support 2025-06-11 15:29:53 -07:00
63 changed files with 471 additions and 2013 deletions

View File

@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.21.0",
"version": "0.20.4",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -24,8 +24,7 @@
"@mantine/spotlight": "^7.17.0",
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.10.3",
"alfaaz": "^1.1.0",
"@tiptap/extension-character-count": "^2.14.0",
"axios": "^1.9.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
@ -41,7 +40,6 @@
"lowlight": "^3.3.0",
"mermaid": "^11.6.0",
"mitt": "^3.0.1",
"posthog-js": "^1.255.1",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.15",

View File

@ -354,9 +354,6 @@
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
"New update": "New update",
"{{latestVersion}} is available": "{{latestVersion}} is available",
"Default page edit mode": "Default page edit mode",
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
"Reading": "Reading"
"Delete member": "Delete member",
"Member deleted successfully": "Member deleted successfully",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",
@ -389,15 +386,5 @@
"Failed to share page": "Failed to share page",
"Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied 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"
"Page copied successfully": "Page copied successfully"
}

View File

@ -1,8 +1,6 @@
import { UserProvider } from "@/features/user/user-provider.tsx";
import { Outlet } from "react-router-dom";
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
import { isCloud } from "@/lib/config.ts";
export default function Layout() {
return (
@ -10,7 +8,6 @@ export default function Layout() {
<GlobalAppShell>
<Outlet />
</GlobalAppShell>
{isCloud() && <PosthogUser />}
</UserProvider>
);
}

View File

@ -30,12 +30,12 @@ export default function BillingDetails() {
>
Plan
</Text>
<Text fw={700} fz="lg" tt="capitalize">
{plans.find(
(plan) => plan.productId === billing.stripeProductId,
)?.name ||
billing.planName ||
"Standard"}
<Text fw={700} fz="lg">
{
plans.find(
(plan) => plan.productId === billing.stripeProductId,
)?.name
}
</Text>
</div>
</Group>
@ -112,58 +112,18 @@ export default function BillingDetails() {
fz="xs"
className={classes.label}
>
Cost
Total
</Text>
<Text fw={700} fz="lg">
{(billing.amount / 100) * billing.quantity}{" "}
{billing.currency.toUpperCase()}
</Text>
<Text c="dimmed" fz="sm">
${billing.amount / 100} /user/{billing.interval}
</Text>
{billing.billingScheme === "tiered" && (
<>
<Text fw={700} fz="lg">
${billing.amount / 100} {billing.currency.toUpperCase()}
</Text>
<Text c="dimmed" fz="sm">
per {billing.interval}
</Text>
</>
)}
{billing.billingScheme !== "tiered" && (
<>
<Text fw={700} fz="lg">
{(billing.amount / 100) * billing.quantity}{" "}
{billing.currency.toUpperCase()}
</Text>
<Text c="dimmed" fz="sm">
${billing.amount / 100} /user/{billing.interval}
</Text>
</>
)}
</div>
</Group>
</Paper>
{billing.billingScheme === "tiered" && billing.tieredUpTo && (
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Current Tier
</Text>
<Text fw={700} fz="lg">
For {billing.tieredUpTo} users
</Text>
{/*billing.tieredFlatAmount && (
<Text c="dimmed" fz="sm">
</Text>
)*/}
</div>
</Group>
</Paper>
)}
</SimpleGrid>
</div>
);

View File

@ -2,28 +2,24 @@ import {
Button,
Card,
List,
SegmentedControl,
ThemeIcon,
Title,
Text,
Group,
Select,
Container,
Stack,
Badge,
Flex,
Switch,
} from "@mantine/core";
import { useState } from "react";
import { IconCheck } from "@tabler/icons-react";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
export default function BillingPlans() {
const { data: plans } = useBillingPlans();
const [isAnnual, setIsAnnual] = useState(true);
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
null,
);
const [interval, setInterval] = useState("yearly");
if (!plans) {
return null;
}
const handleCheckout = async (priceId: string) => {
try {
@ -36,153 +32,84 @@ export default function BillingPlans() {
}
};
if (!plans || plans.length === 0) {
return null;
}
const firstPlan = plans[0];
// Set initial tier value if not set
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
return null;
}
if (!selectedTierValue) {
return null;
}
const selectData = firstPlan.pricingTiers
.filter((tier) => !tier.custom)
.map((tier, index) => {
const prevMaxUsers =
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
return {
value: tier.upTo.toString(),
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
};
});
return (
<Container size="xl" py="xl">
{/* Controls Section */}
<Stack gap="xl" mb="md">
{/* Team Size and Billing Controls */}
<Group justify="center" align="center" gap="sm">
<Select
label="Team size"
description="Select the number of users"
value={selectedTierValue}
onChange={setSelectedTierValue}
data={selectData}
w={250}
size="md"
allowDeselect={false}
/>
<Group justify="center" p="xl">
{plans.map((plan) => {
const price =
interval === "monthly" ? plan.price.monthly : plan.price.yearly;
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId;
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12;
<Group justify="center" align="start">
<Flex justify="center" gap="md" align="center">
<Text size="md">Monthly</Text>
<Switch
defaultChecked={isAnnual}
onChange={(event) => setIsAnnual(event.target.checked)}
size="sm"
/>
<Text size="md">
Annually
<Badge component="span" variant="light" color="blue">
15% OFF
</Badge>
</Text>
</Flex>
</Group>
</Group>
</Stack>
return (
<Card
key={plan.name}
withBorder
radius="md"
shadow="sm"
p="xl"
w={300}
>
<SegmentedControl
value={interval}
onChange={setInterval}
fullWidth
data={[
{ label: "Monthly", value: "monthly" },
{ label: "Yearly (25% OFF)", value: "yearly" },
]}
/>
{/* Plans Grid */}
<Group justify="center" gap="lg" align="stretch">
{plans.map((plan, index) => {
const tieredPlan = plan;
const planSelectedTier =
tieredPlan.pricingTiers.find(
(tier) => tier.upTo.toString() === selectedTierValue,
) || tieredPlan.pricingTiers[0];
const price = isAnnual
? planSelectedTier.yearly
: planSelectedTier.monthly;
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
return (
<Card
key={plan.name}
withBorder
radius="lg"
shadow="sm"
p="xl"
w={350}
miw={300}
style={{
position: "relative",
}}
>
<Stack gap="lg">
{/* Plan Header */}
<Stack gap="xs">
<Title order={3} size="h4">
{plan.name}
</Title>
{plan.description && (
<Text size="sm" c="dimmed">
{plan.description}
</Text>
)}
</Stack>
{/* Pricing */}
<Stack gap="xs">
<Group align="baseline" gap="xs">
<Title order={1} size="h1">
${isAnnual ? (price / 12).toFixed(0) : price}
</Title>
<Text size="lg" c="dimmed">
per {isAnnual ? "month" : "month"}
</Text>
</Group>
{isAnnual && (
<Text size="sm" c="dimmed">
Billed annually
</Text>
)}
<Text size="md" fw={500}>
For {planSelectedTier.upTo} users
<Title order={3} ta="center" mt="sm" mb="xs">
{plan.name}
</Title>
<Text ta="center" size="lg" fw={700}>
{interval === "monthly" && (
<>
${price}{" "}
<Text span size="sm" fw={500} c="dimmed">
/user/month
</Text>
</Stack>
</>
)}
{interval === "yearly" && (
<>
${yearlyMonthPrice}{" "}
<Text span size="sm" fw={500} c="dimmed">
/user/month
</Text>
</>
)}
<br/>
<Text span ta="center" size="md" fw={500} c="dimmed">
billed {interval}
</Text>
</Text>
{/* CTA Button */}
<Button onClick={() => handleCheckout(priceId)} fullWidth>
Upgrade
</Button>
<Card.Section mt="lg">
<Button onClick={() => handleCheckout(priceId)} fullWidth>
Subscribe
</Button>
</Card.Section>
{/* Features */}
<List
spacing="xs"
size="sm"
icon={
<ThemeIcon size={20} radius="xl">
<IconCheck size={14} />
</ThemeIcon>
}
>
{plan.features.map((feature, featureIndex) => (
<List.Item key={featureIndex}>{feature}</List.Item>
))}
</List>
</Stack>
</Card>
);
})}
</Group>
</Container>
<Card.Section mt="md">
<List
spacing="xs"
size="sm"
center
icon={
<ThemeIcon variant="light" size={24} radius="xl">
<IconCheck size={16} />
</ThemeIcon>
}
>
{plan.features.map((feature, index) => (
<List.Item key={index}>{feature}</List.Item>
))}
</List>
</Card.Section>
</Card>
);
})}
</Group>
);
}

View File

@ -25,11 +25,6 @@ export interface IBilling {
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
billingScheme: string | null;
tieredUpTo: string | null;
tieredFlatAmount: number | null;
tieredUnitAmount: number | null;
planName: string | null;
}
export interface ICheckoutLink {
@ -47,18 +42,9 @@ export interface IBillingPlan {
monthlyId: string;
yearlyId: string;
currency: string;
price?: {
price: {
monthly: string;
yearly: string;
};
features: string[];
billingScheme: string | null;
pricingTiers: PricingTier[];
}
interface PricingTier {
upTo: number;
monthly?: number;
yearly?: number;
custom?: boolean;
}

View File

@ -1,41 +0,0 @@
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
export function PosthogUser() {
const posthog = usePostHog();
const [currentUser] = useAtom(currentUserAtom);
useEffect(() => {
if (currentUser) {
const user = currentUser?.user;
const workspace = currentUser?.workspace;
if (!user || !workspace) return;
posthog?.identify(user.id, {
name: user.name,
email: user.email,
workspaceId: user.workspaceId,
workspaceHostname: workspace.hostname,
lastActiveAt: new Date().toISOString(),
createdAt: user.createdAt,
source: "docmost-app",
});
posthog?.group("workspace", workspace.id, {
name: workspace.name,
hostname: workspace.hostname,
plan: workspace?.plan,
status: workspace.status,
isOnTrial: !!workspace.trialEndAt,
hasStripeCustomerId: !!workspace.stripeCustomerId,
memberCount: workspace.memberCount,
lastActiveAt: new Date().toISOString(),
createdAt: workspace.createdAt,
source: "docmost-app",
});
}
}, [posthog, currentUser]);
return null;
}

View File

@ -18,7 +18,6 @@ import classes from "@/features/auth/components/auth.module.css";
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next";
import SsoLogin from "@/ee/components/sso-login.tsx";
const formSchema = z.object({
name: z.string().trim().min(1),
@ -72,43 +71,39 @@ export function InviteSignUpForm() {
{t("Join the workspace")}
</Title>
<SsoLogin />
<Stack align="stretch" justify="center" gap="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="name"
type="text"
label={t("Name")}
placeholder={t("enter your full name")}
variant="filled"
{...form.getInputProps("name")}
/>
{!invitation.enforceSso && (
<Stack align="stretch" justify="center" gap="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="name"
type="text"
label={t("Name")}
placeholder={t("enter your full name")}
variant="filled"
{...form.getInputProps("name")}
/>
<TextInput
id="email"
type="email"
label={t("Email")}
value={invitation.email}
disabled
variant="filled"
mt="md"
/>
<TextInput
id="email"
type="email"
label={t("Email")}
value={invitation.email}
disabled
variant="filled"
mt="md"
/>
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Sign Up")}
</Button>
</form>
</Stack>
)}
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Sign Up")}
</Button>
</form>
</Stack>
</Box>
</Container>
);

View File

@ -21,7 +21,7 @@ import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({
workspaceName: z.string().trim().max(50).optional(),
workspaceName: z.string().trim().min(3).max(50),
name: z.string().min(1).max(50),
email: z
.string()
@ -60,17 +60,15 @@ export function SetupWorkspaceForm() {
{isCloud() && <SsoCloudSignup />}
<form onSubmit={form.onSubmit(onSubmit)}>
{!isCloud() && (
<TextInput
id="workspaceName"
type="text"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
/>
)}
<TextInput
id="workspaceName"
type="text"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
/>
<TextInput
id="name"

View File

@ -10,7 +10,7 @@ export interface IRegister {
}
export interface ISetupWorkspace {
workspaceName?: string;
workspaceName: string;
name: string;
email: string;
password: string;

View File

@ -12,12 +12,6 @@
padding: 8px;
background: var(--mantine-color-gray-light);
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 {

View File

@ -156,11 +156,13 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
)
}
onClick={() => {
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor.chain().focus().setColor(color || "").run();
}
editor.commands.unsetColor();
name !== "Default" &&
editor
.chain()
.focus()
.setColor(color || "")
.run();
setIsOpen(false);
}}
style={{ border: "none" }}

View File

@ -32,7 +32,7 @@ const schema = z.object({
export default function EmbedView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected, updateAttributes, editor } = props;
const { node, selected, updateAttributes } = props;
const { src, provider } = node.attrs;
const embedUrl = useMemo(() => {
@ -50,10 +50,6 @@ export default function EmbedView(props: NodeViewProps) {
});
async function onSubmit(data: { url: string }) {
if (!editor.isEditable) {
return;
}
if (provider) {
const embedProvider = getEmbedProviderById(provider);
if (embedProvider.id === "iframe") {
@ -89,13 +85,7 @@ export default function EmbedView(props: NodeViewProps) {
</AspectRatio>
</>
) : (
<Popover
width={300}
position="bottom"
withArrow
shadow="md"
disabled={!editor.isEditable}
>
<Popover width={300} position="bottom" withArrow shadow="md">
<Popover.Target>
<Card
radius="md"

View File

@ -1,9 +0,0 @@
import { atom } from "jotai";
type SearchAndReplaceAtomType = {
isOpen: boolean;
};
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
isOpen: false,
});

View File

@ -1,312 +0,0 @@
import {
ActionIcon,
Button,
Dialog,
Flex,
Input,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import {
IconArrowNarrowDown,
IconArrowNarrowUp,
IconLetterCase,
IconReplace,
IconSearch,
IconX,
} from "@tabler/icons-react";
import { useEditor } from "@tiptap/react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { getHotkeyHandler, useToggle } from "@mantine/hooks";
import { useLocation } from "react-router-dom";
import classes from "./search-replace.module.css";
interface PageFindDialogDialogProps {
editor: ReturnType<typeof useEditor>;
editable?: boolean;
}
function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialogProps) {
const { t } = useTranslation();
const [searchText, setSearchText] = useState("");
const [replaceText, setReplaceText] = useState("");
const [pageFindState, setPageFindState] = useAtom(searchAndReplaceStateAtom);
const inputRef = useRef(null);
const [replaceButton, replaceButtonToggle] = useToggle([
{ isReplaceShow: false, color: "gray" },
{ isReplaceShow: true, color: "blue" },
]);
const [caseSensitive, caseSensitiveToggle] = useToggle([
{ isCaseSensitive: false, color: "gray" },
{ isCaseSensitive: true, color: "blue" },
]);
const searchInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(event.target.value);
};
const replaceInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
setReplaceText(event.target.value);
};
const closeDialog = () => {
setSearchText("");
setReplaceText("");
setPageFindState({ isOpen: false });
// Reset replace button state when closing
if (replaceButton.isReplaceShow) {
replaceButtonToggle();
}
// Clear search term in editor
if (editor) {
editor.commands.setSearchTerm("");
}
};
const goToSelection = () => {
if (!editor) return;
const { results, resultIndex } = editor.storage.searchAndReplace;
const position: Range = results[resultIndex];
if (!position) return;
// @ts-ignore
editor.commands.setTextSelection(position);
const element = document.querySelector(".search-result-current");
if (element)
element.scrollIntoView({ behavior: "smooth", block: "center" });
editor.commands.setTextSelection(0);
};
const next = () => {
editor.commands.nextSearchResult();
goToSelection();
};
const previous = () => {
editor.commands.previousSearchResult();
goToSelection();
};
const replace = () => {
editor.commands.setReplaceTerm(replaceText);
editor.commands.replace();
goToSelection();
};
const replaceAll = () => {
editor.commands.setReplaceTerm(replaceText);
editor.commands.replaceAll();
};
useEffect(() => {
editor.commands.setSearchTerm(searchText);
editor.commands.resetIndex();
editor.commands.selectCurrentItem();
}, [searchText]);
const handleOpenEvent = (e) => {
setPageFindState({ isOpen: true });
const selectedText = editor.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to,
);
if (selectedText !== "") {
setSearchText(selectedText);
}
inputRef.current?.focus();
inputRef.current?.select();
};
const handleCloseEvent = (e) => {
closeDialog();
};
useEffect(() => {
!pageFindState.isOpen && closeDialog();
document.addEventListener("openFindDialogFromEditor", handleOpenEvent);
document.addEventListener("closeFindDialogFromEditor", handleCloseEvent);
return () => {
document.removeEventListener("openFindDialogFromEditor", handleOpenEvent);
document.removeEventListener(
"closeFindDialogFromEditor",
handleCloseEvent,
);
};
}, [pageFindState.isOpen]);
useEffect(() => {
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
editor.commands.resetIndex();
goToSelection();
}, [caseSensitive]);
const resultsCount = useMemo(
() =>
searchText.trim() === ""
? ""
: editor?.storage?.searchAndReplace?.results.length > 0
? editor?.storage?.searchAndReplace?.resultIndex +
1 +
"/" +
editor?.storage?.searchAndReplace?.results.length
: t("Not found"),
[
searchText,
editor?.storage?.searchAndReplace?.resultIndex,
editor?.storage?.searchAndReplace?.results.length,
],
);
const location = useLocation();
useEffect(() => {
closeDialog();
}, [location]);
return (
<Dialog
className={classes.findDialog}
opened={pageFindState.isOpen}
size="lg"
radius="md"
w={"auto"}
position={{ top: 90, right: 50 }}
withBorder
transitionProps={{ transition: "slide-down" }}
>
<Stack gap="xs">
<Flex align="center" gap="xs">
<Input
ref={inputRef}
placeholder={t("Find")}
leftSection={<IconSearch size={16} />}
rightSection={
<Text size="xs" ta="right">
{resultsCount}
</Text>
}
rightSectionWidth="70"
rightSectionPointerEvents="all"
size="xs"
w={220}
onChange={searchInputEvent}
value={searchText}
autoFocus
onKeyDown={getHotkeyHandler([
["Enter", next],
["shift+Enter", previous],
["alt+C", caseSensitiveToggle],
//@ts-ignore
...(editable ? [["alt+R", replaceButtonToggle]] : []),
])}
/>
<ActionIcon.Group>
<Tooltip label={t("Previous match (Shift+Enter)")}>
<ActionIcon variant="subtle" color="gray" onClick={previous}>
<IconArrowNarrowUp
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
<Tooltip label={t("Next match (Enter)")}>
<ActionIcon variant="subtle" color="gray" onClick={next}>
<IconArrowNarrowDown
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
<Tooltip label={t("Match case (Alt+C)")}>
<ActionIcon
variant="subtle"
color={caseSensitive.color}
onClick={() => caseSensitiveToggle()}
>
<IconLetterCase
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
{editable && (
<Tooltip label={t("Replace")}>
<ActionIcon
variant="subtle"
color={replaceButton.color}
onClick={() => replaceButtonToggle()}
>
<IconReplace
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
)}
<Tooltip label={t("Close (Escape)")}>
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}>
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</Flex>
{replaceButton.isReplaceShow && editable && (
<Flex align="center" gap="xs">
<Input
placeholder={t("Replace")}
leftSection={<IconReplace size={16} />}
rightSection={<div></div>}
rightSectionPointerEvents="all"
size="xs"
w={180}
autoFocus
onChange={replaceInputEvent}
value={replaceText}
onKeyDown={getHotkeyHandler([
["Enter", replace],
["ctrl+alt+Enter", replaceAll],
])}
/>
<ActionIcon.Group>
<Tooltip label={t("Replace (Enter)")}>
<Button
size="xs"
variant="subtle"
color="gray"
onClick={replace}
>
{t("Replace")}
</Button>
</Tooltip>
<Tooltip label={t("Replace all (Ctrl+Alt+Enter)")}>
<Button
size="xs"
variant="subtle"
color="gray"
onClick={replaceAll}
>
{t("Replace all")}
</Button>
</Tooltip>
</ActionIcon.Group>
</Flex>
)}
</Stack>
</Dialog>
);
}
export default SearchAndReplaceDialog;

View File

@ -1,10 +0,0 @@
.findDialog{
@media print {
display: none;
}
}
.findDialog div[data-position="right"].mantine-Input-section {
justify-content: right;
padding-right: 8px;
}

View File

@ -36,7 +36,6 @@ import {
Drawio,
Excalidraw,
Embed,
SearchAndReplace,
Mention,
} from "@docmost/editor-ext";
import {
@ -74,7 +73,6 @@ import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command";
import { CharacterCount } from "@tiptap/extension-character-count";
import { countWords } from "alfaaz";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@ -215,25 +213,7 @@ export const mainExtensions = [
MarkdownClipboard.configure({
transformPastedText: true,
}),
CharacterCount.configure({
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(),
CharacterCount
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
@ -249,4 +229,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
color: randomElement(userColors),
},
}),
];
];

View File

@ -42,11 +42,7 @@ export function FullEditor({
spaceSlug={spaceSlug}
editable={editable}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
/>
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
</Container>
);
}

View File

@ -1,5 +1,11 @@
import "@/features/editor/styles/index.css";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
@ -39,7 +45,6 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
@ -47,7 +52,6 @@ import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
interface PageEditorProps {
@ -67,11 +71,7 @@ export default function PageEditor({
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const ydocRef = useRef<Y.Doc | null>(null);
if (!ydocRef.current) {
ydocRef.current = new Y.Doc();
}
const ydoc = ydocRef.current;
const ydoc = useMemo(() => new Y.Doc(), [pageId]);
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@ -85,126 +85,67 @@ export default function PageEditor({
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
const localProvider = useMemo(() => {
const provider = new IndexeddbPersistence(documentName, ydoc);
const localProvider = providersRef.current?.local;
const remoteProvider = providersRef.current?.remote;
provider.on("synced", () => {
setLocalSynced(true);
});
// Track when collaborative provider is ready and synced
const [collabReady, setCollabReady] = useState(false);
useEffect(() => {
if (
remoteProvider?.status === WebSocketStatus.Connected &&
isLocalSynced &&
isRemoteSynced
) {
setCollabReady(true);
}
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
return provider;
}, [pageId, ydoc]);
useEffect(() => {
if (!providersRef.current) {
const local = new IndexeddbPersistence(documentName, ydoc);
local.on("synced", () => setLocalSynced(true));
const remote = new HocuspocusProvider({
name: documentName,
url: collaborationURL,
document: ydoc,
token: collabQuery?.token,
connect: true,
preserveConnection: false,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
remote.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
remote.connect();
}, 100);
}
});
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
});
remote.on("synced", () => setRemoteSynced(true));
remote.on("disconnect", () => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
providersRef.current = { local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
const remoteProvider = useMemo(() => {
const provider = new HocuspocusProvider({
name: documentName,
url: collaborationURL,
document: ydoc,
token: collabQuery?.token,
connect: false,
preserveConnection: false,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken();
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
});
provider.on("synced", () => {
setRemoteSynced(true);
});
provider.on("disconnect", () => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
return provider;
}, [ydoc, pageId, collabQuery?.token]);
useLayoutEffect(() => {
remoteProvider.connect();
return () => {
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
setRemoteSynced(false);
setLocalSynced(false);
remoteProvider.destroy();
localProvider.destroy();
};
}, [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
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const remoteProvider = providersRef.current.remote;
if (
isIdle &&
documentState === "hidden" &&
remoteProvider.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
remoteProvider.status === WebSocketStatus.Disconnected
) {
resetIdle();
remoteProvider.connect();
setTimeout(() => setIsCollabReady(true), 500);
}
}, [isIdle, documentState, providersReady, resetIdle]);
}, [remoteProvider, localProvider]);
const extensions = useMemo(() => {
if (!remoteProvider || !currentUser?.user) return mainExtensions;
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [remoteProvider, currentUser?.user]);
}, [ydoc, pageId, remoteProvider, currentUser?.user]);
const editor = useEditor(
{
@ -258,7 +199,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson);
},
},
[pageId, editable, remoteProvider],
[pageId, editable, remoteProvider?.status],
);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
@ -311,6 +252,29 @@ export default function PageEditor({
}
}, [remoteProvider?.status]);
useEffect(() => {
if (
isIdle &&
documentState === "hidden" &&
remoteProvider?.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
remoteProvider?.status === WebSocketStatus.Disconnected
) {
resetIdle();
remoteProvider.connect();
setTimeout(() => {
setIsCollabReady(true);
}, 600);
}
}, [isIdle, documentState, remoteProvider]);
const isSynced = isLocalSynced && isRemoteSynced;
useEffect(() => {
@ -326,54 +290,11 @@ export default function PageEditor({
return () => clearTimeout(collabReadyTimeout);
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
useEffect(() => {
// Only honor user default page edit mode preference and permissions
if (editor) {
if (userPageEditMode && editable) {
if (userPageEditMode === PageEditMode.Edit) {
editor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
editor.setEditable(false);
}
} else {
editor.setEditable(false);
}
}
}, [userPageEditMode, editor, editable]);
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
remoteProvider?.status === WebSocketStatus.Connected
) {
hasConnectedOnceRef.current = true;
setShowStatic(false);
}
}, [remoteProvider?.status]);
if (showStatic) {
return (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
/>
);
}
return (
<div style={{ position: "relative" }}>
return isCollabReady ? (
<div>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
{editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && editor.isEditable && (
<div>
<EditorBubbleMenu editor={editor} />
@ -387,12 +308,21 @@ export default function PageEditor({
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</div>
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
</div>
<div
onClick={() => editor.commands.focus("end")}
style={{ paddingBottom: "20vh" }}
></div>
</div>
) : (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
></EditorProvider>
);
}

View File

@ -71,12 +71,4 @@
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
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);
}
}
}

View File

@ -1,9 +0,0 @@
.search-result{
background: #ffff65;
color: #212529;
}
.search-result-current{
background: #ffc266 !important;
color: #212529;
}

View File

@ -9,5 +9,5 @@
@import "./media.css";
@import "./code.css";
@import "./print.css";
@import "./find.css";
@import "./mention.css";

View File

@ -10,11 +10,8 @@ import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
import {
updatePageData,
useUpdateTitlePageMutation,
} from "@/features/page/queries/page-query";
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
import { useDebouncedCallback } from "@mantine/hooks";
import { useAtom } from "jotai";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { History } from "@tiptap/extension-history";
@ -24,8 +21,6 @@ import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export interface TitleEditorProps {
pageId: string;
@ -43,16 +38,12 @@ export function TitleEditor({
editable,
}: TitleEditorProps) {
const { t } = useTranslation();
const { mutateAsync: updateTitlePageMutationAsync } =
useUpdateTitlePageMutation();
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom);
const emit = useQueryEmit();
const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const [currentUser] = useAtom(currentUserAtom);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const titleEditor = useEditor({
extensions: [
@ -112,12 +103,7 @@ export function TitleEditor({
spaceId: page.spaceId,
entity: ["pages"],
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;
@ -150,30 +136,9 @@ export function TitleEditor({
};
}, [pageId]);
useEffect(() => {
// honor user default page edit mode preference
if (userPageEditMode && titleEditor && editable) {
if (userPageEditMode === PageEditMode.Edit) {
titleEditor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
titleEditor.setEditable(false);
}
}
}, [userPageEditMode, titleEditor, editable]);
const openSearchDialog = () => {
const event = new CustomEvent("openFindDialogFromEditor", {});
document.dispatchEvent(event);
};
function handleTitleKeyDown(event: any) {
function handleTitleKeyDown(event) {
if (!titleEditor || !pageEditor || event.shiftKey) return;
// Prevent focus shift when IME composition is active
// `keyCode === 229` is added to support Safari where `isComposing` may not be reliable
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
return;
const { key } = event;
const { $head } = titleEditor.state.selection;
@ -187,16 +152,5 @@ export function TitleEditor({
}
}
return (
<EditorContent
editor={titleEditor}
onKeyDown={(event) => {
// First handle the search hotkey
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
// Then handle other key events
handleTitleKeyDown(event);
}}
/>
);
return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
}

View File

@ -1,30 +1,24 @@
.breadcrumbs {
display: flex;
align-items: center;
overflow: hidden;
flex-wrap: nowrap;
a {
color: var(--mantine-color-default-color);
line-height: inherit;
}
.mantine-Breadcrumbs-breadcrumb {
min-width: 1px;
display: flex;
align-items: center;
overflow: hidden;
}
flex-wrap: nowrap;
a {
color: var(--mantine-color-default-color);
line-height: inherit;
}
.mantine-Breadcrumbs-breadcrumb {
min-width: 1px;
overflow: hidden;
}
}
.truncatedText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.breadcrumbDiv {
overflow: hidden;
@media (max-width: $mantine-breakpoint-sm) {
overflow: visible;
}
}

View File

@ -161,7 +161,7 @@ export default function Breadcrumb() {
};
return (
<div className={classes.breadcrumbDiv}>
<div style={{ overflow: "hidden" }}>
{breadcrumbNodes && (
<Breadcrumbs className={classes.breadcrumbs}>
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}

View File

@ -9,7 +9,6 @@ import {
IconList,
IconMessage,
IconPrinter,
IconSearch,
IconTrash,
IconWifiOff,
} from "@tabler/icons-react";
@ -17,12 +16,7 @@ import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import {
getHotkeyHandler,
useClipboard,
useDisclosure,
useHotkeys,
} from "@mantine/hooks";
import { useClipboard, useDisclosure } from "@mantine/hooks";
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@ -38,9 +32,7 @@ import {
pageEditorAtom,
yjsConnectionStatusAtom,
} 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 { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import ShareModal from "@/features/share/components/share-modal.tsx";
@ -53,26 +45,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const toggleAside = useToggleAside();
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 (
<>
{yjsConnectionStatus === "disconnected" && (
@ -87,8 +59,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</Tooltip>
)}
{!readOnly && <PageStateSegmentedControl size="xs" />}
<ShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow>

View File

@ -1,27 +1,15 @@
.header {
height: 45px;
background-color: var(--mantine-color-body);
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
position: fixed;
z-index: 99;
top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
height: 45px;
background-color: var(--mantine-color-body);
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
position: fixed;
z-index: 99;
top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
@media (max-width: $mantine-breakpoint-sm) {
padding-left: var(--mantine-spacing-xs);
padding-right: var(--mantine-spacing-xs);
}
@media print {
display: none;
}
}
.group {
@media (max-width: $mantine-breakpoint-sm) {
gap: var(--mantine-spacing-sm);
padding-inline: 0 !important;
}
@media print {
display: none;
}
}

View File

@ -9,10 +9,10 @@ interface Props {
export default function PageHeader({ readOnly }: Props) {
return (
<div className={classes.header}>
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
<Breadcrumb />
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
<PageHeaderMenu readOnly={readOnly} />
</Group>
</Group>

View File

@ -65,7 +65,6 @@ export interface IPageInput {
icon: string;
coverPhoto: string;
position: string;
isLocked: boolean;
}
export interface IExportPageParams {

View File

@ -11,7 +11,7 @@ import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(1).max(40),
name: z.string().min(2).max(40),
});
type FormValues = z.infer<typeof formSchema>;

View File

@ -1,65 +0,0 @@
import { Group, Text, MantineSize, SegmentedControl } from "@mantine/core";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateUser } from "@/features/user/services/user-service.ts";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export default function PageStatePref() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Default page edit mode")}</Text>
<Text size="sm" c="dimmed">
{t("Choose your preferred page edit mode. Avoid accidental edits.")}
</Text>
</div>
<PageStateSegmentedControl />
</Group>
);
}
interface PageStateSegmentedControlProps {
size?: MantineSize;
}
export function PageStateSegmentedControl({
size,
}: PageStateSegmentedControlProps) {
const { t } = useTranslation();
const [user, setUser] = useAtom(userAtom);
const pageEditMode =
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const [value, setValue] = useState(pageEditMode);
const handleChange = useCallback(
async (value: string) => {
const updatedUser = await updateUser({ pageEditMode: value });
setValue(value);
setUser(updatedUser);
},
[user, setUser],
);
useEffect(() => {
if (pageEditMode !== value) {
setValue(pageEditMode);
}
}, [pageEditMode, value]);
return (
<SegmentedControl
size={size}
value={value}
onChange={handleChange}
data={[
{ label: t("Edit"), value: PageEditMode.Edit },
{ label: t("Read"), value: PageEditMode.Read },
]}
/>
);
}

View File

@ -19,7 +19,6 @@ export interface IUser {
deactivatedAt: Date;
deletedAt: Date;
fullPageWidth: boolean; // used for update
pageEditMode: string; // used for update
}
export interface ICurrentUser {
@ -30,11 +29,5 @@ export interface ICurrentUser {
export interface IUserSettings {
preferences: {
fullPageWidth: boolean;
pageEditMode: string;
};
}
export enum PageEditMode {
Read = "read",
Edit = "edit",
}
}

View File

@ -11,7 +11,7 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(1),
name: z.string().min(4),
});
type FormValues = z.infer<typeof formSchema>;

View File

@ -173,7 +173,7 @@ export function useRevokeInvitationMutation() {
export function useGetInvitationQuery(
invitationId: string,
): UseQueryResult<IInvitation, Error> {
): UseQueryResult<any, Error> {
return useQuery({
queryKey: ["invitations", invitationId],
queryFn: () => getInvitationById({ invitationId }),

View File

@ -12,7 +12,6 @@ export interface IWorkspace {
settings: any;
status: string;
enforceSso: boolean;
stripeCustomerId: string;
billingEmail: string;
trialEndAt: Date;
createdAt: Date;
@ -36,7 +35,6 @@ export interface IInvitation {
workspaceId: string;
invitedById: string;
createdAt: Date;
enforceSso: boolean;
}
export interface IInvitationLink {

View File

@ -83,18 +83,6 @@ export function getBillingTrialDays() {
return getConfigValue("BILLING_TRIAL_DAYS");
}
export function getPostHogHost() {
return getConfigValue("POSTHOG_HOST");
}
export function isPostHogEnabled(): boolean {
return Boolean(getPostHogHost() && getPostHogKey());
}
export function getPostHogKey() {
return getConfigValue("POSTHOG_KEY");
}
function getConfigValue(key: string, defaultValue: string = undefined): string {
const rawValue = import.meta.env.DEV
? process?.env?.[key]

View File

@ -3,7 +3,7 @@ import "@mantine/spotlight/styles.css";
import "@mantine/notifications/styles.css";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { mantineCssResolver, theme } from "@/theme";
import { mantineCssResolver, theme } from '@/theme';
import { MantineProvider } from "@mantine/core";
import { BrowserRouter } from "react-router-dom";
import { ModalsProvider } from "@mantine/modals";
@ -11,14 +11,6 @@ import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HelmetProvider } from "react-helmet-async";
import "./i18n";
import { PostHogProvider } from "posthog-js/react";
import {
getPostHogHost,
getPostHogKey,
isCloud,
isPostHogEnabled,
} from "@/lib/config.ts";
import posthog from "posthog-js";
export const queryClient = new QueryClient({
defaultOptions: {
@ -31,17 +23,9 @@ export const queryClient = new QueryClient({
},
});
if (isCloud() && isPostHogEnabled) {
posthog.init(getPostHogKey(), {
api_host: getPostHogHost(),
defaults: "2025-05-24",
disable_session_recording: true,
capture_pageleave: false,
});
}
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement,
document.getElementById("root") as HTMLElement
);
root.render(
@ -51,12 +35,10 @@ root.render(
<QueryClientProvider client={queryClient}>
<Notifications position="bottom-center" limit={3} />
<HelmetProvider>
<PostHogProvider client={posthog}>
<App />
</PostHogProvider>
<App />
</HelmetProvider>
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
</BrowserRouter>,
</BrowserRouter>
);

View File

@ -12,11 +12,6 @@ import {
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import { useTranslation } from "react-i18next";
import React from "react";
const MemoizedFullEditor = React.memo(FullEditor);
const MemoizedPageHeader = React.memo(PageHeader);
const MemoizedHistoryModal = React.memo(HistoryModal);
export default function Page() {
const { t } = useTranslation();
@ -54,14 +49,14 @@ export default function Page() {
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
</Helmet>
<MemoizedPageHeader
<PageHeader
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
/>
<MemoizedFullEditor
<FullEditor
key={page.id}
pageId={page.id}
title={page.title}
@ -73,7 +68,7 @@ export default function Page() {
SpaceCaslSubject.Page,
)}
/>
<MemoizedHistoryModal pageId={page.id} />
<HistoryModal pageId={page.id} />
</div>
)
);

View File

@ -2,7 +2,6 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import AccountLanguage from "@/features/user/components/account-language.tsx";
import AccountTheme from "@/features/user/components/account-theme.tsx";
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import PageEditPref from "@/features/user/components/page-state-pref";
import { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core";
import { Helmet } from "react-helmet-async";
@ -29,10 +28,6 @@ export default function AccountPreferences() {
<Divider my={"md"} />
<PageWidthPref />
<Divider my={"md"} />
<PageEditPref />
</>
);
}

View File

@ -14,8 +14,6 @@ export default defineConfig(({ mode }) => {
SUBDOMAIN_HOST,
COLLAB_URL,
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
} = loadEnv(mode, envPath, "");
return {
@ -29,8 +27,6 @@ export default defineConfig(({ mode }) => {
SUBDOMAIN_HOST,
COLLAB_URL,
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
},
APP_VERSION: JSON.stringify(process.env.npm_package_version),
},

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.21.0",
"version": "0.20.4",
"description": "",
"author": "",
"private": true,

View File

@ -16,12 +16,6 @@ export async function comparePasswordHash(
return bcrypt.compare(plainPassword, passwordHash);
}
export function generateRandomSuffixNumbers(length: number) {
return Math.random()
.toFixed(length)
.substring(2, 2 + length);
}
export type RedisConfig = {
host: string;
port: number;

View File

@ -1,9 +1,11 @@
import {
BadRequestException,
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
@ -21,6 +23,7 @@ import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { PasswordResetDto } from './dto/password-reset.dto';
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
import { FastifyReply } from 'fastify';
import { addDays } from 'date-fns';
import { validateSsoEnforcement } from './auth.util';
@Controller('auth')
@ -122,7 +125,7 @@ export class AuthController {
res.setCookie('authToken', token, {
httpOnly: true,
path: '/',
expires: this.environmentService.getCookieExpiresIn(),
expires: addDays(new Date(), 30),
secure: this.environmentService.isHttps(),
});
}

View File

@ -6,16 +6,3 @@ export function validateSsoEnforcement(workspace: Workspace) {
throw new BadRequestException('This workspace has enforced SSO login.');
}
}
export function validateAllowedEmail(userEmail: string, workspace: Workspace) {
const emailParts = userEmail.split('@');
const emailDomain = emailParts[1].toLowerCase();
if (
workspace.emailDomains?.length > 0 &&
!workspace.emailDomains.includes(emailDomain)
) {
throw new BadRequestException(
`The email domain "${emailDomain}" is not approved for this workspace.`,
);
}
}

View File

@ -1,12 +1,6 @@
import {
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
import { CreateUserDto } from './create-user.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import {Transform, TransformFnParams} from "class-transformer";
export class CreateAdminUserDto extends CreateUserDto {
@IsNotEmpty()
@ -15,17 +9,10 @@ export class CreateAdminUserDto extends CreateUserDto {
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@IsOptional()
@MinLength(1)
@IsNotEmpty()
@MinLength(3)
@MaxLength(50)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
workspaceName: string;
@IsOptional()
@MinLength(4)
@MaxLength(50)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
hostname?: string;
}

View File

@ -92,8 +92,7 @@ export class SignupService {
// create workspace with full setup
const workspaceData: CreateWorkspaceDto = {
name: createAdminUserDto.workspaceName || 'My workspace',
hostname: createAdminUserDto.hostname,
name: createAdminUserDto.workspaceName,
};
workspace = await this.workspaceService.create(

View File

@ -1,13 +1,5 @@
import { OmitType, PartialType } from '@nestjs/mapped-types';
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { CreateUserDto } from '../../auth/dto/create-user.dto';
export class UpdateUserDto extends PartialType(
@ -21,18 +13,7 @@ export class UpdateUserDto extends PartialType(
@IsBoolean()
fullPageWidth: boolean;
@IsOptional()
@IsString()
@IsIn(['read', 'edit'])
pageEditMode: string;
@IsOptional()
@IsString()
locale: string;
@IsOptional()
@MinLength(8)
@MaxLength(70)
@IsString()
confirmPassword: string;
}

View File

@ -50,6 +50,6 @@ export class UserController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.userService.update(updateUserDto, user.id, workspace);
return this.userService.update(updateUserDto, user.id, workspace.id);
}
}

View File

@ -3,12 +3,8 @@ import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { UpdateUserDto } from './dto/update-user.dto';
import { comparePasswordHash } from 'src/common/helpers/utils';
import { Workspace } from '@docmost/db/types/entity.types';
import { validateSsoEnforcement } from '../auth/auth.util';
@Injectable()
export class UserService {
@ -21,14 +17,9 @@ export class UserService {
async update(
updateUserDto: UpdateUserDto,
userId: string,
workspace: Workspace,
workspaceId: string,
) {
const includePassword =
updateUserDto.email != null && updateUserDto.confirmPassword != null;
const user = await this.userRepo.findById(userId, workspace.id, {
includePassword,
});
const user = await this.userRepo.findById(userId, workspaceId);
if (!user) {
throw new NotFoundException('User not found');
@ -43,40 +34,14 @@ export class UserService {
);
}
if (typeof updateUserDto.pageEditMode !== 'undefined') {
return this.userRepo.updatePreference(
userId,
'pageEditMode',
updateUserDto.pageEditMode.toLowerCase(),
);
}
if (updateUserDto.name) {
user.name = updateUserDto.name;
}
if (updateUserDto.email && user.email != updateUserDto.email) {
validateSsoEnforcement(workspace);
if (!updateUserDto.confirmPassword) {
throw new BadRequestException(
'You must provide a password to change your email',
);
}
const isPasswordMatch = await comparePasswordHash(
updateUserDto.confirmPassword,
user.password,
);
if (!isPasswordMatch) {
throw new BadRequestException('You must provide the correct password to change your email');
}
if (await this.userRepo.findByEmail(updateUserDto.email, workspace.id)) {
if (await this.userRepo.findByEmail(updateUserDto.email, workspaceId)) {
throw new BadRequestException('A user with this email already exists');
}
user.email = updateUserDto.email;
}
@ -88,9 +53,7 @@ export class UserService {
user.locale = updateUserDto.locale;
}
delete updateUserDto.confirmPassword;
await this.userRepo.updateUser(updateUserDto, userId, workspace.id);
await this.userRepo.updateUser(updateUserDto, userId, workspaceId);
return user;
}
}

View File

@ -29,7 +29,9 @@ import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.fact
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../../casl/interfaces/workspace-ability.type';import { FastifyReply } from 'fastify';
} from '../../casl/interfaces/workspace-ability.type';
import { addDays } from 'date-fns';
import { FastifyReply } from 'fastify';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { CheckHostnameDto } from '../dto/check-hostname.dto';
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
@ -178,13 +180,10 @@ export class WorkspaceController {
@Public()
@HttpCode(HttpStatus.OK)
@Post('invites/info')
async getInvitationById(
@Body() dto: InvitationIdDto,
@AuthWorkspace() workspace: Workspace,
) {
async getInvitationById(@Body() dto: InvitationIdDto, @Req() req: any) {
return this.workspaceInvitationService.getInvitationById(
dto.invitationId,
workspace,
req.raw.workspaceId,
);
}
@ -254,18 +253,18 @@ export class WorkspaceController {
@Post('invites/accept')
async acceptInvite(
@Body() acceptInviteDto: AcceptInviteDto,
@AuthWorkspace() workspace: Workspace,
@Req() req: any,
@Res({ passthrough: true }) res: FastifyReply,
) {
const authToken = await this.workspaceInvitationService.acceptInvitation(
acceptInviteDto,
workspace,
req.raw.workspaceId,
);
res.setCookie('authToken', authToken, {
httpOnly: true,
path: '/',
expires: this.environmentService.getCookieExpiresIn(),
expires: addDays(new Date(), 30),
secure: this.environmentService.isHttps(),
});
}

View File

@ -28,10 +28,6 @@ import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
validateAllowedEmail,
validateSsoEnforcement,
} from '../../auth/auth.util';
@Injectable()
export class WorkspaceInvitationService {
@ -67,19 +63,19 @@ export class WorkspaceInvitationService {
return result;
}
async getInvitationById(invitationId: string, workspace: Workspace) {
async getInvitationById(invitationId: string, workspaceId: string) {
const invitation = await this.db
.selectFrom('workspaceInvitations')
.select(['id', 'email', 'createdAt'])
.where('id', '=', invitationId)
.where('workspaceId', '=', workspace.id)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
if (!invitation) {
throw new NotFoundException('Invitation not found');
}
return { ...invitation, enforceSso: workspace.enforceSso };
return invitation;
}
async getInvitationTokenById(invitationId: string, workspaceId: string) {
@ -145,10 +141,6 @@ export class WorkspaceInvitationService {
groupIds: validGroups?.map((group: Partial<Group>) => group.id),
}));
if (invitesToInsert.length < 1) {
return;
}
invites = await trx
.insertInto('workspaceInvitations')
.values(invitesToInsert)
@ -177,12 +169,12 @@ export class WorkspaceInvitationService {
}
}
async acceptInvitation(dto: AcceptInviteDto, workspace: Workspace) {
async acceptInvitation(dto: AcceptInviteDto, workspaceId: string) {
const invitation = await this.db
.selectFrom('workspaceInvitations')
.selectAll()
.where('id', '=', dto.invitationId)
.where('workspaceId', '=', workspace.id)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
if (!invitation) {
@ -193,9 +185,6 @@ export class WorkspaceInvitationService {
throw new BadRequestException('Invalid invitation token');
}
validateSsoEnforcement(workspace);
validateAllowedEmail(invitation.email, workspace);
let newUser: User;
try {
@ -208,7 +197,7 @@ export class WorkspaceInvitationService {
password: dto.password,
role: invitation.role,
invitedById: invitation.invitedById,
workspaceId: workspace.id,
workspaceId: workspaceId,
},
trx,
);
@ -216,7 +205,7 @@ export class WorkspaceInvitationService {
// add user to default group
await this.groupUserRepo.addUserToDefaultGroup(
newUser.id,
workspace.id,
workspaceId,
trx,
);
@ -226,7 +215,7 @@ export class WorkspaceInvitationService {
.selectFrom('groups')
.select(['id', 'name'])
.where('groups.id', 'in', invitation.groupIds)
.where('groups.workspaceId', '=', workspace.id)
.where('groups.workspaceId', '=', workspaceId)
.execute();
if (validGroups && validGroups.length > 0) {
@ -267,7 +256,7 @@ export class WorkspaceInvitationService {
// notify the inviter
const invitedByUser = await this.userRepo.findById(
invitation.invitedById,
workspace.id,
workspaceId,
);
if (invitedByUser) {
@ -284,9 +273,7 @@ export class WorkspaceInvitationService {
}
if (this.environmentService.isCloud()) {
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, {
workspaceId: workspace.id,
});
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, { workspaceId });
}
return this.tokenService.generateAccessToken(newUser);

View File

@ -32,7 +32,6 @@ import { AttachmentType } from 'src/core/attachment/attachment.constants';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { generateRandomSuffixNumbers } from '../../../common/helpers';
@Injectable()
export class WorkspaceService {
@ -378,20 +377,24 @@ export class WorkspaceService {
name: string,
trx?: KyselyTransaction,
): Promise<string> {
const generateRandomSuffix = (length: number) =>
Math.random()
.toFixed(length)
.substring(2, 2 + length);
let subdomain = name
.toLowerCase()
.replace(/[^a-z0-9-]/g, '')
.substring(0, 20)
.replace(/^-+|-+$/g, ''); //remove any hyphen at the start or end
.replace(/[^a-z0-9]/g, '')
.substring(0, 20);
// Ensure we leave room for a random suffix.
const maxSuffixLength = 6;
if (subdomain.length < 4) {
subdomain = `${subdomain}-${generateRandomSuffixNumbers(maxSuffixLength)}`;
subdomain = `${subdomain}-${generateRandomSuffix(maxSuffixLength)}`;
}
if (DISALLOWED_HOSTNAMES.includes(subdomain)) {
subdomain = `workspace-${generateRandomSuffixNumbers(maxSuffixLength)}`;
subdomain = `workspace-${generateRandomSuffix(maxSuffixLength)}`;
}
let uniqueHostname = subdomain;
@ -405,7 +408,7 @@ export class WorkspaceService {
break;
}
// Append a random suffix and retry.
const randomSuffix = generateRandomSuffixNumbers(maxSuffixLength);
const randomSuffix = generateRandomSuffix(maxSuffixLength);
uniqueHostname = `${subdomain}-${randomSuffix}`.substring(0, 25);
}

View File

@ -1,23 +0,0 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('billing')
.addColumn('billing_scheme', 'varchar', (col) => col)
.addColumn('tiered_up_to', 'varchar', (col) => col)
.addColumn('tiered_flat_amount', 'int8', (col) => col)
.addColumn('tiered_unit_amount', 'int8', (col) => col)
.addColumn('plan_name', 'varchar', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('billing')
.dropColumn('billing_scheme')
.dropColumn('tiered_up_to')
.dropColumn('tiered_flat_amount')
.dropColumn('tiered_unit_amount')
.dropColumn('plan_name')
.execute();
}

View File

@ -84,7 +84,6 @@ export interface Backlinks {
export interface Billing {
amount: Int8 | null;
billingScheme: string | null;
cancelAt: Timestamp | null;
cancelAtPeriodEnd: boolean | null;
canceledAt: Timestamp | null;
@ -97,7 +96,6 @@ export interface Billing {
metadata: Json | null;
periodEndAt: Timestamp | null;
periodStartAt: Timestamp;
planName: string | null;
quantity: Int8 | null;
status: string;
stripeCustomerId: string | null;
@ -105,9 +103,6 @@ export interface Billing {
stripePriceId: string | null;
stripeProductId: string | null;
stripeSubscriptionId: string;
tieredFlatAmount: Int8 | null;
tieredUnitAmount: Int8 | null;
tieredUpTo: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}

View File

@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import ms, { StringValue } from 'ms';
@Injectable()
export class EnvironmentService {
@ -57,18 +56,7 @@ export class EnvironmentService {
}
getJwtTokenExpiresIn(): string {
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '90d');
}
getCookieExpiresIn(): Date {
const expiresInStr = this.getJwtTokenExpiresIn();
let msUntilExpiry: number;
try {
msUntilExpiry = ms(expiresInStr as StringValue);
} catch (err) {
msUntilExpiry = ms('90d');
}
return new Date(Date.now() + msUntilExpiry);
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '30d');
}
getStorageDriver(): string {
@ -205,12 +193,4 @@ export class EnvironmentService {
.toLowerCase();
return disable === 'true';
}
getPostHogHost(): string {
return this.configService.get<string>('POSTHOG_HOST');
}
getPostHogKey(): string {
return this.configService.get<string>('POSTHOG_KEY');
}
}

View File

@ -37,8 +37,6 @@ export class StaticModule implements OnModuleInit {
CLOUD: this.environmentService.isCloud(),
FILE_UPLOAD_SIZE_LIMIT:
this.environmentService.getFileUploadSizeLimit(),
FILE_IMPORT_SIZE_LIMIT:
this.environmentService.getFileImportSizeLimit(),
DRAWIO_URL: this.environmentService.getDrawioUrl(),
SUBDOMAIN_HOST: this.environmentService.isCloud()
? this.environmentService.getSubdomainHost()
@ -47,8 +45,6 @@ export class StaticModule implements OnModuleInit {
BILLING_TRIAL_DAYS: this.environmentService.isCloud()
? this.environmentService.getBillingTrialDays()
: undefined,
POSTHOG_HOST: this.environmentService.getPostHogHost(),
POSTHOG_KEY: this.environmentService.getPostHogKey(),
};
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;

View File

@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.21.0",
"version": "0.20.4",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@ -26,40 +26,40 @@
"@joplin/turndown": "^4.0.74",
"@joplin/turndown-plugin-gfm": "^1.0.56",
"@sindresorhus/slugify": "1.1.0",
"@tiptap/core": "^2.10.3",
"@tiptap/extension-code-block": "^2.10.3",
"@tiptap/extension-code-block-lowlight": "^2.10.3",
"@tiptap/extension-collaboration": "^2.10.3",
"@tiptap/extension-collaboration-cursor": "^2.10.3",
"@tiptap/extension-color": "^2.10.3",
"@tiptap/extension-document": "^2.10.3",
"@tiptap/extension-heading": "^2.10.3",
"@tiptap/extension-highlight": "^2.10.3",
"@tiptap/extension-history": "^2.10.3",
"@tiptap/extension-image": "^2.10.3",
"@tiptap/extension-link": "^2.10.3",
"@tiptap/extension-list-item": "^2.10.3",
"@tiptap/extension-list-keymap": "^2.10.3",
"@tiptap/extension-placeholder": "^2.10.3",
"@tiptap/extension-subscript": "^2.10.3",
"@tiptap/extension-superscript": "^2.10.3",
"@tiptap/extension-table": "^2.10.3",
"@tiptap/extension-table-cell": "^2.10.3",
"@tiptap/extension-table-header": "^2.10.3",
"@tiptap/extension-table-row": "^2.10.3",
"@tiptap/extension-task-item": "^2.10.3",
"@tiptap/extension-task-list": "^2.10.3",
"@tiptap/extension-text": "^2.10.3",
"@tiptap/extension-text-align": "^2.10.3",
"@tiptap/extension-text-style": "^2.10.3",
"@tiptap/extension-typography": "^2.10.3",
"@tiptap/extension-underline": "^2.10.3",
"@tiptap/extension-youtube": "^2.10.3",
"@tiptap/html": "^2.10.3",
"@tiptap/pm": "^2.10.3",
"@tiptap/react": "^2.10.3",
"@tiptap/starter-kit": "^2.10.3",
"@tiptap/suggestion": "^2.10.3",
"@tiptap/core": "^2.14.0",
"@tiptap/extension-code-block": "^2.14.0",
"@tiptap/extension-code-block-lowlight": "^2.14.0",
"@tiptap/extension-collaboration": "^2.14.0",
"@tiptap/extension-collaboration-cursor": "^2.14.0",
"@tiptap/extension-color": "^2.14.0",
"@tiptap/extension-document": "^2.14.0",
"@tiptap/extension-heading": "^2.14.0",
"@tiptap/extension-highlight": "^2.14.0",
"@tiptap/extension-history": "^2.14.0",
"@tiptap/extension-image": "^2.14.0",
"@tiptap/extension-link": "^2.14.0",
"@tiptap/extension-list-item": "^2.14.0",
"@tiptap/extension-list-keymap": "^2.14.0",
"@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/extension-subscript": "^2.14.0",
"@tiptap/extension-superscript": "^2.14.0",
"@tiptap/extension-table": "^2.14.0",
"@tiptap/extension-table-cell": "^2.14.0",
"@tiptap/extension-table-header": "^2.14.0",
"@tiptap/extension-table-row": "^2.14.0",
"@tiptap/extension-task-item": "^2.14.0",
"@tiptap/extension-task-list": "^2.14.0",
"@tiptap/extension-text": "^2.14.0",
"@tiptap/extension-text-align": "^2.14.0",
"@tiptap/extension-text-style": "^2.14.0",
"@tiptap/extension-typography": "^2.14.0",
"@tiptap/extension-underline": "^2.14.0",
"@tiptap/extension-youtube": "^2.14.0",
"@tiptap/html": "^2.14.0",
"@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0",
"@tiptap/suggestion": "^2.14.0",
"bytes": "^3.1.2",
"cross-env": "^7.0.3",
"date-fns": "^4.1.0",
@ -69,7 +69,6 @@
"jszip": "^3.10.1",
"linkifyjs": "^4.2.0",
"marked": "13.0.3",
"ms": "3.0.0-canary.1",
"uuid": "^11.1.0",
"y-indexeddb": "^9.0.12",
"yjs": "^13.6.27"

View File

@ -17,5 +17,4 @@ export * from "./lib/excalidraw";
export * from "./lib/embed";
export * from "./lib/mention";
export * from "./lib/markdown";
export * from "./lib/search-and-replace";
export * from "./lib/embed-provider";

View File

@ -35,42 +35,6 @@ export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
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;
},
};
},

View File

@ -1,3 +0,0 @@
import { SearchAndReplace } from './search-and-replace'
export * from './search-and-replace'
export default SearchAndReplace

View File

@ -1,455 +0,0 @@
/***
MIT License
Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***/
import { Extension, Range, type Dispatch } from "@tiptap/core";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import {
Plugin,
PluginKey,
type EditorState,
type Transaction,
} from "@tiptap/pm/state";
import { Node as PMNode, Mark } from "@tiptap/pm/model";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
search: {
/**
* @description Set search term in extension.
*/
setSearchTerm: (searchTerm: string) => ReturnType;
/**
* @description Set replace term in extension.
*/
setReplaceTerm: (replaceTerm: string) => ReturnType;
/**
* @description Set case sensitivity in extension.
*/
setCaseSensitive: (caseSensitive: boolean) => ReturnType;
/**
* @description Reset current search result to first instance.
*/
resetIndex: () => ReturnType;
/**
* @description Find next instance of search result.
*/
nextSearchResult: () => ReturnType;
/**
* @description Find previous instance of search result.
*/
previousSearchResult: () => ReturnType;
/**
* @description Replace first instance of search result with given replace term.
*/
replace: () => ReturnType;
/**
* @description Replace all instances of search result with given replace term.
*/
replaceAll: () => ReturnType;
/**
* @description Find selected instance of search result.
*/
selectCurrentItem: () => ReturnType;
};
}
}
interface TextNodesWithPosition {
text: string;
pos: number;
}
const getRegex = (
s: string,
disableRegex: boolean,
caseSensitive: boolean,
): RegExp => {
return RegExp(
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s,
caseSensitive ? "gu" : "gui",
);
};
interface ProcessedSearches {
decorationsToReturn: DecorationSet;
results: Range[];
}
function processSearches(
doc: PMNode,
searchTerm: RegExp,
searchResultClass: string,
resultIndex: number,
): ProcessedSearches {
const decorations: Decoration[] = [];
const results: Range[] = [];
let textNodesWithPosition: TextNodesWithPosition[] = [];
let index = 0;
if (!searchTerm) {
return {
decorationsToReturn: DecorationSet.empty,
results: [],
};
}
doc?.descendants((node, pos) => {
if (node.isText) {
if (textNodesWithPosition[index]) {
textNodesWithPosition[index] = {
text: textNodesWithPosition[index].text + node.text,
pos: textNodesWithPosition[index].pos,
};
} else {
textNodesWithPosition[index] = {
text: `${node.text}`,
pos,
};
}
} else {
index += 1;
}
});
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
for (const element of textNodesWithPosition) {
const { text, pos } = element;
const matches = Array.from(text.matchAll(searchTerm)).filter(
([matchText]) => matchText.trim(),
);
for (const m of matches) {
if (m[0] === "") break;
if (m.index !== undefined) {
results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
}
}
for (let i = 0; i < results.length; i += 1) {
const r = results[i];
const className =
i === resultIndex
? `${searchResultClass} ${searchResultClass}-current`
: searchResultClass;
const decoration: Decoration = Decoration.inline(r.from, r.to, {
class: className,
});
decorations.push(decoration);
}
return {
decorationsToReturn: DecorationSet.create(doc, decorations),
results,
};
}
const replace = (
replaceTerm: string,
results: Range[],
resultIndex: number,
{ state, dispatch }: { state: EditorState; dispatch: Dispatch },
) => {
const firstResult = results[resultIndex];
if (!firstResult) return;
const { from, to } = results[resultIndex];
if (dispatch) {
const tr = state.tr;
// Get all marks that span the text being replaced
const marksSet = new Set<Mark>();
state.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) {
node.marks.forEach(mark => marksSet.add(mark));
}
});
const marks = Array.from(marksSet);
// Delete the old text and insert new text with preserved marks
tr.delete(from, to);
tr.insert(from, state.schema.text(replaceTerm, marks));
dispatch(tr);
}
};
const replaceAll = (
replaceTerm: string,
results: Range[],
{ tr, dispatch }: { tr: Transaction; dispatch: Dispatch },
) => {
const resultsCopy = results.slice();
if (!resultsCopy.length) return;
// Process replacements in reverse order to avoid position shifting issues
for (let i = resultsCopy.length - 1; i >= 0; i -= 1) {
const { from, to } = resultsCopy[i];
// Get all marks that span the text being replaced
const marksSet = new Set<Mark>();
tr.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) {
node.marks.forEach(mark => marksSet.add(mark));
}
});
const marks = Array.from(marksSet);
// Delete and insert with preserved marks
tr.delete(from, to);
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
}
dispatch(tr);
};
export const searchAndReplacePluginKey = new PluginKey(
"searchAndReplacePlugin",
);
export interface SearchAndReplaceOptions {
searchResultClass: string;
disableRegex: boolean;
}
export interface SearchAndReplaceStorage {
searchTerm: string;
replaceTerm: string;
results: Range[];
lastSearchTerm: string;
caseSensitive: boolean;
lastCaseSensitive: boolean;
resultIndex: number;
lastResultIndex: number;
}
export const SearchAndReplace = Extension.create<
SearchAndReplaceOptions,
SearchAndReplaceStorage
>({
name: "searchAndReplace",
addOptions() {
return {
searchResultClass: "search-result",
disableRegex: true,
};
},
addStorage() {
return {
searchTerm: "",
replaceTerm: "",
results: [],
lastSearchTerm: "",
caseSensitive: false,
lastCaseSensitive: false,
resultIndex: 0,
lastResultIndex: 0,
};
},
addCommands() {
return {
setSearchTerm:
(searchTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.searchTerm = searchTerm;
return false;
},
setReplaceTerm:
(replaceTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.replaceTerm = replaceTerm;
return false;
},
setCaseSensitive:
(caseSensitive: boolean) =>
({ editor }) => {
editor.storage.searchAndReplace.caseSensitive = caseSensitive;
return false;
},
resetIndex:
() =>
({ editor }) => {
editor.storage.searchAndReplace.resultIndex = 0;
return false;
},
nextSearchResult:
() =>
({ editor }) => {
const { results, resultIndex } = editor.storage.searchAndReplace;
const nextIndex = resultIndex + 1;
if (results[nextIndex]) {
editor.storage.searchAndReplace.resultIndex = nextIndex;
} else {
editor.storage.searchAndReplace.resultIndex = 0;
}
return false;
},
previousSearchResult:
() =>
({ editor }) => {
const { results, resultIndex } = editor.storage.searchAndReplace;
const prevIndex = resultIndex - 1;
if (results[prevIndex]) {
editor.storage.searchAndReplace.resultIndex = prevIndex;
} else {
editor.storage.searchAndReplace.resultIndex = results.length - 1;
}
return false;
},
replace:
() =>
({ editor, state, dispatch }) => {
const { replaceTerm, results, resultIndex } =
editor.storage.searchAndReplace;
replace(replaceTerm, results, resultIndex, { state, dispatch });
// After replace, adjust index if needed
// The results will be recalculated by the plugin, but we need to ensure
// the index doesn't exceed the new bounds
setTimeout(() => {
const newResultsLength = editor.storage.searchAndReplace.results.length;
if (newResultsLength > 0 && editor.storage.searchAndReplace.resultIndex >= newResultsLength) {
// Keep the same position if possible, otherwise go to the last result
editor.storage.searchAndReplace.resultIndex = Math.min(resultIndex, newResultsLength - 1);
}
}, 0);
return false;
},
replaceAll:
() =>
({ editor, tr, dispatch }) => {
const { replaceTerm, results } = editor.storage.searchAndReplace;
replaceAll(replaceTerm, results, { tr, dispatch });
return false;
},
selectCurrentItem:
() =>
({ editor }) => {
const { results } = editor.storage.searchAndReplace;
for (let i = 0; i < results.length; i++) {
if (
results[i].from == editor.state.selection.from &&
results[i].to == editor.state.selection.to
) {
editor.storage.searchAndReplace.resultIndex = i;
}
}
return false;
},
};
},
addProseMirrorPlugins() {
const editor = this.editor;
const { searchResultClass, disableRegex } = this.options;
const setLastSearchTerm = (t: string) =>
(editor.storage.searchAndReplace.lastSearchTerm = t);
const setLastCaseSensitive = (t: boolean) =>
(editor.storage.searchAndReplace.lastCaseSensitive = t);
const setLastResultIndex = (t: number) =>
(editor.storage.searchAndReplace.lastResultIndex = t);
return [
new Plugin({
key: searchAndReplacePluginKey,
state: {
init: () => DecorationSet.empty,
apply({ doc, docChanged }, oldState) {
const {
searchTerm,
lastSearchTerm,
caseSensitive,
lastCaseSensitive,
resultIndex,
lastResultIndex,
} = editor.storage.searchAndReplace;
if (
!docChanged &&
lastSearchTerm === searchTerm &&
lastCaseSensitive === caseSensitive &&
lastResultIndex === resultIndex
)
return oldState;
setLastSearchTerm(searchTerm);
setLastCaseSensitive(caseSensitive);
setLastResultIndex(resultIndex);
if (!searchTerm) {
editor.storage.searchAndReplace.results = [];
return DecorationSet.empty;
}
const { decorationsToReturn, results } = processSearches(
doc,
getRegex(searchTerm, disableRegex, caseSensitive),
searchResultClass,
resultIndex,
);
editor.storage.searchAndReplace.results = results;
return decorationsToReturn;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}),
];
},
});
export default SearchAndReplace;

223
pnpm-lock.yaml generated
View File

@ -30,7 +30,7 @@ importers:
version: 2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
'@hocuspocus/transformer':
specifier: ^2.15.2
version: 2.15.2(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27)
version: 2.15.2(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27)
'@joplin/turndown':
specifier: ^4.0.74
version: 4.0.74
@ -41,106 +41,106 @@ importers:
specifier: 1.1.0
version: 1.1.0
'@tiptap/core':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/pm@2.14.0)
'@tiptap/extension-code-block':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/extension-code-block-lowlight':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/extension-code-block@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(highlight.js@11.11.1)(lowlight@3.3.0)
'@tiptap/extension-collaboration':
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))
'@tiptap/extension-collaboration-cursor':
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(y-prosemirror@1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(y-prosemirror@1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))
'@tiptap/extension-color':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/extension-text-style@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0)))
'@tiptap/extension-document':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-heading':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-highlight':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-history':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/extension-image':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-link':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/extension-list-item':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-list-keymap':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-placeholder':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/extension-subscript':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-superscript':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-table':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/extension-table-cell':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-table-header':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-table-row':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-task-item':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/extension-task-list':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-text':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-text-align':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-text-style':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-typography':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-underline':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-youtube':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/html':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/pm':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0
'@tiptap/react':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tiptap/starter-kit':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0
'@tiptap/suggestion':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
bytes:
specifier: ^3.1.2
@ -169,9 +169,6 @@ importers:
marked:
specifier: 13.0.3
version: 13.0.3
ms:
specifier: 3.0.0-canary.1
version: 3.0.0-canary.1
uuid:
specifier: ^11.1.0
version: 11.1.0
@ -246,11 +243,8 @@ importers:
specifier: ^5.80.6
version: 5.80.6(react@18.3.1)
'@tiptap/extension-character-count':
specifier: ^2.10.3
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
alfaaz:
specifier: ^1.1.0
version: 1.1.0
axios:
specifier: ^1.9.0
version: 1.9.0
@ -296,9 +290,6 @@ importers:
mitt:
specifier: ^3.0.1
version: 3.0.1
posthog-js:
specifier: ^1.255.1
version: 1.255.1
react:
specifier: ^18.3.1
version: 18.3.1
@ -4686,9 +4677,6 @@ packages:
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
alfaaz@1.1.0:
resolution: {integrity: sha512-J/P07R41APslK7NmD5303bwStN8jpRA4DdvtLeAr1Jhfj6XWGrASUWI0G6jbWjJAZyw3Lu1Pb4J8rsM/cb+xDQ==}
ansi-align@3.0.1:
resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
@ -5216,9 +5204,6 @@ packages:
core-js-compat@3.35.0:
resolution: {integrity: sha512-5blwFAddknKeNgsjBzilkdQ0+YK8L1PfqPYq40NOYMYFSS38qj+hpTcLLWwpIwA2A5bje/x5jmVn2tzUMg9IVw==}
core-js@3.43.0:
resolution: {integrity: sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@ -5994,9 +5979,6 @@ packages:
picomatch:
optional: true
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
@ -7376,10 +7358,6 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
ms@3.0.0-canary.1:
resolution: {integrity: sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g==}
engines: {node: '>=12.13'}
msgpackr-extract@3.0.2:
resolution: {integrity: sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==}
hasBin: true
@ -7964,23 +7942,9 @@ packages:
postgres-range@1.1.4:
resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==}
posthog-js@1.255.1:
resolution: {integrity: sha512-KMh0o9MhORhEZVjXpktXB5rJ8PfDk+poqBoTSoLzWgNjhJf6D8jcyB9jUMA6vVPfn4YeepVX5NuclDRqOwr5Mw==}
peerDependencies:
'@rrweb/types': 2.0.0-alpha.17
rrweb-snapshot: 2.0.0-alpha.17
peerDependenciesMeta:
'@rrweb/types':
optional: true
rrweb-snapshot:
optional: true
postmark@4.0.5:
resolution: {integrity: sha512-nerZdd3TwOH4CgGboZnlUM/q7oZk0EqpZgJL+Y3Nup8kHeaukxouQ6JcFF3EJEijc4QbuNv1TefGhboAKtf/SQ==}
preact@10.26.9:
resolution: {integrity: sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@ -8060,6 +8024,9 @@ packages:
prosemirror-menu@1.2.4:
resolution: {integrity: sha512-S/bXlc0ODQup6aiBbWVsX/eM+xJgCTAfMq/nLqaO5ID/am4wS0tTCIkzwytmao7ypEtjj39i7YbJjAgO20mIqA==}
prosemirror-model@1.23.0:
resolution: {integrity: sha512-Q/fgsgl/dlOAW9ILu4OOhYWQbc7TQd4BwKH/RwmUjyVf8682Be4zj3rOYdLnYEcGzyg8LL9Q5IWYKD8tdToreQ==}
prosemirror-model@1.25.1:
resolution: {integrity: sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==}
@ -8082,9 +8049,15 @@ packages:
prosemirror-state: ^1.4.2
prosemirror-view: ^1.33.8
prosemirror-transform@1.10.2:
resolution: {integrity: sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==}
prosemirror-transform@1.10.4:
resolution: {integrity: sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==}
prosemirror-view@1.37.0:
resolution: {integrity: sha512-z2nkKI1sJzyi7T47Ji/ewBPuIma1RNvQCCYVdV+MqWBV7o4Sa1n94UJCJJ1aQRF/xRkFfyqLGlGFWitIcCOtbg==}
prosemirror-view@1.40.0:
resolution: {integrity: sha512-2G3svX0Cr1sJjkD/DYWSe3cfV5VPVTBOxI9XQEGWJDFEpsZb/gh4MV29ctv+OJx2RFX4BLt09i+6zaGM/ldkCw==}
@ -9320,9 +9293,6 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
web-worker@1.5.0:
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
@ -11826,12 +11796,12 @@ snapshots:
- bufferutil
- utf-8-validate
'@hocuspocus/transformer@2.15.2(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27)':
'@hocuspocus/transformer@2.15.2(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27)':
dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
'@tiptap/pm': 2.14.0
'@tiptap/starter-kit': 2.14.0
y-prosemirror: 1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
y-prosemirror: 1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
yjs: 13.6.27
'@humanfs/core@0.19.1': {}
@ -13587,16 +13557,16 @@ snapshots:
dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
'@tiptap/extension-collaboration-cursor@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(y-prosemirror@1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))':
'@tiptap/extension-collaboration-cursor@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(y-prosemirror@1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))':
dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
y-prosemirror: 1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
y-prosemirror: 1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
'@tiptap/extension-collaboration@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))':
'@tiptap/extension-collaboration@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))':
dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
'@tiptap/pm': 2.14.0
y-prosemirror: 1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
y-prosemirror: 1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
'@tiptap/extension-color@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/extension-text-style@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0)))':
dependencies:
@ -13760,14 +13730,14 @@ snapshots:
prosemirror-keymap: 1.2.2
prosemirror-markdown: 1.13.1
prosemirror-menu: 1.2.4
prosemirror-model: 1.25.1
prosemirror-model: 1.23.0
prosemirror-schema-basic: 1.2.3
prosemirror-schema-list: 1.4.1
prosemirror-state: 1.4.3
prosemirror-tables: 1.7.1
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)
prosemirror-transform: 1.10.4
prosemirror-view: 1.40.0
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.0
'@tiptap/react@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -14588,8 +14558,6 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
alfaaz@1.1.0: {}
ansi-align@3.0.1:
dependencies:
string-width: 4.2.3
@ -15220,8 +15188,6 @@ snapshots:
dependencies:
browserslist: 4.24.2
core-js@3.43.0: {}
core-util-is@1.0.3: {}
cors@2.8.5:
@ -16209,8 +16175,6 @@ snapshots:
optionalDependencies:
picomatch: 4.0.2
fflate@0.4.8: {}
fflate@0.8.2: {}
figures@3.2.0:
@ -17889,8 +17853,6 @@ snapshots:
ms@2.1.3: {}
ms@3.0.0-canary.1: {}
msgpackr-extract@3.0.2:
dependencies:
node-gyp-build-optional-packages: 5.0.7
@ -18512,21 +18474,12 @@ snapshots:
postgres-range@1.1.4: {}
posthog-js@1.255.1:
dependencies:
core-js: 3.43.0
fflate: 0.4.8
preact: 10.26.9
web-vitals: 4.2.4
postmark@4.0.5:
dependencies:
axios: 1.9.0
transitivePeerDependencies:
- debug
preact@10.26.9: {}
prelude-ls@1.2.1: {}
prettier@3.4.1: {}
@ -18566,7 +18519,7 @@ snapshots:
prosemirror-changeset@2.3.1:
dependencies:
prosemirror-transform: 1.10.4
prosemirror-transform: 1.10.2
prosemirror-collab@1.3.1:
dependencies:
@ -18574,34 +18527,34 @@ snapshots:
prosemirror-commands@1.6.2:
dependencies:
prosemirror-model: 1.25.1
prosemirror-model: 1.23.0
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-transform: 1.10.2
prosemirror-dropcursor@1.8.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-view: 1.40.0
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.0
prosemirror-gapcursor@1.3.2:
dependencies:
prosemirror-keymap: 1.2.2
prosemirror-model: 1.25.1
prosemirror-model: 1.23.0
prosemirror-state: 1.4.3
prosemirror-view: 1.40.0
prosemirror-view: 1.37.0
prosemirror-history@1.4.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-view: 1.40.0
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.0
rope-sequence: 1.3.4
prosemirror-inputrules@1.4.0:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-transform: 1.10.2
prosemirror-keymap@1.2.2:
dependencies:
@ -18612,7 +18565,7 @@ snapshots:
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 14.1.0
prosemirror-model: 1.25.1
prosemirror-model: 1.23.0
prosemirror-menu@1.2.4:
dependencies:
@ -18621,25 +18574,29 @@ snapshots:
prosemirror-history: 1.4.1
prosemirror-state: 1.4.3
prosemirror-model@1.23.0:
dependencies:
orderedmap: 2.1.1
prosemirror-model@1.25.1:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-basic@1.2.3:
dependencies:
prosemirror-model: 1.25.1
prosemirror-model: 1.23.0
prosemirror-schema-list@1.4.1:
dependencies:
prosemirror-model: 1.25.1
prosemirror-model: 1.23.0
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-transform: 1.10.2
prosemirror-state@1.4.3:
dependencies:
prosemirror-model: 1.25.1
prosemirror-transform: 1.10.4
prosemirror-view: 1.40.0
prosemirror-model: 1.23.0
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.0
prosemirror-tables@1.7.1:
dependencies:
@ -18649,18 +18606,28 @@ snapshots:
prosemirror-transform: 1.10.4
prosemirror-view: 1.40.0
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0):
prosemirror-trailing-node@3.0.0(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0):
dependencies:
'@remirror/core-constants': 3.0.0
escape-string-regexp: 4.0.0
prosemirror-model: 1.25.1
prosemirror-model: 1.23.0
prosemirror-state: 1.4.3
prosemirror-view: 1.40.0
prosemirror-view: 1.37.0
prosemirror-transform@1.10.2:
dependencies:
prosemirror-model: 1.23.0
prosemirror-transform@1.10.4:
dependencies:
prosemirror-model: 1.25.1
prosemirror-view@1.37.0:
dependencies:
prosemirror-model: 1.23.0
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-view@1.40.0:
dependencies:
prosemirror-model: 1.25.1
@ -19950,8 +19917,6 @@ snapshots:
dependencies:
defaults: 1.0.4
web-vitals@4.2.4: {}
web-worker@1.5.0: {}
webidl-conversions@3.0.1: {}
@ -20134,12 +20099,12 @@ snapshots:
lib0: 0.2.88
yjs: 13.6.27
y-prosemirror@1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27):
y-prosemirror@1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27):
dependencies:
lib0: 0.2.108
prosemirror-model: 1.25.1
prosemirror-model: 1.23.0
prosemirror-state: 1.4.3
prosemirror-view: 1.40.0
prosemirror-view: 1.37.0
y-protocols: 1.0.6(yjs@13.6.27)
yjs: 13.6.27