mirror of
https://github.com/docmost/docmost.git
synced 2026-05-13 11:44:07 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6433cddb98 | |||
| 038d87c08a | |||
| 22ade69f97 | |||
| aef806a262 | |||
| 6205741d26 | |||
| 6f66577d61 | |||
| 6f0fb9beff | |||
| c5639feff5 | |||
| b164ff2e2f | |||
| 59f111e730 | |||
| 9f2be72173 | |||
| 993d771282 | |||
| a925dc0782 | |||
| 0cc3c6c68a | |||
| 052b2042ff | |||
| 22182418ed | |||
| e71584dfd4 | |||
| a1b6e7dbbd | |||
| 8c380db8c3 | |||
| 4c5b684ed4 |
@@ -274,6 +274,7 @@
|
|||||||
"Add row below": "Add row below",
|
"Add row below": "Add row below",
|
||||||
"Delete table": "Delete table",
|
"Delete table": "Delete table",
|
||||||
"Info": "Info",
|
"Info": "Info",
|
||||||
|
"Note": "Note",
|
||||||
"Success": "Success",
|
"Success": "Success",
|
||||||
"Warning": "Warning",
|
"Warning": "Warning",
|
||||||
"Danger": "Danger",
|
"Danger": "Danger",
|
||||||
@@ -363,6 +364,15 @@
|
|||||||
"Heading {{level}}": "Heading {{level}}",
|
"Heading {{level}}": "Heading {{level}}",
|
||||||
"Toggle title": "Toggle title",
|
"Toggle title": "Toggle title",
|
||||||
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
||||||
|
"Write...": "Write...",
|
||||||
|
"Column count": "Column count",
|
||||||
|
"{{count}} Columns": "{{count}} Columns",
|
||||||
|
"Equal columns": "Equal columns",
|
||||||
|
"Left sidebar": "Left sidebar",
|
||||||
|
"Right sidebar": "Right sidebar",
|
||||||
|
"Wide center": "Wide center",
|
||||||
|
"Left wide": "Left wide",
|
||||||
|
"Right wide": "Right wide",
|
||||||
"Names do not match": "Names do not match",
|
"Names do not match": "Names do not match",
|
||||||
"Today, {{time}}": "Today, {{time}}",
|
"Today, {{time}}": "Today, {{time}}",
|
||||||
"Yesterday, {{time}}": "Yesterday, {{time}}",
|
"Yesterday, {{time}}": "Yesterday, {{time}}",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
size?: number | string;
|
||||||
|
stroke?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IconColumns4({ size = 24, stroke = 2 }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={rem(size)}
|
||||||
|
height={rem(size)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
|
||||||
|
<path d="M7.5 3v18" />
|
||||||
|
<path d="M12 3v18" />
|
||||||
|
<path d="M16.5 3v18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
size?: number | string;
|
||||||
|
stroke?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IconColumns5({ size = 24, stroke = 2 }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={rem(size)}
|
||||||
|
height={rem(size)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
|
||||||
|
<path d="M6.6 3v18" />
|
||||||
|
<path d="M10.2 3v18" />
|
||||||
|
<path d="M13.8 3v18" />
|
||||||
|
<path d="M17.4 3v18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { Group, Menu, Text, UnstyledButton } from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconChevronDown,
|
|
||||||
IconLock,
|
|
||||||
IconShieldLock,
|
|
||||||
IconCheck,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import classes from "./page-permission.module.css";
|
|
||||||
|
|
||||||
type AccessLevel = "open" | "restricted";
|
|
||||||
|
|
||||||
type GeneralAccessSelectProps = {
|
|
||||||
value: AccessLevel;
|
|
||||||
onChange: (value: AccessLevel) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
hasInheritedRestriction?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function GeneralAccessSelect({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
hasInheritedRestriction,
|
|
||||||
}: GeneralAccessSelectProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const isDirectlyRestricted = value === "restricted";
|
|
||||||
const showInheritedState = hasInheritedRestriction && !isDirectlyRestricted;
|
|
||||||
|
|
||||||
const currentLabel = showInheritedState
|
|
||||||
? t("Restricted by parent")
|
|
||||||
: isDirectlyRestricted
|
|
||||||
? t("Restricted")
|
|
||||||
: t("Open");
|
|
||||||
|
|
||||||
const currentDescription = showInheritedState
|
|
||||||
? t("Inherits restrictions from ancestor page")
|
|
||||||
: isDirectlyRestricted
|
|
||||||
? t("Only specific people can access")
|
|
||||||
: t("Everyone in this space can access");
|
|
||||||
|
|
||||||
const CurrentIcon = showInheritedState
|
|
||||||
? IconShieldLock
|
|
||||||
: isDirectlyRestricted
|
|
||||||
? IconLock
|
|
||||||
: IconShieldLock;
|
|
||||||
|
|
||||||
const accessOptions = [
|
|
||||||
{
|
|
||||||
value: "open" as const,
|
|
||||||
label: hasInheritedRestriction ? t("Restricted by parent") : t("Open"),
|
|
||||||
description: hasInheritedRestriction
|
|
||||||
? t("Use only inherited restrictions")
|
|
||||||
: t("Everyone in this space can access"),
|
|
||||||
icon: IconShieldLock,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "restricted" as const,
|
|
||||||
label: t("Restricted"),
|
|
||||||
description: hasInheritedRestriction
|
|
||||||
? t("Add restrictions on top of inherited")
|
|
||||||
: t("Only specific people can access"),
|
|
||||||
icon: IconLock,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Menu withArrow disabled={disabled}>
|
|
||||||
<Menu.Target>
|
|
||||||
<UnstyledButton className={classes.generalAccessBox} disabled={disabled}>
|
|
||||||
<div
|
|
||||||
className={`${classes.generalAccessIcon} ${isDirectlyRestricted || showInheritedState ? classes.generalAccessIconRestricted : ""}`}
|
|
||||||
>
|
|
||||||
<CurrentIcon size={18} stroke={1.5} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Group gap={4}>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{currentLabel}
|
|
||||||
</Text>
|
|
||||||
{!disabled && <IconChevronDown size={14} />}
|
|
||||||
</Group>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{currentDescription}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</UnstyledButton>
|
|
||||||
</Menu.Target>
|
|
||||||
|
|
||||||
<Menu.Dropdown>
|
|
||||||
{accessOptions.map((option) => (
|
|
||||||
<Menu.Item
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => onChange(option.value)}
|
|
||||||
leftSection={<option.icon size={16} stroke={1.5} />}
|
|
||||||
rightSection={
|
|
||||||
option.value === value ? <IconCheck size={16} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Text size="sm">{option.label}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{option.description}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { Menu, Text, UnstyledButton, Group } from "@mantine/core";
|
|
||||||
import { IconChevronDown, IconCheck } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
|
||||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
|
|
||||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle";
|
|
||||||
import { userAtom } from "@/features/user/atoms/current-user-atom";
|
|
||||||
import { formatMemberCount } from "@/lib";
|
|
||||||
import {
|
|
||||||
IPagePermissionMember,
|
|
||||||
PagePermissionRole,
|
|
||||||
} from "@/ee/page-permission/types/page-permission.types";
|
|
||||||
import {
|
|
||||||
pagePermissionRoleData,
|
|
||||||
getPagePermissionRoleLabel,
|
|
||||||
} from "@/ee/page-permission/types/page-permission-role-data";
|
|
||||||
import classes from "./page-permission.module.css";
|
|
||||||
|
|
||||||
type PagePermissionItemProps = {
|
|
||||||
member: IPagePermissionMember;
|
|
||||||
onRoleChange: (memberId: string, type: "user" | "group", role: string) => void;
|
|
||||||
onRemove: (memberId: string, type: "user" | "group") => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PagePermissionItem({
|
|
||||||
member,
|
|
||||||
onRoleChange,
|
|
||||||
onRemove,
|
|
||||||
disabled,
|
|
||||||
}: PagePermissionItemProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const currentUser = useAtomValue(userAtom);
|
|
||||||
const isCurrentUser = member.type === "user" && member.id === currentUser?.id;
|
|
||||||
const roleLabel = getPagePermissionRoleLabel(member.role);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classes.permissionItem}>
|
|
||||||
<div className={classes.permissionItemInfo}>
|
|
||||||
{member.type === "user" && (
|
|
||||||
<CustomAvatar avatarUrl={member.avatarUrl} name={member.name} />
|
|
||||||
)}
|
|
||||||
{member.type === "group" && <IconGroupCircle />}
|
|
||||||
|
|
||||||
<div className={classes.permissionItemDetails}>
|
|
||||||
<AutoTooltipText
|
|
||||||
fz="sm"
|
|
||||||
fw={500}
|
|
||||||
tooltipLabel={isCurrentUser ? `${member.name} (${t("You")})` : member.name}
|
|
||||||
>
|
|
||||||
{member.name}
|
|
||||||
{isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>}
|
|
||||||
</AutoTooltipText>
|
|
||||||
<AutoTooltipText fz="xs" c="dimmed">
|
|
||||||
{member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)}
|
|
||||||
</AutoTooltipText>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={classes.permissionItemRole}>
|
|
||||||
{isCurrentUser || disabled ? (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t(roleLabel)}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Menu withArrow position="bottom-end">
|
|
||||||
<Menu.Target>
|
|
||||||
<UnstyledButton>
|
|
||||||
<Group gap={4}>
|
|
||||||
<Text size="sm">{t(roleLabel)}</Text>
|
|
||||||
<IconChevronDown size={14} />
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
</Menu.Target>
|
|
||||||
|
|
||||||
<Menu.Dropdown>
|
|
||||||
{pagePermissionRoleData.map((role) => (
|
|
||||||
<Menu.Item
|
|
||||||
key={role.value}
|
|
||||||
onClick={() => onRoleChange(member.id, member.type, role.value)}
|
|
||||||
rightSection={
|
|
||||||
role.value === member.role ? <IconCheck size={16} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Text size="sm">{t(role.label)}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t(role.description)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
<Menu.Divider />
|
|
||||||
<Menu.Item
|
|
||||||
color="red"
|
|
||||||
onClick={() => onRemove(member.id, member.type)}
|
|
||||||
>
|
|
||||||
{t("Remove access")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import { Avatar, Group, ScrollArea, Text } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { modals } from "@mantine/modals";
|
|
||||||
import { userAtom } from "@/features/user/atoms/current-user-atom";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
|
||||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle";
|
|
||||||
import {
|
|
||||||
IPagePermissionMember,
|
|
||||||
PagePermissionRole,
|
|
||||||
} from "@/ee/page-permission/types/page-permission.types";
|
|
||||||
import {
|
|
||||||
useRemovePagePermissionMutation,
|
|
||||||
useUpdatePagePermissionRoleMutation,
|
|
||||||
} from "@/ee/page-permission/queries/page-permission-query";
|
|
||||||
import { PagePermissionItem } from "./page-permission-item";
|
|
||||||
import classes from "./page-permission.module.css";
|
|
||||||
|
|
||||||
type PagePermissionListProps = {
|
|
||||||
pageId: string;
|
|
||||||
members: IPagePermissionMember[];
|
|
||||||
canManage: boolean;
|
|
||||||
onRemoveAll?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PagePermissionList({
|
|
||||||
pageId,
|
|
||||||
members,
|
|
||||||
canManage,
|
|
||||||
onRemoveAll,
|
|
||||||
}: PagePermissionListProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const currentUser = useAtomValue(userAtom);
|
|
||||||
const updateRoleMutation = useUpdatePagePermissionRoleMutation();
|
|
||||||
const removeMutation = useRemovePagePermissionMutation();
|
|
||||||
|
|
||||||
const handleRoleChange = async (
|
|
||||||
memberId: string,
|
|
||||||
type: "user" | "group",
|
|
||||||
newRole: string,
|
|
||||||
) => {
|
|
||||||
await updateRoleMutation.mutateAsync({
|
|
||||||
pageId,
|
|
||||||
role: newRole as PagePermissionRole,
|
|
||||||
...(type === "user" ? { userId: memberId } : { groupId: memberId }),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = (memberId: string, type: "user" | "group") => {
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t("Remove access"),
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{t("Are you sure you want to remove this member's access to the page?")}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
centered: true,
|
|
||||||
labels: { confirm: t("Remove"), cancel: t("Cancel") },
|
|
||||||
confirmProps: { color: "red" },
|
|
||||||
onConfirm: async () => {
|
|
||||||
await removeMutation.mutateAsync({
|
|
||||||
pageId,
|
|
||||||
...(type === "user" ? { userIds: [memberId] } : { groupIds: [memberId] }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveAll = () => {
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t("Remove all access"),
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{t("Are you sure you want to remove all specific access? This will make the page open to everyone in the space.")}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
centered: true,
|
|
||||||
labels: { confirm: t("Remove all"), cancel: t("Cancel") },
|
|
||||||
confirmProps: { color: "red" },
|
|
||||||
onConfirm: () => onRemoveAll?.(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortedMembers = [...members].sort((a, b) => {
|
|
||||||
if (a.type === "user" && a.id === currentUser?.id) return -1;
|
|
||||||
if (b.type === "user" && b.id === currentUser?.id) return 1;
|
|
||||||
if (a.type === "group" && b.type === "user") return -1;
|
|
||||||
if (a.type === "user" && b.type === "group") return 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const getSummaryText = () => {
|
|
||||||
const names: string[] = [];
|
|
||||||
let remaining = 0;
|
|
||||||
|
|
||||||
for (const member of sortedMembers) {
|
|
||||||
if (names.length < 2) {
|
|
||||||
if (member.type === "user" && member.id === currentUser?.id) {
|
|
||||||
names.push(t("You"));
|
|
||||||
} else {
|
|
||||||
names.push(member.name);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
remaining++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remaining > 0) {
|
|
||||||
return `${names.join(", ")}, ${t("and {{count}} other", { count: remaining })}`;
|
|
||||||
}
|
|
||||||
return names.join(", ");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (members.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={classes.specificAccessHeader}>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{t("Specific access")}
|
|
||||||
</Text>
|
|
||||||
{canManage && members.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
•
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
className={classes.removeAllLink}
|
|
||||||
onClick={handleRemoveAll}
|
|
||||||
>
|
|
||||||
{t("Remove all")}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Group gap={0} mb="xs">
|
|
||||||
<div className={classes.avatarStack}>
|
|
||||||
{sortedMembers.slice(0, 3).map((member, index) => (
|
|
||||||
<div
|
|
||||||
key={member.id}
|
|
||||||
className={classes.avatarStackItem}
|
|
||||||
style={{ zIndex: sortedMembers.length - index }}
|
|
||||||
>
|
|
||||||
{member.type === "user" ? (
|
|
||||||
<CustomAvatar
|
|
||||||
avatarUrl={member.avatarUrl}
|
|
||||||
name={member.name}
|
|
||||||
size={28}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Avatar size={28} radius="xl">
|
|
||||||
<IconGroupCircle />
|
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Text size="sm" ml="xs">
|
|
||||||
{getSummaryText()}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<ScrollArea mah={250}>
|
|
||||||
{sortedMembers.map((member) => (
|
|
||||||
<PagePermissionItem
|
|
||||||
key={`${member.type}-${member.id}`}
|
|
||||||
member={member}
|
|
||||||
onRoleChange={handleRoleChange}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
disabled={!canManage}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ScrollArea>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Paper,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Link, useParams } from "react-router-dom";
|
|
||||||
import { IconArrowRight, IconLock, IconShieldLock } from "@tabler/icons-react";
|
|
||||||
import { MultiMemberSelect } from "@/features/space/components/multi-member-select";
|
|
||||||
import {
|
|
||||||
IPageRestrictionInfo,
|
|
||||||
PagePermissionRole,
|
|
||||||
} from "@/ee/page-permission/types/page-permission.types";
|
|
||||||
import {
|
|
||||||
useAddPagePermissionMutation,
|
|
||||||
usePagePermissionsQuery,
|
|
||||||
useRestrictPageMutation,
|
|
||||||
useUnrestrictPageMutation,
|
|
||||||
} from "@/ee/page-permission/queries/page-permission-query";
|
|
||||||
import { pagePermissionRoleData } from "@/ee/page-permission/types/page-permission-role-data";
|
|
||||||
import { GeneralAccessSelect } from "@/ee/page-permission";
|
|
||||||
import { PagePermissionList } from "@/ee/page-permission";
|
|
||||||
import classes from "./page-permission.module.css";
|
|
||||||
import { buildPageUrl } from "@/features/page/page.utils";
|
|
||||||
|
|
||||||
type PagePermissionTabProps = {
|
|
||||||
pageId: string;
|
|
||||||
restrictionInfo: IPageRestrictionInfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PagePermissionTab({
|
|
||||||
pageId,
|
|
||||||
restrictionInfo,
|
|
||||||
}: PagePermissionTabProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { spaceSlug } = useParams();
|
|
||||||
const [memberIds, setMemberIds] = useState<string[]>([]);
|
|
||||||
const [role, setRole] = useState<string>(PagePermissionRole.WRITER);
|
|
||||||
|
|
||||||
const { data: permissionsData, isLoading } = usePagePermissionsQuery(pageId);
|
|
||||||
const restrictMutation = useRestrictPageMutation();
|
|
||||||
const unrestrictMutation = useUnrestrictPageMutation();
|
|
||||||
const addPermissionMutation = useAddPagePermissionMutation();
|
|
||||||
|
|
||||||
const hasInheritedRestriction = restrictionInfo.hasInheritedRestriction;
|
|
||||||
const hasDirectRestriction = restrictionInfo.hasDirectRestriction;
|
|
||||||
const canManage = restrictionInfo.userAccess.canManage;
|
|
||||||
|
|
||||||
const handleDirectAccessChange = async (value: "open" | "restricted") => {
|
|
||||||
if (value === "restricted" && !hasDirectRestriction) {
|
|
||||||
await restrictMutation.mutateAsync(pageId);
|
|
||||||
} else if (value === "open" && hasDirectRestriction) {
|
|
||||||
await unrestrictMutation.mutateAsync(pageId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddMembers = async () => {
|
|
||||||
if (memberIds.length === 0) return;
|
|
||||||
|
|
||||||
const userIds = memberIds
|
|
||||||
.filter((id) => id.startsWith("user-"))
|
|
||||||
.map((id) => id.replace("user-", ""));
|
|
||||||
|
|
||||||
const groupIds = memberIds
|
|
||||||
.filter((id) => id.startsWith("group-"))
|
|
||||||
.map((id) => id.replace("group-", ""));
|
|
||||||
|
|
||||||
await addPermissionMutation.mutateAsync({
|
|
||||||
pageId,
|
|
||||||
role: role as PagePermissionRole,
|
|
||||||
...(userIds.length > 0 && { userIds }),
|
|
||||||
...(groupIds.length > 0 && { groupIds }),
|
|
||||||
});
|
|
||||||
|
|
||||||
setMemberIds([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveAll = async () => {
|
|
||||||
await unrestrictMutation.mutateAsync(pageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
{hasInheritedRestriction && (
|
|
||||||
<Paper className={classes.inheritedSection} p="sm" radius="sm">
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<ThemeIcon
|
|
||||||
size="lg"
|
|
||||||
radius="sm"
|
|
||||||
variant="light"
|
|
||||||
color="orange"
|
|
||||||
>
|
|
||||||
<IconShieldLock size={18} stroke={1.5} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Box style={{ flex: 1 }}>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{t("Inherited restriction")}
|
|
||||||
</Text>
|
|
||||||
<Group gap={4}>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("Access limited by")}
|
|
||||||
</Text>
|
|
||||||
<Link
|
|
||||||
to={buildPageUrl(
|
|
||||||
spaceSlug,
|
|
||||||
restrictionInfo.id,
|
|
||||||
restrictionInfo.title,
|
|
||||||
)}
|
|
||||||
style={{ textDecoration: "none" }}
|
|
||||||
>
|
|
||||||
<Group gap={2}>
|
|
||||||
<Text size="xs" fw={500} c="blue">
|
|
||||||
{restrictionInfo.title || t("Untitled")}
|
|
||||||
</Text>
|
|
||||||
<IconArrowRight size={12} color="var(--mantine-color-blue-6)" />
|
|
||||||
</Group>
|
|
||||||
</Link>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Text size="sm" fw={500} mb="xs">
|
|
||||||
{t("This page")}
|
|
||||||
</Text>
|
|
||||||
<GeneralAccessSelect
|
|
||||||
value={hasDirectRestriction ? "restricted" : "open"}
|
|
||||||
onChange={handleDirectAccessChange}
|
|
||||||
disabled={!canManage}
|
|
||||||
hasInheritedRestriction={hasInheritedRestriction}
|
|
||||||
/>
|
|
||||||
{!hasDirectRestriction && !hasInheritedRestriction && (
|
|
||||||
<Text size="xs" c="dimmed" mt={4}>
|
|
||||||
{t("Everyone in this space can access this page")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{!hasDirectRestriction && hasInheritedRestriction && (
|
|
||||||
<Text size="xs" c="dimmed" mt={4}>
|
|
||||||
{t("Add additional restrictions specific to this page")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{hasDirectRestriction && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{canManage && (
|
|
||||||
<Group gap="xs" align="flex-end">
|
|
||||||
<Box style={{ flex: 1 }}>
|
|
||||||
<MultiMemberSelect value={memberIds} onChange={setMemberIds} />
|
|
||||||
</Box>
|
|
||||||
<Select
|
|
||||||
data={pagePermissionRoleData.map((r) => ({
|
|
||||||
label: t(r.label),
|
|
||||||
value: r.value,
|
|
||||||
}))}
|
|
||||||
value={role}
|
|
||||||
onChange={(value) => value && setRole(value)}
|
|
||||||
allowDeselect={false}
|
|
||||||
variant="filled"
|
|
||||||
w={120}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleAddMembers}
|
|
||||||
disabled={memberIds.length === 0}
|
|
||||||
loading={addPermissionMutation.isPending}
|
|
||||||
>
|
|
||||||
{t("Add")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<Group justify="center" py="md">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<PagePermissionList
|
|
||||||
pageId={pageId}
|
|
||||||
members={permissionsData?.items || []}
|
|
||||||
canManage={canManage}
|
|
||||||
onRemoveAll={handleRemoveAll}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
.generalAccessBox {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--mantine-spacing-sm);
|
|
||||||
padding: var(--mantine-spacing-xs) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.generalAccessIcon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
background-color: var(--mantine-color-gray-1);
|
|
||||||
}
|
|
||||||
@mixin dark {
|
|
||||||
background-color: var(--mantine-color-dark-5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.generalAccessIconRestricted {
|
|
||||||
@mixin light {
|
|
||||||
background-color: var(--mantine-color-red-0);
|
|
||||||
color: var(--mantine-color-red-6);
|
|
||||||
}
|
|
||||||
@mixin dark {
|
|
||||||
background-color: rgba(250, 82, 82, 0.1);
|
|
||||||
color: var(--mantine-color-red-5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.permissionItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--mantine-spacing-xs) 0;
|
|
||||||
gap: var(--mantine-spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.permissionItemInfo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--mantine-spacing-sm);
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permissionItemDetails {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permissionItemRole {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarStack {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarStackItem {
|
|
||||||
margin-left: -8px;
|
|
||||||
border: 2px solid var(--mantine-color-body);
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarStackItem:first-child {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.specificAccessHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--mantine-spacing-xs);
|
|
||||||
margin-top: var(--mantine-spacing-md);
|
|
||||||
margin-bottom: var(--mantine-spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.removeAllLink {
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--mantine-font-size-sm);
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
color: var(--mantine-color-gray-6);
|
|
||||||
}
|
|
||||||
@mixin dark {
|
|
||||||
color: var(--mantine-color-dark-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.inheritedInfo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--mantine-spacing-xs);
|
|
||||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
margin-bottom: var(--mantine-spacing-sm);
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
background-color: var(--mantine-color-gray-0);
|
|
||||||
}
|
|
||||||
@mixin dark {
|
|
||||||
background-color: var(--mantine-color-dark-6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.inheritedSection {
|
|
||||||
@mixin light {
|
|
||||||
background-color: var(--mantine-color-orange-0);
|
|
||||||
border: 1px solid var(--mantine-color-orange-2);
|
|
||||||
}
|
|
||||||
@mixin dark {
|
|
||||||
background-color: rgba(255, 146, 43, 0.08);
|
|
||||||
border: 1px solid rgba(255, 146, 43, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Indicator,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Tabs,
|
|
||||||
Center,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import { IconWorld, IconLock } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
|
||||||
import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-permission-query";
|
|
||||||
import { PagePermissionTab } from "@/ee/page-permission";
|
|
||||||
import { PublishTab } from "./publish-tab";
|
|
||||||
import { useShareForPageQuery } from "@/features/share/queries/share-query";
|
|
||||||
|
|
||||||
type PageShareModalProps = {
|
|
||||||
readOnly?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PageShareModal({ readOnly }: PageShareModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { pageSlug } = useParams();
|
|
||||||
const pageSlugId = extractPageSlugId(pageSlug);
|
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
|
||||||
const [activeTab, setActiveTab] = useState<string | null>("share");
|
|
||||||
|
|
||||||
const { data: page } = usePageQuery({ pageId: pageSlugId });
|
|
||||||
const pageId = page?.id;
|
|
||||||
const isRestricted = page?.permissions?.hasRestriction ?? false;
|
|
||||||
|
|
||||||
const { data: share } = useShareForPageQuery(pageId);
|
|
||||||
const isPubliclyShared = !!share;
|
|
||||||
|
|
||||||
const { data: restrictionInfo, isLoading: restrictionLoading } =
|
|
||||||
usePageRestrictionInfoQuery(opened ? pageId : undefined);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
style={{ border: "none" }}
|
|
||||||
size="compact-sm"
|
|
||||||
leftSection={
|
|
||||||
<Indicator
|
|
||||||
color={isRestricted ? "red" : "green"}
|
|
||||||
offset={5}
|
|
||||||
disabled={!isRestricted && !isPubliclyShared}
|
|
||||||
withBorder
|
|
||||||
>
|
|
||||||
{isRestricted ? (
|
|
||||||
<IconLock size={20} stroke={1.5} />
|
|
||||||
) : (
|
|
||||||
<IconWorld size={20} stroke={1.5} />
|
|
||||||
)}
|
|
||||||
</Indicator>
|
|
||||||
}
|
|
||||||
variant="default"
|
|
||||||
onClick={open}
|
|
||||||
>
|
|
||||||
{t("Share")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={close}
|
|
||||||
title={t("Share")}
|
|
||||||
size={600}
|
|
||||||
>
|
|
||||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
|
||||||
<Tabs.List mb="md">
|
|
||||||
<Tabs.Tab value="share">{t("Share")}</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="publish">{t("Publish")}</Tabs.Tab>
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Panel value="share">
|
|
||||||
{restrictionLoading || !pageId ? (
|
|
||||||
<Center py="xl">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
) : (
|
|
||||||
<PagePermissionTab
|
|
||||||
pageId={pageId}
|
|
||||||
restrictionInfo={restrictionInfo}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="publish">
|
|
||||||
<PublishTab pageId={pageId} readOnly={readOnly} />
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Anchor,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconExternalLink, IconLock } from "@tabler/icons-react";
|
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { getPageIcon } from "@/lib";
|
|
||||||
import CopyTextButton from "@/components/common/copy";
|
|
||||||
import { getAppUrl, isCloud } from "@/lib/config";
|
|
||||||
import { buildPageUrl } from "@/features/page/page.utils";
|
|
||||||
import {
|
|
||||||
useCreateShareMutation,
|
|
||||||
useDeleteShareMutation,
|
|
||||||
useShareForPageQuery,
|
|
||||||
useUpdateShareMutation,
|
|
||||||
} from "@/features/share/queries/share-query";
|
|
||||||
import useTrial from "@/ee/hooks/use-trial";
|
|
||||||
|
|
||||||
type PublishTabProps = {
|
|
||||||
pageId: string;
|
|
||||||
readOnly?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PublishTab({ pageId, readOnly }: PublishTabProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { pageSlug, spaceSlug } = useParams();
|
|
||||||
const { isTrial } = useTrial();
|
|
||||||
|
|
||||||
const { data: share } = useShareForPageQuery(pageId);
|
|
||||||
const createShareMutation = useCreateShareMutation();
|
|
||||||
const updateShareMutation = useUpdateShareMutation();
|
|
||||||
const deleteShareMutation = useDeleteShareMutation();
|
|
||||||
|
|
||||||
const pageIsShared = share && share.level === 0;
|
|
||||||
const isDescendantShared = share && share.level > 0;
|
|
||||||
|
|
||||||
const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
|
|
||||||
|
|
||||||
const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsPagePublic(!!share);
|
|
||||||
}, [share, pageId]);
|
|
||||||
|
|
||||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
|
|
||||||
if (value) {
|
|
||||||
createShareMutation.mutateAsync({
|
|
||||||
pageId: pageId,
|
|
||||||
includeSubPages: true,
|
|
||||||
searchIndexing: false,
|
|
||||||
});
|
|
||||||
setIsPagePublic(value);
|
|
||||||
} else {
|
|
||||||
if (share && share.id) {
|
|
||||||
deleteShareMutation.mutateAsync(share.id);
|
|
||||||
setIsPagePublic(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubPagesChange = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
updateShareMutation.mutateAsync({
|
|
||||||
shareId: share.id,
|
|
||||||
includeSubPages: value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleIndexSearchChange = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
updateShareMutation.mutateAsync({
|
|
||||||
shareId: share.id,
|
|
||||||
searchIndexing: value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const shareLink = useMemo(
|
|
||||||
() => (
|
|
||||||
<Group my="sm" gap={4} wrap="nowrap">
|
|
||||||
<TextInput
|
|
||||||
variant="filled"
|
|
||||||
value={publicLink}
|
|
||||||
readOnly
|
|
||||||
rightSection={<CopyTextButton text={publicLink} />}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
/>
|
|
||||||
<ActionIcon
|
|
||||||
component="a"
|
|
||||||
variant="default"
|
|
||||||
target="_blank"
|
|
||||||
href={publicLink}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<IconExternalLink size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
[publicLink],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isCloud() && isTrial) {
|
|
||||||
return (
|
|
||||||
<Stack align="center" py="md">
|
|
||||||
<IconLock size={20} stroke={1.5} />
|
|
||||||
<Text size="sm" ta="center" fw={500}>
|
|
||||||
{t("Upgrade to share pages")}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
|
||||||
{t(
|
|
||||||
"Page sharing is available on paid plans. Upgrade to share your pages publicly.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Button size="xs" onClick={() => navigate("/settings/billing")}>
|
|
||||||
{t("Upgrade Plan")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDescendantShared) {
|
|
||||||
return (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text size="sm">{t("Inherits public sharing from")}</Text>
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
underline="never"
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--mantine-color-text)",
|
|
||||||
}}
|
|
||||||
component={Link}
|
|
||||||
to={buildPageUrl(
|
|
||||||
spaceSlug,
|
|
||||||
share.sharedPage.slugId,
|
|
||||||
share.sharedPage.title,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Group gap="4" wrap="nowrap">
|
|
||||||
{getPageIcon(share.sharedPage.icon)}
|
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
|
||||||
{share.sharedPage.title || t("untitled")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Anchor>
|
|
||||||
{shareLink}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="sm">
|
|
||||||
{isPagePublic ? t("Shared to web") : t("Share to web")}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{isPagePublic
|
|
||||||
? t("Anyone with the link can view this page")
|
|
||||||
: t("Make this page publicly accessible")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
onChange={handleChange}
|
|
||||||
checked={isPagePublic}
|
|
||||||
disabled={readOnly}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{pageIsShared && (
|
|
||||||
<>
|
|
||||||
{shareLink}
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="sm">{t("Include sub-pages")}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("Make sub-pages public too")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
onChange={handleSubPagesChange}
|
|
||||||
checked={share.includeSubPages}
|
|
||||||
size="xs"
|
|
||||||
disabled={readOnly}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="sm">{t("Search engine indexing")}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("Allow search engines to index page")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
onChange={handleIndexSearchChange}
|
|
||||||
checked={share.searchIndexing}
|
|
||||||
size="xs"
|
|
||||||
disabled={readOnly}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
|
|
||||||
import {
|
|
||||||
SpaceCaslAction,
|
|
||||||
SpaceCaslSubject,
|
|
||||||
} from "@/features/space/permissions/permissions.type";
|
|
||||||
import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-permission-query";
|
|
||||||
|
|
||||||
export function usePagePermission(pageId: string, spaceRules: any) {
|
|
||||||
const spaceAbility = useSpaceAbility(spaceRules);
|
|
||||||
const { data: restrictionInfo, isLoading } =
|
|
||||||
usePageRestrictionInfoQuery(pageId);
|
|
||||||
|
|
||||||
if (isLoading || !restrictionInfo) {
|
|
||||||
return { canEdit: false, restrictionInfo: undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasRestriction =
|
|
||||||
restrictionInfo.hasDirectRestriction ||
|
|
||||||
restrictionInfo.hasInheritedRestriction;
|
|
||||||
|
|
||||||
const canEdit = hasRestriction
|
|
||||||
? (restrictionInfo.userAccess?.canEdit ?? false)
|
|
||||||
: spaceAbility.can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
|
|
||||||
|
|
||||||
return { canEdit, restrictionInfo };
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export * from "./components/page-share-modal";
|
|
||||||
export * from "./components/page-permission-tab";
|
|
||||||
export * from "./components/publish-tab";
|
|
||||||
export * from "./components/page-permission-list";
|
|
||||||
export * from "./components/page-permission-item";
|
|
||||||
export * from "./components/general-access-select";
|
|
||||||
export * from "./hooks/use-page-permission";
|
|
||||||
export * from "./queries/page-permission-query";
|
|
||||||
export * from "./services/page-permission-service";
|
|
||||||
export * from "./types/page-permission.types";
|
|
||||||
export * from "./types/page-permission-role-data";
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import {
|
|
||||||
keepPreviousData,
|
|
||||||
useMutation,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
UseQueryResult,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
IAddPagePermission,
|
|
||||||
IPagePermissionMember,
|
|
||||||
IPageRestrictionInfo,
|
|
||||||
IRemovePagePermission,
|
|
||||||
IUpdatePagePermissionRole,
|
|
||||||
} from "@/ee/page-permission/types/page-permission.types";
|
|
||||||
import {
|
|
||||||
addPagePermission,
|
|
||||||
getPagePermissions,
|
|
||||||
getPageRestrictionInfo,
|
|
||||||
removePagePermission,
|
|
||||||
restrictPage,
|
|
||||||
unrestrictPage,
|
|
||||||
updatePagePermissionRole,
|
|
||||||
} from "@/ee/page-permission/services/page-permission-service";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { IPagination, QueryParams } from "@/lib/types";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export function usePageRestrictionInfoQuery(
|
|
||||||
pageId: string | undefined,
|
|
||||||
): UseQueryResult<IPageRestrictionInfo, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["page-restriction-info", pageId],
|
|
||||||
queryFn: () => getPageRestrictionInfo(pageId),
|
|
||||||
enabled: !!pageId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePagePermissionsQuery(
|
|
||||||
pageId: string,
|
|
||||||
params?: QueryParams,
|
|
||||||
): UseQueryResult<IPagination<IPagePermissionMember>, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["page-permissions", pageId, params],
|
|
||||||
queryFn: () => getPagePermissions(pageId, params),
|
|
||||||
enabled: !!pageId,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRestrictPageMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<void, Error, string>({
|
|
||||||
mutationFn: (pageId) => restrictPage(pageId),
|
|
||||||
onSuccess: (_, pageId) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["page-restriction-info", pageId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["page-permissions", pageId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to restrict page"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUnrestrictPageMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<void, Error, string>({
|
|
||||||
mutationFn: (pageId) => unrestrictPage(pageId),
|
|
||||||
onSuccess: (_, pageId) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["page-restriction-info", pageId],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["page-permissions", pageId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to remove page restriction"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAddPagePermissionMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<void, Error, IAddPagePermission>({
|
|
||||||
mutationFn: (data) => addPagePermission(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["page-permissions", variables.pageId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to add permission"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRemovePagePermissionMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<void, Error, IRemovePagePermission>({
|
|
||||||
mutationFn: (data) => removePagePermission(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["page-permissions", variables.pageId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to remove permission"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdatePagePermissionRoleMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<void, Error, IUpdatePagePermissionRole>({
|
|
||||||
mutationFn: (data) => updatePagePermissionRole(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.refetchQueries({
|
|
||||||
queryKey: ["page-permissions", variables.pageId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to update permission"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import { IPagination, QueryParams } from "@/lib/types";
|
|
||||||
import {
|
|
||||||
IAddPagePermission,
|
|
||||||
IPagePermissionMember,
|
|
||||||
IPageRestrictionInfo,
|
|
||||||
IRemovePagePermission,
|
|
||||||
IUpdatePagePermissionRole,
|
|
||||||
} from "@/ee/page-permission/types/page-permission.types";
|
|
||||||
|
|
||||||
export async function restrictPage(pageId: string): Promise<void> {
|
|
||||||
await api.post("/pages/restrict", { pageId });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addPagePermission(
|
|
||||||
data: IAddPagePermission,
|
|
||||||
): Promise<void> {
|
|
||||||
await api.post("/pages/add-permission", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removePagePermission(
|
|
||||||
data: IRemovePagePermission,
|
|
||||||
): Promise<void> {
|
|
||||||
await api.post("/pages/remove-permission", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updatePagePermissionRole(
|
|
||||||
data: IUpdatePagePermissionRole,
|
|
||||||
): Promise<void> {
|
|
||||||
await api.post("/pages/update-permission", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unrestrictPage(pageId: string): Promise<void> {
|
|
||||||
await api.post("/pages/remove-restriction", { pageId });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPagePermissions(
|
|
||||||
pageId: string,
|
|
||||||
params?: QueryParams,
|
|
||||||
): Promise<IPagination<IPagePermissionMember>> {
|
|
||||||
const req = await api.post<IPagination<IPagePermissionMember>>(
|
|
||||||
"/pages/permissions",
|
|
||||||
{ pageId, ...params },
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPageRestrictionInfo(
|
|
||||||
pageId: string,
|
|
||||||
): Promise<IPageRestrictionInfo> {
|
|
||||||
const req = await api.post<IPageRestrictionInfo>("/pages/permission-info", {
|
|
||||||
pageId,
|
|
||||||
});
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { IRoleData } from "@/lib/types";
|
|
||||||
import { PagePermissionRole } from "./page-permission.types";
|
|
||||||
|
|
||||||
export const pagePermissionRoleData: IRoleData[] = [
|
|
||||||
{
|
|
||||||
label: "Can edit",
|
|
||||||
value: PagePermissionRole.WRITER,
|
|
||||||
description: "Can edit page and manage access",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Can view",
|
|
||||||
value: PagePermissionRole.READER,
|
|
||||||
description: "Can only view page",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getPagePermissionRoleLabel(value: string): string | undefined {
|
|
||||||
const role = pagePermissionRoleData.find((item) => item.value === value);
|
|
||||||
return role ? role.label : undefined;
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
export enum PagePermissionRole {
|
|
||||||
READER = "reader",
|
|
||||||
WRITER = "writer",
|
|
||||||
}
|
|
||||||
|
|
||||||
export type IAddPagePermission = {
|
|
||||||
pageId: string;
|
|
||||||
role: PagePermissionRole;
|
|
||||||
userIds?: string[];
|
|
||||||
groupIds?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IRemovePagePermission = {
|
|
||||||
pageId: string;
|
|
||||||
userIds?: string[];
|
|
||||||
groupIds?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IUpdatePagePermissionRole = {
|
|
||||||
pageId: string;
|
|
||||||
role: PagePermissionRole;
|
|
||||||
userId?: string;
|
|
||||||
groupId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IPageRestrictionInfo = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
hasDirectRestriction: boolean;
|
|
||||||
hasInheritedRestriction: boolean;
|
|
||||||
userAccess: {
|
|
||||||
canView: boolean;
|
|
||||||
canEdit: boolean;
|
|
||||||
canManage: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type IPagePermissionBase = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IPagePermissionUser = IPagePermissionBase & {
|
|
||||||
type: "user";
|
|
||||||
email: string;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IPagePermissionGroup = IPagePermissionBase & {
|
|
||||||
type: "group";
|
|
||||||
memberCount: number;
|
|
||||||
isDefault: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IPagePermissionMember = IPagePermissionUser | IPagePermissionGroup;
|
|
||||||
@@ -17,6 +17,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
|
|
||||||
function CommentListWithTabs() {
|
function CommentListWithTabs() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -33,7 +38,14 @@ function CommentListWithTabs() {
|
|||||||
const isCloudEE = useIsCloudEE();
|
const isCloudEE = useIsCloudEE();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
const canComment = page?.permissions?.canEdit ?? false;
|
const spaceRules = space?.membership?.permissions;
|
||||||
|
const spaceAbility = useSpaceAbility(spaceRules);
|
||||||
|
|
||||||
|
|
||||||
|
const canComment: boolean = spaceAbility.can(
|
||||||
|
SpaceCaslAction.Manage,
|
||||||
|
SpaceCaslSubject.Page
|
||||||
|
);
|
||||||
|
|
||||||
// Separate active and resolved comments
|
// Separate active and resolved comments
|
||||||
const { activeComments, resolvedComments } = useMemo(() => {
|
const { activeComments, resolvedComments } = useMemo(() => {
|
||||||
@@ -42,14 +54,14 @@ function CommentListWithTabs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parentComments = comments.items.filter(
|
const parentComments = comments.items.filter(
|
||||||
(comment: IComment) => comment.parentCommentId === null,
|
(comment: IComment) => comment.parentCommentId === null
|
||||||
);
|
);
|
||||||
|
|
||||||
const active = parentComments.filter(
|
const active = parentComments.filter(
|
||||||
(comment: IComment) => !comment.resolvedAt,
|
(comment: IComment) => !comment.resolvedAt
|
||||||
);
|
);
|
||||||
const resolved = parentComments.filter(
|
const resolved = parentComments.filter(
|
||||||
(comment: IComment) => comment.resolvedAt,
|
(comment: IComment) => comment.resolvedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
return { activeComments: active, resolvedComments: resolved };
|
return { activeComments: active, resolvedComments: resolved };
|
||||||
@@ -77,7 +89,7 @@ function CommentListWithTabs() {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id],
|
[createCommentMutation, page?.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderComments = useCallback(
|
const renderComments = useCallback(
|
||||||
@@ -119,7 +131,7 @@ function CommentListWithTabs() {
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
),
|
),
|
||||||
[comments, handleAddReply, isLoading, space?.membership?.role],
|
[comments, handleAddReply, isLoading, space?.membership?.role]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isCommentsLoading) {
|
if (isCommentsLoading) {
|
||||||
@@ -187,14 +199,7 @@ function CommentListWithTabs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
|
||||||
style={{
|
|
||||||
height: "85vh",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
marginTop: "-15px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
|
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
|
||||||
<Tabs.List justify="center">
|
<Tabs.List justify="center">
|
||||||
<Tabs.Tab
|
<Tabs.Tab
|
||||||
@@ -268,9 +273,9 @@ const ChildComments = ({
|
|||||||
const getChildComments = useCallback(
|
const getChildComments = useCallback(
|
||||||
(parentId: string) =>
|
(parentId: string) =>
|
||||||
comments.items.filter(
|
comments.items.filter(
|
||||||
(comment: IComment) => comment.parentCommentId === parentId,
|
(comment: IComment) => comment.parentCommentId === parentId
|
||||||
),
|
),
|
||||||
[comments.items],
|
[comments.items]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,16 +7,19 @@ import {
|
|||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
IconAlertTriangleFilled,
|
IconAlertTriangleFilled,
|
||||||
IconCircleCheckFilled,
|
IconCircleCheckFilled,
|
||||||
IconCircleXFilled,
|
IconCircleXFilled,
|
||||||
IconInfoCircleFilled,
|
IconInfoCircleFilled,
|
||||||
IconMoodSmile,
|
IconMoodSmile,
|
||||||
|
IconNotes,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { CalloutType } from "@docmost/editor-ext";
|
import { CalloutType, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
export function CalloutMenu({ editor }: EditorMenuProps) {
|
export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -26,6 +29,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
if (!state) {
|
if (!state) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (isTextSelected(editor)) return false;
|
||||||
|
|
||||||
return editor.isActive("callout");
|
return editor.isActive("callout");
|
||||||
},
|
},
|
||||||
@@ -42,6 +46,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
return {
|
return {
|
||||||
isCallout: ctx.editor.isActive("callout"),
|
isCallout: ctx.editor.isActive("callout"),
|
||||||
isInfo: ctx.editor.isActive("callout", { type: "info" }),
|
isInfo: ctx.editor.isActive("callout", { type: "info" }),
|
||||||
|
isNote: ctx.editor.isActive("callout", { type: "note" }),
|
||||||
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
|
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
|
||||||
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
|
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
|
||||||
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
|
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
|
||||||
@@ -126,15 +131,31 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group className="actionIconGroup">
|
<div className={classes.toolbar}>
|
||||||
<Tooltip position="top" label={t("Info")}>
|
<Tooltip position="top" label={t("Info")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => setCalloutType("info")}
|
onClick={() => setCalloutType("info")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Info")}
|
aria-label={t("Info")}
|
||||||
variant={editorState?.isInfo ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isInfo })}
|
||||||
>
|
>
|
||||||
<IconInfoCircleFilled size={18} />
|
<IconInfoCircleFilled
|
||||||
|
size={18}
|
||||||
|
color="var(--mantine-color-blue-5)"
|
||||||
|
/>
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Note")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() => setCalloutType("note")}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Note")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isNote })}
|
||||||
|
>
|
||||||
|
<IconNotes size={18} color="var(--mantine-color-grape-5)" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -143,9 +164,13 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={() => setCalloutType("success")}
|
onClick={() => setCalloutType("success")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Success")}
|
aria-label={t("Success")}
|
||||||
variant={editorState?.isSuccess ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isSuccess })}
|
||||||
>
|
>
|
||||||
<IconCircleCheckFilled size={18} />
|
<IconCircleCheckFilled
|
||||||
|
size={18}
|
||||||
|
color="var(--mantine-color-green-5)"
|
||||||
|
/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -154,9 +179,13 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={() => setCalloutType("warning")}
|
onClick={() => setCalloutType("warning")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Warning")}
|
aria-label={t("Warning")}
|
||||||
variant={editorState?.isWarning ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isWarning })}
|
||||||
>
|
>
|
||||||
<IconAlertTriangleFilled size={18} />
|
<IconAlertTriangleFilled
|
||||||
|
size={18}
|
||||||
|
color="var(--mantine-color-orange-5)"
|
||||||
|
/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -165,9 +194,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={() => setCalloutType("danger")}
|
onClick={() => setCalloutType("danger")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Danger")}
|
aria-label={t("Danger")}
|
||||||
variant={editorState?.isDanger ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isDanger })}
|
||||||
>
|
>
|
||||||
<IconCircleXFilled size={18} />
|
<IconCircleXFilled size={18} color="var(--mantine-color-red-5)" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -178,11 +208,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
icon={currentIcon || <IconMoodSmile size={18} />}
|
icon={currentIcon || <IconMoodSmile size={18} />}
|
||||||
actionIconProps={{
|
actionIconProps={{
|
||||||
size: "lg",
|
size: "lg",
|
||||||
variant: "default",
|
variant: "subtle",
|
||||||
c: undefined,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ActionIcon.Group>
|
</div>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IconCircleCheckFilled,
|
IconCircleCheckFilled,
|
||||||
IconCircleXFilled,
|
IconCircleXFilled,
|
||||||
IconInfoCircleFilled,
|
IconInfoCircleFilled,
|
||||||
|
IconNotes,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Alert } from "@mantine/core";
|
import { Alert } from "@mantine/core";
|
||||||
import classes from "./callout.module.css";
|
import classes from "./callout.module.css";
|
||||||
@@ -22,6 +23,7 @@ export default function CalloutView(props: NodeViewProps) {
|
|||||||
icon={getCalloutIcon(type, icon)}
|
icon={getCalloutIcon(type, icon)}
|
||||||
p="xs"
|
p="xs"
|
||||||
classNames={{
|
classNames={{
|
||||||
|
root: classes.root,
|
||||||
message: classes.message,
|
message: classes.message,
|
||||||
icon: classes.icon,
|
icon: classes.icon,
|
||||||
}}
|
}}
|
||||||
@@ -34,12 +36,14 @@ export default function CalloutView(props: NodeViewProps) {
|
|||||||
|
|
||||||
function getCalloutIcon(type: CalloutType, customIcon?: string) {
|
function getCalloutIcon(type: CalloutType, customIcon?: string) {
|
||||||
if (customIcon && customIcon.trim() !== "") {
|
if (customIcon && customIcon.trim() !== "") {
|
||||||
return <span style={{ fontSize: '18px' }}>{customIcon}</span>;
|
return <span style={{ fontSize: "18px" }}>{customIcon}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "info":
|
case "info":
|
||||||
return <IconInfoCircleFilled />;
|
return <IconInfoCircleFilled />;
|
||||||
|
case "note":
|
||||||
|
return <IconNotes />;
|
||||||
case "success":
|
case "success":
|
||||||
return <IconCircleCheckFilled />;
|
return <IconCircleCheckFilled />;
|
||||||
case "warning":
|
case "warning":
|
||||||
@@ -55,6 +59,8 @@ function getCalloutColor(type: CalloutType) {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case "info":
|
case "info":
|
||||||
return "blue";
|
return "blue";
|
||||||
|
case "note":
|
||||||
|
return "grape";
|
||||||
case "success":
|
case "success":
|
||||||
return "green";
|
return "green";
|
||||||
case "warning":
|
case "warning":
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
.root {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin-inline-end: var(--mantine-spacing-md);
|
margin-inline-end: var(--mantine-spacing-xs);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -11,18 +15,8 @@
|
|||||||
.message {
|
.message {
|
||||||
font-size: var(--mantine-font-size-md);
|
font-size: var(--mantine-font-size-md);
|
||||||
color: var(--mantine-color-default-color);
|
color: var(--mantine-color-default-color);
|
||||||
|
overflow: visible;
|
||||||
white-space: nowrap;
|
text-overflow: unset;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
@mixin where-light {
|
|
||||||
color: var(--mantine-color-default-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin where-dark {
|
|
||||||
color: var(--mantine-color-default-color);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
|
import {
|
||||||
|
EditorMenuProps,
|
||||||
|
ShouldShowProps,
|
||||||
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
|
import { ActionIcon, Tooltip, Popover, Button } from "@mantine/core";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
IconChevronDown,
|
||||||
|
IconCheck,
|
||||||
|
IconColumns2,
|
||||||
|
IconColumns3,
|
||||||
|
IconLayoutSidebar,
|
||||||
|
IconLayoutSidebarRight,
|
||||||
|
IconLayoutAlignCenter,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { isTextSelected } from "@docmost/editor-ext";
|
||||||
|
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
|
type LayoutPreset = {
|
||||||
|
layout: ColumnsLayout;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const twoColumnPresets: LayoutPreset[] = [
|
||||||
|
{ layout: "two_equal", label: "Equal columns", icon: IconColumns2 },
|
||||||
|
{
|
||||||
|
layout: "two_left_sidebar",
|
||||||
|
label: "Left sidebar",
|
||||||
|
icon: IconLayoutSidebar,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout: "two_right_sidebar",
|
||||||
|
label: "Right sidebar",
|
||||||
|
icon: IconLayoutSidebarRight,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const threeColumnPresets: LayoutPreset[] = [
|
||||||
|
{ layout: "three_equal", label: "Equal columns", icon: IconColumns3 },
|
||||||
|
{
|
||||||
|
layout: "three_with_sidebars",
|
||||||
|
label: "Wide center",
|
||||||
|
icon: IconLayoutAlignCenter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout: "three_left_wide",
|
||||||
|
label: "Left wide",
|
||||||
|
icon: IconLayoutSidebarRight,
|
||||||
|
},
|
||||||
|
{ layout: "three_right_wide", label: "Right wide", icon: IconLayoutSidebar
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getPresetsForCount(count: number): LayoutPreset[] {
|
||||||
|
if (count === 2) return twoColumnPresets;
|
||||||
|
if (count === 3) return threeColumnPresets;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnsMenu({ editor }: EditorMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isCountOpen, setIsCountOpen] = useState(false);
|
||||||
|
|
||||||
|
const nodesWithMenus = [
|
||||||
|
"callout",
|
||||||
|
"image",
|
||||||
|
"video",
|
||||||
|
"drawio",
|
||||||
|
"excalidraw",
|
||||||
|
"table",
|
||||||
|
];
|
||||||
|
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) return false;
|
||||||
|
if (!editor.isActive("columns")) return false;
|
||||||
|
if (isTextSelected(editor)) return false;
|
||||||
|
if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
|
||||||
|
|
||||||
|
const parent = findParentNode(
|
||||||
|
(node: PMNode) => node.type.name === "columns",
|
||||||
|
)(state.selection);
|
||||||
|
if (!parent) return false;
|
||||||
|
|
||||||
|
const dom = editor.view.nodeDOM(parent.pos) as HTMLElement;
|
||||||
|
if (!dom) return false;
|
||||||
|
|
||||||
|
const rect = dom.getBoundingClientRect();
|
||||||
|
return rect.bottom > 0 && rect.top < window.innerHeight;
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editorState = useEditorState({
|
||||||
|
editor,
|
||||||
|
selector: (ctx) => {
|
||||||
|
if (!ctx.editor) return null;
|
||||||
|
|
||||||
|
const { selection } = ctx.editor.state;
|
||||||
|
const parent = findParentNode(
|
||||||
|
(node: PMNode) => node.type.name === "columns",
|
||||||
|
)(selection);
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnCount: parent?.node.childCount || 2,
|
||||||
|
layout: (parent?.node.attrs.layout as ColumnsLayout) || "two_equal",
|
||||||
|
isNormal: ctx.editor.isActive("columns", { widthMode: "normal" }),
|
||||||
|
isWide: ctx.editor.isActive("columns", { widthMode: "wide" }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const { selection } = editor.state;
|
||||||
|
const predicate = (node: PMNode) => node.type.name === "columns";
|
||||||
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
|
if (parent) {
|
||||||
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
|
const domRect = dom.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Columns entirely out of viewport — return real rect so menu goes off-screen
|
||||||
|
if (domRect.bottom <= 0 || domRect.top >= window.innerHeight) {
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp bottom so menu stays within viewport when columns extend below it
|
||||||
|
// 55px = 15px offset + ~40px menu height
|
||||||
|
const maxBottom = window.innerHeight - 55;
|
||||||
|
if (domRect.bottom > maxBottom) {
|
||||||
|
const clamped = new DOMRect(
|
||||||
|
domRect.x,
|
||||||
|
domRect.y,
|
||||||
|
domRect.width,
|
||||||
|
maxBottom - domRect.y,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => clamped,
|
||||||
|
getClientRects: () => [clamped],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const setColumnCount = useCallback(
|
||||||
|
(count: number) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setColumnCount(count)
|
||||||
|
.run();
|
||||||
|
setIsCountOpen(false);
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setLayout = useCallback(
|
||||||
|
(layout: ColumnsLayout) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setColumnsLayout(layout)
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnCount = editorState?.columnCount || 2;
|
||||||
|
const currentLayout = editorState?.layout || "two_equal";
|
||||||
|
const presets = getPresetsForCount(columnCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseBubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
pluginKey="columns-menu"
|
||||||
|
updateDelay={0}
|
||||||
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
|
options={{
|
||||||
|
placement: "bottom",
|
||||||
|
offset: {
|
||||||
|
mainAxis: 5,
|
||||||
|
},
|
||||||
|
flip: false,
|
||||||
|
}}
|
||||||
|
shouldShow={shouldShow}
|
||||||
|
>
|
||||||
|
<div className={classes.toolbar}>
|
||||||
|
<Popover opened={isCountOpen} onChange={setIsCountOpen} withArrow>
|
||||||
|
<Popover.Target>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="dark"
|
||||||
|
size="compact-sm"
|
||||||
|
rightSection={<IconChevronDown size={12} />}
|
||||||
|
onClick={() => setIsCountOpen(!isCountOpen)}
|
||||||
|
aria-label={t("Column count")}
|
||||||
|
>
|
||||||
|
{t("{{count}} Columns", { count: columnCount })}
|
||||||
|
</Button>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown p={4}>
|
||||||
|
<Button.Group orientation="vertical">
|
||||||
|
{[2, 3, 4, 5].map((n) => (
|
||||||
|
<Button
|
||||||
|
key={n}
|
||||||
|
variant={n === columnCount ? "light" : "subtle"}
|
||||||
|
color={n === columnCount ? "blue" : "dark"}
|
||||||
|
justify="space-between"
|
||||||
|
fullWidth
|
||||||
|
rightSection={
|
||||||
|
n === columnCount ? <IconCheck size={14} /> : null
|
||||||
|
}
|
||||||
|
onClick={() => setColumnCount(n)}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{t("{{count}} Columns", { count: n })}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Button.Group>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{presets.length > 0 && <div className={classes.divider} />}
|
||||||
|
|
||||||
|
{presets.map((preset) => (
|
||||||
|
<Tooltip key={preset.layout} position="top" label={t(preset.label)}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() => setLayout(preset.layout)}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t(preset.label)}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({
|
||||||
|
[classes.active]: currentLayout === preset.layout,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<preset.icon size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ColumnsMenu;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
|
import classes from "./node-resize.module.css";
|
||||||
|
|
||||||
|
export function createResizeHandle(
|
||||||
|
direction: ResizableNodeViewDirection,
|
||||||
|
): HTMLElement {
|
||||||
|
const handle = document.createElement("div");
|
||||||
|
handle.dataset.resizeHandle = direction;
|
||||||
|
handle.style.position = "absolute";
|
||||||
|
handle.className = classes.handle;
|
||||||
|
|
||||||
|
if (direction === "left") {
|
||||||
|
handle.style.left = "-8px";
|
||||||
|
handle.style.top = "0";
|
||||||
|
handle.style.bottom = "0";
|
||||||
|
} else if (direction === "right") {
|
||||||
|
handle.style.right = "-8px";
|
||||||
|
handle.style.top = "0";
|
||||||
|
handle.style.bottom = "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
const bar = document.createElement("div");
|
||||||
|
bar.className = classes.handleBar;
|
||||||
|
handle.appendChild(bar);
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildResizeClasses(nodeClass: string) {
|
||||||
|
return {
|
||||||
|
container: `${classes.container} ${nodeClass}`,
|
||||||
|
wrapper: classes.wrapper,
|
||||||
|
resizing: classes.resizing,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: visible;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper img,
|
||||||
|
.wrapper video {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: ew-resize;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle[data-resize-handle="left"] {
|
||||||
|
left: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle[data-resize-handle="right"] {
|
||||||
|
right: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper:hover .handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing .handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handleBar {
|
||||||
|
width: 4px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle:hover .handleBar {
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing .handleBar {
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||||
|
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||||
|
box-shadow: 0 2px 12px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar :global(.mantine-ActionIcon-root) {
|
||||||
|
--ai-color: light-dark(var(--mantine-color-dark-7), var(--mantine-color-gray-4)) !important;
|
||||||
|
--ai-hover: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .active {
|
||||||
|
--ai-color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)) !important;
|
||||||
|
--ai-hover: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5)) !important;
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
align-self: center;
|
||||||
|
margin: 0 2px;
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3));
|
||||||
|
}
|
||||||
@@ -1,24 +1,41 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
import { ActionIcon, Modal, Tooltip, useComputedColorScheme } from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
IconLayoutAlignCenter,
|
||||||
|
IconLayoutAlignLeft,
|
||||||
|
IconLayoutAlignRight,
|
||||||
|
IconDownload,
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
|
||||||
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
|
import {
|
||||||
|
DrawIoEmbed,
|
||||||
|
DrawIoEmbedRef,
|
||||||
|
EventExit,
|
||||||
|
EventSave,
|
||||||
|
} from "react-drawio";
|
||||||
|
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
|
||||||
|
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
export function DrawioMenu({ editor }: EditorMenuProps) {
|
export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||||
const shouldShow = useCallback(
|
const { t } = useTranslation();
|
||||||
({ state }: ShouldShowProps) => {
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
if (!state) {
|
const [initialXML, setInitialXML] = useState<string>("");
|
||||||
return false;
|
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||||
}
|
const computedColorScheme = useComputedColorScheme();
|
||||||
|
|
||||||
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -30,11 +47,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
const drawioAttr = ctx.editor.getAttributes("drawio");
|
const drawioAttr = ctx.editor.getAttributes("drawio");
|
||||||
return {
|
return {
|
||||||
isDrawio: ctx.editor.isActive("drawio"),
|
isDrawio: ctx.editor.isActive("drawio"),
|
||||||
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
|
isAlignLeft: ctx.editor.isActive("drawio", { align: "left" }),
|
||||||
|
isAlignCenter: ctx.editor.isActive("drawio", { align: "center" }),
|
||||||
|
isAlignRight: ctx.editor.isActive("drawio", { align: "right" }),
|
||||||
|
src: drawioAttr?.src || null,
|
||||||
|
attachmentId: drawioAttr?.attachmentId || null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
@@ -57,38 +89,218 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const alignLeft = useCallback(() => {
|
||||||
(value: number) => {
|
editor
|
||||||
editor.commands.updateAttributes("drawio", { width: `${value}%` });
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setDrawioAlign("left")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignCenter = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setDrawioAlign("center")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignRight = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setDrawioAlign("right")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (!editorState?.src) return;
|
||||||
|
const url = getFileUrl(editorState.src);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "";
|
||||||
|
a.click();
|
||||||
|
}, [editorState?.src]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
editor.commands.deleteSelection();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(async () => {
|
||||||
|
if (!editorState?.src) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = getFileUrl(editorState.src);
|
||||||
|
const request = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const blob = await request.blob();
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
reader.onloadend = () => {
|
||||||
|
const base64data = (reader.result || "") as string;
|
||||||
|
setInitialXML(base64data);
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}, [editorState?.src, open]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(
|
||||||
|
async (data: EventSave) => {
|
||||||
|
const svgString = decodeBase64ToSvgString(data.xml);
|
||||||
|
const fileName = "diagram.drawio.svg";
|
||||||
|
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const pageId = editor.storage?.pageId;
|
||||||
|
const attachmentId = editorState?.attachmentId;
|
||||||
|
|
||||||
|
let attachment: IAttachment = null;
|
||||||
|
if (attachmentId) {
|
||||||
|
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
||||||
|
} else {
|
||||||
|
attachment = await uploadFile(drawioSVGFile, pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.commands.updateAttributes("drawio", {
|
||||||
|
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||||
|
title: attachment.fileName,
|
||||||
|
size: attachment.fileSize,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
close();
|
||||||
},
|
},
|
||||||
[editor],
|
[editor, editorState?.attachmentId, close],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<>
|
||||||
editor={editor}
|
<BaseBubbleMenu
|
||||||
pluginKey={`drawio-menu`}
|
editor={editor}
|
||||||
updateDelay={0}
|
pluginKey={`drawio-menu`}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
updateDelay={0}
|
||||||
options={{
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
placement: "top",
|
options={{
|
||||||
offset: 8,
|
placement: "top",
|
||||||
flip: false,
|
offset: 8,
|
||||||
}}
|
flip: false,
|
||||||
shouldShow={shouldShow}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
}}
|
||||||
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
{editorState?.width && (
|
<div className={classes.toolbar}>
|
||||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
<Tooltip position="top" label={t("Align left")}>
|
||||||
)}
|
<ActionIcon
|
||||||
</div>
|
onClick={alignLeft}
|
||||||
</BaseBubbleMenu>
|
size="lg"
|
||||||
|
aria-label={t("Align left")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignLeft size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Align center")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignCenter}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align center")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignCenter size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Align right")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignRight}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align right")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignRight size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Edit")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleOpen}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Edit")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Download")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Download")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconDownload size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDelete}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
|
||||||
|
<Modal.Root opened={opened} onClose={close} fullScreen>
|
||||||
|
<Modal.Overlay />
|
||||||
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
|
<Modal.Body>
|
||||||
|
<div style={{ height: "100vh" }}>
|
||||||
|
<DrawIoEmbed
|
||||||
|
ref={drawioRef}
|
||||||
|
xml={initialXML}
|
||||||
|
baseUrl={getDrawioUrl()}
|
||||||
|
urlParameters={{
|
||||||
|
ui: computedColorScheme === "light" ? "kennedy" : "dark",
|
||||||
|
spin: true,
|
||||||
|
libraries: true,
|
||||||
|
saveAndExit: true,
|
||||||
|
noSaveBtn: true,
|
||||||
|
}}
|
||||||
|
onSave={(data: EventSave) => {
|
||||||
|
if (data.parentEvent !== "save") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleSave(data);
|
||||||
|
}}
|
||||||
|
onClose={(data: EventExit) => {
|
||||||
|
if (data.parentEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Card,
|
Card,
|
||||||
Image,
|
|
||||||
Modal,
|
Modal,
|
||||||
Text,
|
Text,
|
||||||
useComputedColorScheme,
|
useComputedColorScheme,
|
||||||
@@ -10,7 +9,7 @@ import {
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
|
import { getDrawioUrl } from "@/lib/config.ts";
|
||||||
import {
|
import {
|
||||||
DrawIoEmbed,
|
DrawIoEmbed,
|
||||||
DrawIoEmbedRef,
|
DrawIoEmbedRef,
|
||||||
@@ -26,7 +25,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
export default function DrawioView(props: NodeViewProps) {
|
export default function DrawioView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, updateAttributes, editor, selected } = props;
|
const { node, updateAttributes, editor, selected } = props;
|
||||||
const { src, title, width, attachmentId } = node.attrs;
|
const { attachmentId } = node.attrs;
|
||||||
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||||
const [initialXML, setInitialXML] = useState<string>("");
|
const [initialXML, setInitialXML] = useState<string>("");
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
@@ -36,33 +35,11 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
open();
|
||||||
try {
|
|
||||||
if (src) {
|
|
||||||
const url = getFileUrl(src);
|
|
||||||
const request = await fetch(url, {
|
|
||||||
credentials: "include",
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
const blob = await request.blob();
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
reader.onloadend = () => {
|
|
||||||
const base64data = (reader.result || "") as string;
|
|
||||||
setInitialXML(base64data);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async (data: EventSave) => {
|
const handleSave = async (data: EventSave) => {
|
||||||
const svgString = decodeBase64ToSvgString(data.xml);
|
const svgString = decodeBase64ToSvgString(data.xml);
|
||||||
|
|
||||||
const fileName = "diagram.drawio.svg";
|
const fileName = "diagram.drawio.svg";
|
||||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
@@ -70,7 +47,6 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
|
|
||||||
let attachment: IAttachment = null;
|
let attachment: IAttachment = null;
|
||||||
|
|
||||||
if (attachmentId) {
|
if (attachmentId) {
|
||||||
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
||||||
} else {
|
} else {
|
||||||
@@ -106,14 +82,12 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
noSaveBtn: true,
|
noSaveBtn: true,
|
||||||
}}
|
}}
|
||||||
onSave={(data: EventSave) => {
|
onSave={(data: EventSave) => {
|
||||||
// If the save is triggered by another event, then do nothing
|
|
||||||
if (data.parentEvent !== "save") {
|
if (data.parentEvent !== "save") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleSave(data);
|
handleSave(data);
|
||||||
}}
|
}}
|
||||||
onClose={(data: EventExit) => {
|
onClose={(data: EventExit) => {
|
||||||
// If the exit is triggered by another event, then do nothing
|
|
||||||
if (data.parentEvent) {
|
if (data.parentEvent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -125,62 +99,28 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
</Modal.Root>
|
</Modal.Root>
|
||||||
|
|
||||||
{src ? (
|
<Card
|
||||||
<div style={{ position: "relative" }}>
|
radius="md"
|
||||||
<Image
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
p="xs"
|
||||||
radius="md"
|
style={{
|
||||||
fit="contain"
|
display: "flex",
|
||||||
w={width}
|
justifyContent: "center",
|
||||||
src={getFileUrl(src)}
|
alignItems: "center",
|
||||||
alt={title}
|
}}
|
||||||
className={clsx(
|
withBorder
|
||||||
selected ? "ProseMirror-selectednode" : "",
|
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||||
"alignCenter",
|
>
|
||||||
)}
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
/>
|
<ActionIcon variant="transparent" color="gray">
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
{selected && editor.isEditable && (
|
<Text component="span" size="lg" c="dimmed">
|
||||||
<ActionIcon
|
{t("Double-click to edit Draw.io diagram")}
|
||||||
onClick={handleOpen}
|
</Text>
|
||||||
variant="default"
|
|
||||||
color="gray"
|
|
||||||
mx="xs"
|
|
||||||
className="print-hide"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</Card>
|
||||||
<Card
|
|
||||||
radius="md"
|
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
withBorder
|
|
||||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center" }}>
|
|
||||||
<ActionIcon variant="transparent" color="gray">
|
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<Text component="span" size="lg" c="dimmed">
|
|
||||||
{t("Double-click to edit Draw.io diagram")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,57 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { lazy, Suspense, useCallback, useState } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Tooltip,
|
||||||
|
useComputedColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
IconLayoutAlignCenter,
|
||||||
|
IconLayoutAlignLeft,
|
||||||
|
IconLayoutAlignRight,
|
||||||
|
IconDownload,
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
|
import { svgStringToFile } from "@/lib";
|
||||||
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||||
|
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||||
|
import ReactClearModal from "react-clear-modal";
|
||||||
|
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||||
|
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
|
const ExcalidrawComponent = lazy(() =>
|
||||||
|
import("@excalidraw/excalidraw").then((module) => ({
|
||||||
|
default: module.Excalidraw,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||||
const shouldShow = useCallback(
|
const { t } = useTranslation();
|
||||||
({ state }: ShouldShowProps) => {
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
if (!state) {
|
const [excalidrawAPI, setExcalidrawAPI] =
|
||||||
return false;
|
useState<ExcalidrawImperativeAPI>(null);
|
||||||
}
|
useHandleLibrary({
|
||||||
|
excalidrawAPI,
|
||||||
return (
|
adapter: localStorageLibraryAdapter,
|
||||||
editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
|
});
|
||||||
);
|
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||||
},
|
const computedColorScheme = useComputedColorScheme();
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -32,11 +63,29 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
|
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
|
||||||
return {
|
return {
|
||||||
isExcalidraw: ctx.editor.isActive("excalidraw"),
|
isExcalidraw: ctx.editor.isActive("excalidraw"),
|
||||||
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
|
isAlignLeft: ctx.editor.isActive("excalidraw", { align: "left" }),
|
||||||
|
isAlignCenter: ctx.editor.isActive("excalidraw", { align: "center" }),
|
||||||
|
isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }),
|
||||||
|
src: excalidrawAttr?.src || null,
|
||||||
|
attachmentId: excalidrawAttr?.attachmentId || null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
editor.isActive("excalidraw") &&
|
||||||
|
editor.getAttributes("excalidraw")?.src
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
@@ -59,38 +108,248 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const alignLeft = useCallback(() => {
|
||||||
(value: number) => {
|
editor
|
||||||
editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
|
.chain()
|
||||||
},
|
.focus(undefined, { scrollIntoView: false })
|
||||||
[editor],
|
.setExcalidrawAlign("left")
|
||||||
);
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignCenter = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setExcalidrawAlign("center")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignRight = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setExcalidrawAlign("right")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (!editorState?.src) return;
|
||||||
|
const url = getFileUrl(editorState.src);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "";
|
||||||
|
a.click();
|
||||||
|
}, [editorState?.src]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
editor.commands.deleteSelection();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(async () => {
|
||||||
|
if (!editorState?.src) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = getFileUrl(editorState.src);
|
||||||
|
const request = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { loadFromBlob } = await import("@excalidraw/excalidraw");
|
||||||
|
const data = await loadFromBlob(await request.blob(), null, null);
|
||||||
|
setExcalidrawData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}, [editorState?.src, open]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!excalidrawAPI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { exportToSvg } = await import("@excalidraw/excalidraw");
|
||||||
|
|
||||||
|
const svg = await exportToSvg({
|
||||||
|
elements: excalidrawAPI?.getSceneElements(),
|
||||||
|
appState: {
|
||||||
|
exportEmbedScene: true,
|
||||||
|
exportWithDarkMode: false,
|
||||||
|
},
|
||||||
|
files: excalidrawAPI?.getFiles(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const serializer = new XMLSerializer();
|
||||||
|
let svgString = serializer.serializeToString(svg);
|
||||||
|
|
||||||
|
svgString = svgString.replace(
|
||||||
|
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
|
||||||
|
"https://unpkg.com/@excalidraw/excalidraw@latest",
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileName = "diagram.excalidraw.svg";
|
||||||
|
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const pageId = editor.storage?.pageId;
|
||||||
|
const attachmentId = editorState?.attachmentId;
|
||||||
|
|
||||||
|
let attachment: IAttachment = null;
|
||||||
|
if (attachmentId) {
|
||||||
|
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
|
||||||
|
} else {
|
||||||
|
attachment = await uploadFile(excalidrawSvgFile, pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.commands.updateAttributes("excalidraw", {
|
||||||
|
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||||
|
title: attachment.fileName,
|
||||||
|
size: attachment.fileSize,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
close();
|
||||||
|
}, [editor, excalidrawAPI, editorState?.attachmentId, close]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<>
|
||||||
editor={editor}
|
<BaseBubbleMenu
|
||||||
pluginKey={`excalidraw-menu`}
|
editor={editor}
|
||||||
updateDelay={0}
|
pluginKey={`excalidraw-menu`}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
updateDelay={0}
|
||||||
options={{
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
placement: "top",
|
options={{
|
||||||
offset: 8,
|
placement: "top",
|
||||||
flip: false,
|
offset: 8,
|
||||||
}}
|
flip: false,
|
||||||
shouldShow={shouldShow}
|
}}
|
||||||
>
|
shouldShow={shouldShow}
|
||||||
<div
|
>
|
||||||
|
<div className={classes.toolbar}>
|
||||||
|
<Tooltip position="top" label={t("Align left")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignLeft}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align left")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({
|
||||||
|
[classes.active]: editorState?.isAlignLeft,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignLeft size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Align center")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignCenter}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align center")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({
|
||||||
|
[classes.active]: editorState?.isAlignCenter,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignCenter size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Align right")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignRight}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align right")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({
|
||||||
|
[classes.active]: editorState?.isAlignRight,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignRight size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Edit")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleOpen}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Edit")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Download")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Download")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconDownload size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDelete}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
|
||||||
|
<ReactClearModal
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
flexDirection: "column",
|
padding: 0,
|
||||||
alignItems: "center",
|
zIndex: 200,
|
||||||
|
}}
|
||||||
|
isOpen={opened}
|
||||||
|
onRequestClose={close}
|
||||||
|
disableCloseOnBgClick={true}
|
||||||
|
contentProps={{
|
||||||
|
style: {
|
||||||
|
padding: 0,
|
||||||
|
width: "90vw",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{editorState?.width && (
|
<Group
|
||||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
justify="flex-end"
|
||||||
)}
|
wrap="nowrap"
|
||||||
</div>
|
bg="var(--mantine-color-body)"
|
||||||
</BaseBubbleMenu>
|
p="xs"
|
||||||
|
>
|
||||||
|
<Button onClick={handleSave} size={"compact-sm"}>
|
||||||
|
{t("Save & Exit")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={close} color="red" size={"compact-sm"}>
|
||||||
|
{t("Exit")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<div style={{ height: "90vh" }}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ExcalidrawComponent
|
||||||
|
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
|
initialData={{
|
||||||
|
...excalidrawData,
|
||||||
|
scrollToContent: true,
|
||||||
|
}}
|
||||||
|
theme={computedColorScheme}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</ReactClearModal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,28 +4,24 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
|
||||||
Text,
|
Text,
|
||||||
useComputedColorScheme,
|
useComputedColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
import { svgStringToFile } from "@/lib";
|
import { svgStringToFile } from "@/lib";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||||
import ReactClearModal from "react-clear-modal";
|
import ReactClearModal from "react-clear-modal";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
import { lazy } from "react";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||||
|
|
||||||
const Excalidraw = lazy(() =>
|
const ExcalidrawComponent = lazy(() =>
|
||||||
import("@excalidraw/excalidraw").then((module) => ({
|
import("@excalidraw/excalidraw").then((module) => ({
|
||||||
default: module.Excalidraw,
|
default: module.Excalidraw,
|
||||||
})),
|
})),
|
||||||
@@ -34,7 +30,7 @@ const Excalidraw = lazy(() =>
|
|||||||
export default function ExcalidrawView(props: NodeViewProps) {
|
export default function ExcalidrawView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, updateAttributes, editor, selected } = props;
|
const { node, updateAttributes, editor, selected } = props;
|
||||||
const { src, title, width, attachmentId } = node.attrs;
|
const { attachmentId } = node.attrs;
|
||||||
|
|
||||||
const [excalidrawAPI, setExcalidrawAPI] =
|
const [excalidrawAPI, setExcalidrawAPI] =
|
||||||
useState<ExcalidrawImperativeAPI>(null);
|
useState<ExcalidrawImperativeAPI>(null);
|
||||||
@@ -50,25 +46,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
open();
|
||||||
try {
|
|
||||||
if (src) {
|
|
||||||
const url = getFileUrl(src);
|
|
||||||
const request = await fetch(url, {
|
|
||||||
credentials: "include",
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { loadFromBlob } = await import("@excalidraw/excalidraw");
|
|
||||||
|
|
||||||
const data = await loadFromBlob(await request.blob(), null, null);
|
|
||||||
setExcalidrawData(data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -151,7 +129,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
<div style={{ height: "90vh" }}>
|
<div style={{ height: "90vh" }}>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Excalidraw
|
<ExcalidrawComponent
|
||||||
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
initialData={{
|
initialData={{
|
||||||
...excalidrawData,
|
...excalidrawData,
|
||||||
@@ -163,62 +141,28 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</ReactClearModal>
|
</ReactClearModal>
|
||||||
|
|
||||||
{src ? (
|
<Card
|
||||||
<div style={{ position: "relative" }}>
|
radius="md"
|
||||||
<Image
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
p="xs"
|
||||||
radius="md"
|
style={{
|
||||||
fit="contain"
|
display: "flex",
|
||||||
w={width}
|
justifyContent: "center",
|
||||||
src={getFileUrl(src)}
|
alignItems: "center",
|
||||||
alt={title}
|
}}
|
||||||
className={clsx(
|
withBorder
|
||||||
selected ? "ProseMirror-selectednode" : "",
|
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||||
"alignCenter",
|
>
|
||||||
)}
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
/>
|
<ActionIcon variant="transparent" color="gray">
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
{selected && editor.isEditable && (
|
<Text component="span" size="lg" c="dimmed">
|
||||||
<ActionIcon
|
{t("Double-click to edit Excalidraw diagram")}
|
||||||
onClick={handleOpen}
|
</Text>
|
||||||
variant="default"
|
|
||||||
color="gray"
|
|
||||||
mx="xs"
|
|
||||||
className="print-hide"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</Card>
|
||||||
<Card
|
|
||||||
radius="md"
|
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
withBorder
|
|
||||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center" }}>
|
|
||||||
<ActionIcon variant="transparent" color="gray">
|
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<Text component="span" size="lg" c="dimmed">
|
|
||||||
{t("Double-click to edit Excalidraw diagram")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback, useRef } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
IconLayoutAlignCenter,
|
IconLayoutAlignCenter,
|
||||||
IconLayoutAlignLeft,
|
IconLayoutAlignLeft,
|
||||||
IconLayoutAlignRight,
|
IconLayoutAlignRight,
|
||||||
|
IconDownload,
|
||||||
|
IconRefresh,
|
||||||
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
|
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -32,7 +39,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
|
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
|
||||||
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
|
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
|
||||||
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
||||||
width: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
|
src: imageAttrs?.src || null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -94,17 +101,40 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const handleDownload = useCallback(() => {
|
||||||
(value: number) => {
|
if (!editorState?.src) return;
|
||||||
editor
|
const url = getFileUrl(editorState.src);
|
||||||
.chain()
|
const a = document.createElement("a");
|
||||||
.focus(undefined, { scrollIntoView: false })
|
a.href = url;
|
||||||
.setImageWidth(value)
|
a.download = "";
|
||||||
.run();
|
a.click();
|
||||||
|
}, [editorState?.src]);
|
||||||
|
|
||||||
|
const handleReplace = useCallback(() => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const pageId = editor.storage?.pageId;
|
||||||
|
if (pageId) {
|
||||||
|
const pos = editor.state.selection.from;
|
||||||
|
uploadImageAction(file, editor, pos, pageId);
|
||||||
|
}
|
||||||
|
// Reset so the same file can be selected again
|
||||||
|
e.target.value = "";
|
||||||
},
|
},
|
||||||
[editor],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
editor.commands.deleteSelection();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@@ -118,13 +148,14 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group className="actionIconGroup">
|
<div className={classes.toolbar}>
|
||||||
<Tooltip position="top" label={t("Align left")}>
|
<Tooltip position="top" label={t("Align left")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignImageLeft}
|
onClick={alignImageLeft}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align left")}
|
aria-label={t("Align left")}
|
||||||
variant={editorState?.isAlignLeft ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
||||||
>
|
>
|
||||||
<IconLayoutAlignLeft size={18} />
|
<IconLayoutAlignLeft size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -135,7 +166,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={alignImageCenter}
|
onClick={alignImageCenter}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align center")}
|
aria-label={t("Align center")}
|
||||||
variant={editorState?.isAlignCenter ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
||||||
>
|
>
|
||||||
<IconLayoutAlignCenter size={18} />
|
<IconLayoutAlignCenter size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -146,16 +178,56 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={alignImageRight}
|
onClick={alignImageRight}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align right")}
|
aria-label={t("Align right")}
|
||||||
variant={editorState?.isAlignRight ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
||||||
>
|
>
|
||||||
<IconLayoutAlignRight size={18} />
|
<IconLayoutAlignRight size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
|
||||||
|
|
||||||
{editorState?.width && (
|
<div className={classes.divider} />
|
||||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
|
||||||
)}
|
<Tooltip position="top" label={t("Download")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Download")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconDownload size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Replace image")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleReplace}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Replace image")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconRefresh size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDelete}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import {
|
||||||
|
createResizeHandle,
|
||||||
|
buildResizeClasses,
|
||||||
|
} from "../common/node-resize-handles";
|
||||||
|
|
||||||
|
export const createImageHandle = createResizeHandle;
|
||||||
|
export const imageResizeClasses = buildResizeClasses("node-image");
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: visible;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper img {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: ew-resize;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle[data-resize-handle="left"] {
|
||||||
|
left: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle[data-resize-handle="right"] {
|
||||||
|
right: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper:hover .handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing .handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handleBar {
|
||||||
|
width: 4px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle:hover .handleBar {
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing .handleBar {
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconAppWindow,
|
IconAppWindow,
|
||||||
IconSitemap,
|
IconSitemap,
|
||||||
|
IconColumns3,
|
||||||
|
IconColumns2,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
@@ -31,6 +33,8 @@ import { uploadAttachmentAction } from "@/features/editor/components/attachment/
|
|||||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||||
import IconMermaid from "@/components/icons/icon-mermaid";
|
import IconMermaid from "@/components/icons/icon-mermaid";
|
||||||
import IconDrawio from "@/components/icons/icon-drawio";
|
import IconDrawio from "@/components/icons/icon-drawio";
|
||||||
|
import { IconColumns4 } from "@/components/icons/icon-columns-4";
|
||||||
|
import { IconColumns5 } from "@/components/icons/icon-columns-5";
|
||||||
import {
|
import {
|
||||||
AirtableIcon,
|
AirtableIcon,
|
||||||
FigmaIcon,
|
FigmaIcon,
|
||||||
@@ -390,6 +394,58 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "2 Columns",
|
||||||
|
description: "Split content into two columns.",
|
||||||
|
searchTerms: ["columns", "layout", "split", "side"],
|
||||||
|
icon: IconColumns2,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertColumns({ layout: "two_equal" })
|
||||||
|
.run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "3 Columns",
|
||||||
|
description: "Split content into three columns.",
|
||||||
|
searchTerms: ["columns", "layout", "split", "triple"],
|
||||||
|
icon: IconColumns3,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertColumns({ layout: "three_equal" })
|
||||||
|
.run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "4 Columns",
|
||||||
|
description: "Split content into four columns.",
|
||||||
|
searchTerms: ["columns", "layout", "split"],
|
||||||
|
icon: IconColumns4,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertColumns({ layout: "four_equal" })
|
||||||
|
.run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "5 Columns",
|
||||||
|
description: "Split content into five columns.",
|
||||||
|
searchTerms: ["columns", "layout", "split"],
|
||||||
|
icon: IconColumns5,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertColumns({ layout: "five_equal" })
|
||||||
|
.run(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Iframe embed",
|
title: "Iframe embed",
|
||||||
description: "Embed any Iframe",
|
description: "Embed any Iframe",
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
|||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={t("Background color")} withArrow>
|
<Tooltip label={t("Background color")} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Background color")}
|
aria-label={t("Background color")}
|
||||||
onClick={() => setOpened(!opened)}
|
onClick={() => setOpened(!opened)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { TableBackgroundColor } from "./table-background-color";
|
import { TableBackgroundColor } from "./table-background-color";
|
||||||
import { TableTextAlignment } from "./table-text-alignment";
|
import { TableTextAlignment } from "./table-text-alignment";
|
||||||
import { BubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu } from "@tiptap/react/menus";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
export const TableCellMenu = React.memo(
|
export const TableCellMenu = React.memo(
|
||||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||||
@@ -69,14 +70,16 @@ export const TableCellMenu = React.memo(
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<div className={classes.toolbar}>
|
||||||
<TableBackgroundColor editor={editor} />
|
<TableBackgroundColor editor={editor} />
|
||||||
<TableTextAlignment editor={editor} />
|
<TableTextAlignment editor={editor} />
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Merge cells")}>
|
<Tooltip position="top" label={t("Merge cells")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={mergeCells}
|
onClick={mergeCells}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Merge cells")}
|
aria-label={t("Merge cells")}
|
||||||
>
|
>
|
||||||
@@ -87,7 +90,7 @@ export const TableCellMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Split cell")}>
|
<Tooltip position="top" label={t("Split cell")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={splitCell}
|
onClick={splitCell}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Split cell")}
|
aria-label={t("Split cell")}
|
||||||
>
|
>
|
||||||
@@ -95,10 +98,12 @@ export const TableCellMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete column")}>
|
<Tooltip position="top" label={t("Delete column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteColumn}
|
onClick={deleteColumn}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Delete column")}
|
aria-label={t("Delete column")}
|
||||||
>
|
>
|
||||||
@@ -109,7 +114,7 @@ export const TableCellMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Delete row")}>
|
<Tooltip position="top" label={t("Delete row")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteRow}
|
onClick={deleteRow}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Delete row")}
|
aria-label={t("Delete row")}
|
||||||
>
|
>
|
||||||
@@ -117,17 +122,19 @@ export const TableCellMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header cell")}>
|
<Tooltip position="top" label={t("Toggle header cell")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderCell}
|
onClick={toggleHeaderCell}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Toggle header cell")}
|
aria-label={t("Toggle header cell")}
|
||||||
>
|
>
|
||||||
<IconTableRow size={18} />
|
<IconTableRow size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
</div>
|
||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import {
|
|||||||
IconTrashX,
|
IconTrashX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { BubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu } from "@tiptap/react/menus";
|
||||||
import { isCellSelection } from "@docmost/editor-ext";
|
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
export const TableMenu = React.memo(
|
export const TableMenu = React.memo(
|
||||||
({ editor }: EditorMenuProps): JSX.Element => {
|
({ editor }: EditorMenuProps): JSX.Element => {
|
||||||
@@ -30,6 +31,7 @@ export const TableMenu = React.memo(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isTextSelected(editor)) return false;
|
||||||
return editor.isActive("table") && !isCellSelection(state.selection);
|
return editor.isActive("table") && !isCellSelection(state.selection);
|
||||||
},
|
},
|
||||||
[editor]
|
[editor]
|
||||||
@@ -118,11 +120,11 @@ export const TableMenu = React.memo(
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<div className={classes.toolbar}>
|
||||||
<Tooltip position="top" label={t("Add left column")}>
|
<Tooltip position="top" label={t("Add left column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addColumnLeft}
|
onClick={addColumnLeft}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Add left column")}
|
aria-label={t("Add left column")}
|
||||||
>
|
>
|
||||||
@@ -133,7 +135,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Add right column")}>
|
<Tooltip position="top" label={t("Add right column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addColumnRight}
|
onClick={addColumnRight}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Add right column")}
|
aria-label={t("Add right column")}
|
||||||
>
|
>
|
||||||
@@ -144,7 +146,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Delete column")}>
|
<Tooltip position="top" label={t("Delete column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteColumn}
|
onClick={deleteColumn}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Delete column")}
|
aria-label={t("Delete column")}
|
||||||
>
|
>
|
||||||
@@ -152,10 +154,12 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Add row above")}>
|
<Tooltip position="top" label={t("Add row above")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addRowAbove}
|
onClick={addRowAbove}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Add row above")}
|
aria-label={t("Add row above")}
|
||||||
>
|
>
|
||||||
@@ -166,7 +170,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Add row below")}>
|
<Tooltip position="top" label={t("Add row below")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addRowBelow}
|
onClick={addRowBelow}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Add row below")}
|
aria-label={t("Add row below")}
|
||||||
>
|
>
|
||||||
@@ -177,7 +181,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Delete row")}>
|
<Tooltip position="top" label={t("Delete row")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteRow}
|
onClick={deleteRow}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Delete row")}
|
aria-label={t("Delete row")}
|
||||||
>
|
>
|
||||||
@@ -185,10 +189,12 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header row")}>
|
<Tooltip position="top" label={t("Toggle header row")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderRow}
|
onClick={toggleHeaderRow}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Toggle header row")}
|
aria-label={t("Toggle header row")}
|
||||||
>
|
>
|
||||||
@@ -199,7 +205,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Toggle header column")}>
|
<Tooltip position="top" label={t("Toggle header column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderColumn}
|
onClick={toggleHeaderColumn}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Toggle header column")}
|
aria-label={t("Toggle header column")}
|
||||||
>
|
>
|
||||||
@@ -207,18 +213,19 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete table")}>
|
<Tooltip position="top" label={t("Delete table")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteTable}
|
onClick={deleteTable}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
color="red"
|
|
||||||
aria-label={t("Delete table")}
|
aria-label={t("Delete table")}
|
||||||
>
|
>
|
||||||
<IconTrashX size={18} />
|
<IconTrashX size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
</div>
|
||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
|||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={t("Text alignment")} withArrow>
|
<Tooltip label={t("Text alignment")} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Text alignment")}
|
aria-label={t("Text alignment")}
|
||||||
onClick={() => setOpened(!opened)}
|
onClick={() => setOpened(!opened)}
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
IconLayoutAlignCenter,
|
IconLayoutAlignCenter,
|
||||||
IconLayoutAlignLeft,
|
IconLayoutAlignLeft,
|
||||||
IconLayoutAlignRight,
|
IconLayoutAlignRight,
|
||||||
|
IconDownload,
|
||||||
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
export function VideoMenu({ editor }: EditorMenuProps) {
|
export function VideoMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -32,7 +36,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
|
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
|
||||||
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
|
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
|
||||||
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
|
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
|
||||||
width: videoAttrs?.width ? parseInt(videoAttrs.width) : null,
|
src: videoAttrs?.src || null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -70,7 +74,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignVideoLeft = useCallback(() => {
|
const alignLeft = useCallback(() => {
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus(undefined, { scrollIntoView: false })
|
.focus(undefined, { scrollIntoView: false })
|
||||||
@@ -78,7 +82,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignVideoCenter = useCallback(() => {
|
const alignCenter = useCallback(() => {
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus(undefined, { scrollIntoView: false })
|
.focus(undefined, { scrollIntoView: false })
|
||||||
@@ -86,7 +90,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignVideoRight = useCallback(() => {
|
const alignRight = useCallback(() => {
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus(undefined, { scrollIntoView: false })
|
.focus(undefined, { scrollIntoView: false })
|
||||||
@@ -94,16 +98,18 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const handleDownload = useCallback(() => {
|
||||||
(value: number) => {
|
if (!editorState?.src) return;
|
||||||
editor
|
const url = getFileUrl(editorState.src);
|
||||||
.chain()
|
const a = document.createElement("a");
|
||||||
.focus(undefined, { scrollIntoView: false })
|
a.href = url;
|
||||||
.setVideoWidth(value)
|
a.download = "";
|
||||||
.run();
|
a.click();
|
||||||
},
|
}, [editorState?.src]);
|
||||||
[editor],
|
|
||||||
);
|
const handleDelete = useCallback(() => {
|
||||||
|
editor.commands.deleteSelection();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
@@ -118,13 +124,14 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group className="actionIconGroup">
|
<div className={classes.toolbar}>
|
||||||
<Tooltip position="top" label={t("Align left")}>
|
<Tooltip position="top" label={t("Align left")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignVideoLeft}
|
onClick={alignLeft}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align left")}
|
aria-label={t("Align left")}
|
||||||
variant={editorState?.isAlignLeft ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
||||||
>
|
>
|
||||||
<IconLayoutAlignLeft size={18} />
|
<IconLayoutAlignLeft size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -132,10 +139,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
<Tooltip position="top" label={t("Align center")}>
|
<Tooltip position="top" label={t("Align center")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignVideoCenter}
|
onClick={alignCenter}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align center")}
|
aria-label={t("Align center")}
|
||||||
variant={editorState?.isAlignCenter ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
||||||
>
|
>
|
||||||
<IconLayoutAlignCenter size={18} />
|
<IconLayoutAlignCenter size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -143,19 +151,40 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
<Tooltip position="top" label={t("Align right")}>
|
<Tooltip position="top" label={t("Align right")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignVideoRight}
|
onClick={alignRight}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align right")}
|
aria-label={t("Align right")}
|
||||||
variant={editorState?.isAlignRight ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
||||||
>
|
>
|
||||||
<IconLayoutAlignRight size={18} />
|
<IconLayoutAlignRight size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
|
||||||
|
|
||||||
{editorState?.width && (
|
<div className={classes.divider} />
|
||||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
|
||||||
)}
|
<Tooltip position="top" label={t("Download")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Download")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconDownload size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDelete}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import {
|
|||||||
Highlight,
|
Highlight,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
SharedStorage,
|
SharedStorage,
|
||||||
|
Columns,
|
||||||
|
Column,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -52,6 +54,14 @@ import { IUser } from "@/features/user/types/user.types.ts";
|
|||||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
||||||
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
||||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||||
|
import {
|
||||||
|
createImageHandle,
|
||||||
|
imageResizeClasses,
|
||||||
|
} from "@/features/editor/components/image/image-resize-handles.ts";
|
||||||
|
import {
|
||||||
|
createResizeHandle,
|
||||||
|
buildResizeClasses,
|
||||||
|
} from "@/features/editor/components/common/node-resize-handles.ts";
|
||||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||||
import VideoView from "@/features/editor/components/video/video-view.tsx";
|
import VideoView from "@/features/editor/components/video/video-view.tsx";
|
||||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||||
@@ -91,6 +101,7 @@ lowlight.register("fortran", fortran);
|
|||||||
lowlight.register("haskell", haskell);
|
lowlight.register("haskell", haskell);
|
||||||
lowlight.register("scala", scala);
|
lowlight.register("scala", scala);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
export const mainExtensions = [
|
export const mainExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: false,
|
heading: false,
|
||||||
@@ -115,7 +126,7 @@ export const mainExtensions = [
|
|||||||
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: ({ node }) => {
|
placeholder: ({ editor, node, pos }) => {
|
||||||
if (node.type.name === "heading") {
|
if (node.type.name === "heading") {
|
||||||
return i18n.t("Heading {{level}}", { level: node.attrs.level });
|
return i18n.t("Heading {{level}}", { level: node.attrs.level });
|
||||||
}
|
}
|
||||||
@@ -123,6 +134,17 @@ export const mainExtensions = [
|
|||||||
return i18n.t("Toggle title");
|
return i18n.t("Toggle title");
|
||||||
}
|
}
|
||||||
if (node.type.name === "paragraph") {
|
if (node.type.name === "paragraph") {
|
||||||
|
const $pos = editor.state.doc.resolve(pos);
|
||||||
|
const parentName = $pos.parent.type.name;
|
||||||
|
if (
|
||||||
|
parentName === "column" ||
|
||||||
|
parentName === "tableCell" ||
|
||||||
|
parentName === "tableHeader" ||
|
||||||
|
parentName === "callout" ||
|
||||||
|
parentName === "blockquote"
|
||||||
|
) {
|
||||||
|
return i18n.t("Write...");
|
||||||
|
}
|
||||||
return i18n.t('Write anything. Enter "/" for commands');
|
return i18n.t('Write anything. Enter "/" for commands');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -200,9 +222,29 @@ export const mainExtensions = [
|
|||||||
TiptapImage.configure({
|
TiptapImage.configure({
|
||||||
view: ImageView,
|
view: ImageView,
|
||||||
allowBase64: false,
|
allowBase64: false,
|
||||||
|
resize: {
|
||||||
|
enabled: true,
|
||||||
|
directions: ["left", "right"],
|
||||||
|
minWidth: 80,
|
||||||
|
minHeight: 40,
|
||||||
|
alwaysPreserveAspectRatio: true,
|
||||||
|
//@ts-ignore
|
||||||
|
createCustomHandle: createImageHandle,
|
||||||
|
className: imageResizeClasses,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
TiptapVideo.configure({
|
TiptapVideo.configure({
|
||||||
view: VideoView,
|
view: VideoView,
|
||||||
|
resize: {
|
||||||
|
enabled: true,
|
||||||
|
directions: ["left", "right"],
|
||||||
|
minWidth: 80,
|
||||||
|
minHeight: 40,
|
||||||
|
alwaysPreserveAspectRatio: true,
|
||||||
|
//@ts-ignore
|
||||||
|
createCustomHandle: createResizeHandle,
|
||||||
|
className: buildResizeClasses("node-video"),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Callout.configure({
|
Callout.configure({
|
||||||
view: CalloutView,
|
view: CalloutView,
|
||||||
@@ -221,9 +263,29 @@ export const mainExtensions = [
|
|||||||
}),
|
}),
|
||||||
Drawio.configure({
|
Drawio.configure({
|
||||||
view: DrawioView,
|
view: DrawioView,
|
||||||
|
resize: {
|
||||||
|
enabled: true,
|
||||||
|
directions: ["left", "right"],
|
||||||
|
minWidth: 80,
|
||||||
|
minHeight: 40,
|
||||||
|
alwaysPreserveAspectRatio: true,
|
||||||
|
//@ts-ignore
|
||||||
|
createCustomHandle: createResizeHandle,
|
||||||
|
className: buildResizeClasses("node-drawio"),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Excalidraw.configure({
|
Excalidraw.configure({
|
||||||
view: ExcalidrawView,
|
view: ExcalidrawView,
|
||||||
|
resize: {
|
||||||
|
enabled: true,
|
||||||
|
directions: ["left", "right"],
|
||||||
|
minWidth: 80,
|
||||||
|
minHeight: 40,
|
||||||
|
alwaysPreserveAspectRatio: true,
|
||||||
|
//@ts-ignore
|
||||||
|
createCustomHandle: createResizeHandle,
|
||||||
|
className: buildResizeClasses("node-excalidraw"),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Embed.configure({
|
Embed.configure({
|
||||||
view: EmbedView,
|
view: EmbedView,
|
||||||
@@ -253,6 +315,8 @@ export const mainExtensions = [
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}).configure(),
|
}).configure(),
|
||||||
|
Columns,
|
||||||
|
Column,
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import { jwtDecode } from "jwt-decode";
|
|||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||||
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
||||||
|
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -416,6 +417,7 @@ export default function PageEditor({
|
|||||||
<SubpagesMenu editor={editor} />
|
<SubpagesMenu editor={editor} />
|
||||||
<ExcalidrawMenu editor={editor} />
|
<ExcalidrawMenu editor={editor} />
|
||||||
<DrawioMenu editor={editor} />
|
<DrawioMenu editor={editor} />
|
||||||
|
<ColumnsMenu editor={editor} />
|
||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
div[data-type="columns"] {
|
||||||
|
display: flex;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"] > div[data-type="column"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
|
||||||
|
border-left: 1px solid transparent;
|
||||||
|
padding-left: 1rem;
|
||||||
|
transition: border 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"]:hover
|
||||||
|
> div[data-type="column"]
|
||||||
|
+ div[data-type="column"] {
|
||||||
|
border-left: 1px solid
|
||||||
|
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-7));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confluence layout types */
|
||||||
|
div[data-type="columns"][data-layout="two_left_sidebar"]
|
||||||
|
> div[data-type="column"]:first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-layout="two_left_sidebar"]
|
||||||
|
> div[data-type="column"]:last-child {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-layout="two_right_sidebar"]
|
||||||
|
> div[data-type="column"]:first-child {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-layout="two_right_sidebar"]
|
||||||
|
> div[data-type="column"]:last-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-layout="three_left_wide"]
|
||||||
|
> div[data-type="column"]:first-child {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-layout="three_right_wide"]
|
||||||
|
> div[data-type="column"]:last-child {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-layout="three_with_sidebars"]
|
||||||
|
> div[data-type="column"]:first-child,
|
||||||
|
div[data-type="columns"][data-layout="three_with_sidebars"]
|
||||||
|
> div[data-type="column"]:last-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-layout="three_with_sidebars"]
|
||||||
|
> div[data-type="column"]:nth-child(2) {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack columns vertically on small viewports */
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
div[data-type="columns"] {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"]:hover
|
||||||
|
> div[data-type="column"]
|
||||||
|
+ div[data-type="column"] {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wide width mode — extends columns to full container width */
|
||||||
|
div[data-type="columns"][data-width-mode="wide"] {
|
||||||
|
margin-left: -3rem;
|
||||||
|
margin-right: -3rem;
|
||||||
|
width: calc(100% + 6rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $mantine-breakpoint-sm) {
|
||||||
|
div[data-type="columns"][data-width-mode="wide"] {
|
||||||
|
margin-left: -1rem;
|
||||||
|
margin-right: -1rem;
|
||||||
|
width: calc(100% + 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
div[data-type="columns"] {
|
||||||
|
flex-direction: row !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-width-mode="wide"] {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,13 +82,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
padding-left: 25px;
|
padding-left: 1rem;
|
||||||
padding-right: 25px;
|
border-left: 3px solid
|
||||||
border-left: 2px solid var(--mantine-color-gray-6);
|
light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
|
||||||
background-color: light-dark(
|
|
||||||
var(--mantine-color-gray-0),
|
|
||||||
var(--mantine-color-dark-8)
|
|
||||||
);
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,13 +122,14 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.node-callout {
|
}
|
||||||
div[style*="white-space: inherit;"] {
|
|
||||||
> :first-child {
|
.react-renderer.node-callout div[style*="white-space: inherit;"] > :first-child {
|
||||||
margin: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
.react-renderer.node-callout + .react-renderer.node-callout {
|
||||||
|
margin-top: 0.75em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection {
|
.selection {
|
||||||
|
|||||||
@@ -13,3 +13,4 @@
|
|||||||
@import "./mention.css";
|
@import "./mention.css";
|
||||||
@import "./ordered-list.css";
|
@import "./ordered-list.css";
|
||||||
@import "./highlight.css";
|
@import "./highlight.css";
|
||||||
|
@import "./columns.css";
|
||||||
|
|||||||
@@ -171,14 +171,11 @@ export function TitleEditor({
|
|||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (titleEditor) {
|
// honor user default page edit mode preference
|
||||||
if (userPageEditMode && editable) {
|
if (userPageEditMode && titleEditor && editable) {
|
||||||
if (userPageEditMode === PageEditMode.Edit) {
|
if (userPageEditMode === PageEditMode.Edit) {
|
||||||
titleEditor.setEditable(true);
|
titleEditor.setEditable(true);
|
||||||
} else if (userPageEditMode === PageEditMode.Read) {
|
} else if (userPageEditMode === PageEditMode.Read) {
|
||||||
titleEditor.setEditable(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
titleEditor.setEditable(false);
|
titleEditor.setEditable(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
|
|||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
import ShareModal from "@/features/share/components/share-modal.tsx";
|
import ShareModal from "@/features/share/components/share-modal.tsx";
|
||||||
import { PageShareModal } from "@/ee/page-permission";
|
|
||||||
|
|
||||||
interface PageHeaderMenuProps {
|
interface PageHeaderMenuProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -76,9 +75,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
|
|
||||||
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
||||||
|
|
||||||
{/*<ShareModal readOnly={readOnly} />*/}
|
<ShareModal readOnly={readOnly} />
|
||||||
<PageShareModal readOnly={readOnly}/>
|
|
||||||
|
|
||||||
|
|
||||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ export interface IPage {
|
|||||||
lastUpdatedBy: ILastUpdatedBy;
|
lastUpdatedBy: ILastUpdatedBy;
|
||||||
deletedBy: IDeletedBy;
|
deletedBy: IDeletedBy;
|
||||||
space: Partial<ISpace>;
|
space: Partial<ISpace>;
|
||||||
permissions?: {
|
|
||||||
canEdit: boolean;
|
|
||||||
hasRestriction: boolean;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ICreator {
|
interface ICreator {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface MultiMemberSelectProps {
|
interface MultiMemberSelectProps {
|
||||||
value?: string[];
|
|
||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +33,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|
||||||
export function MultiMemberSelect({ value, onChange }: MultiMemberSelectProps) {
|
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
||||||
@@ -86,7 +85,6 @@ export function MultiMemberSelect({ value, onChange }: MultiMemberSelectProps) {
|
|||||||
return (
|
return (
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
data={data}
|
data={data}
|
||||||
value={value}
|
|
||||||
renderOption={renderMultiSelectOption}
|
renderOption={renderMultiSelectOption}
|
||||||
hidePickedOptions
|
hidePickedOptions
|
||||||
maxDropdownHeight={300}
|
maxDropdownHeight={300}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ if (isCloud() && isPostHogEnabled) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const container = document.getElementById("root") as HTMLElement;
|
const container = document.getElementById("root") as HTMLElement;
|
||||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { Helmet } from "react-helmet-async";
|
|||||||
import PageHeader from "@/features/page/components/header/page-header.tsx";
|
import PageHeader from "@/features/page/components/header/page-header.tsx";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||||
@@ -13,6 +18,7 @@ import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
|||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
const MemoizedFullEditor = React.memo(FullEditor);
|
const MemoizedFullEditor = React.memo(FullEditor);
|
||||||
const MemoizedPageHeader = React.memo(PageHeader);
|
const MemoizedPageHeader = React.memo(PageHeader);
|
||||||
const MemoizedHistoryModal = React.memo(HistoryModal);
|
const MemoizedHistoryModal = React.memo(HistoryModal);
|
||||||
@@ -52,7 +58,8 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
const canEdit = page?.permissions?.canEdit ?? false;
|
const spaceRules = space?.membership?.permissions;
|
||||||
|
const spaceAbility = useSpaceAbility(spaceRules);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <></>;
|
return <></>;
|
||||||
@@ -94,7 +101,12 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
|
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<MemoizedPageHeader readOnly={!canEdit} />
|
<MemoizedPageHeader
|
||||||
|
readOnly={spaceAbility.cannot(
|
||||||
|
SpaceCaslAction.Manage,
|
||||||
|
SpaceCaslSubject.Page,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<MemoizedFullEditor
|
<MemoizedFullEditor
|
||||||
key={page.id}
|
key={page.id}
|
||||||
@@ -103,7 +115,10 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
content={page.content}
|
content={page.content}
|
||||||
slugId={page.slugId}
|
slugId={page.slugId}
|
||||||
spaceSlug={page?.space?.slug}
|
spaceSlug={page?.space?.slug}
|
||||||
editable={canEdit}
|
editable={spaceAbility.can(
|
||||||
|
SpaceCaslAction.Manage,
|
||||||
|
SpaceCaslSubject.Page,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<MemoizedHistoryModal pageId={page.id} />
|
<MemoizedHistoryModal pageId={page.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,12 +39,10 @@
|
|||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@keyv/redis": "^5.1.6",
|
|
||||||
"@langchain/core": "1.1.18",
|
"@langchain/core": "1.1.18",
|
||||||
"@langchain/textsplitters": "1.0.1",
|
"@langchain/textsplitters": "1.0.1",
|
||||||
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||||
"@nestjs/bullmq": "^11.0.4",
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
"@nestjs/cache-manager": "^3.1.0",
|
|
||||||
"@nestjs/common": "^11.1.11",
|
"@nestjs/common": "^11.1.11",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.1.13",
|
"@nestjs/core": "^11.1.13",
|
||||||
@@ -158,11 +156,6 @@
|
|||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node"
|
||||||
"moduleNameMapper": {
|
|
||||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
|
||||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
|
||||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { EnvironmentService } from './integrations/environment/environment.service';
|
|
||||||
import { CoreModule } from './core/core.module';
|
import { CoreModule } from './core/core.module';
|
||||||
import { EnvironmentModule } from './integrations/environment/environment.module';
|
import { EnvironmentModule } from './integrations/environment/environment.module';
|
||||||
import { CollaborationModule } from './collaboration/collaboration.module';
|
import { CollaborationModule } from './collaboration/collaboration.module';
|
||||||
@@ -19,8 +18,6 @@ import { SecurityModule } from './integrations/security/security.module';
|
|||||||
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
|
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
|
||||||
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
||||||
import { RedisConfigService } from './integrations/redis/redis-config.service';
|
import { RedisConfigService } from './integrations/redis/redis-config.service';
|
||||||
import { CacheModule } from '@nestjs/cache-manager';
|
|
||||||
import KeyvRedis from '@keyv/redis';
|
|
||||||
import { LoggerModule } from './common/logger/logger.module';
|
import { LoggerModule } from './common/logger/logger.module';
|
||||||
|
|
||||||
const enterpriseModules = [];
|
const enterpriseModules = [];
|
||||||
@@ -46,18 +43,6 @@ try {
|
|||||||
RedisModule.forRootAsync({
|
RedisModule.forRootAsync({
|
||||||
useClass: RedisConfigService,
|
useClass: RedisConfigService,
|
||||||
}),
|
}),
|
||||||
CacheModule.registerAsync({
|
|
||||||
isGlobal: true,
|
|
||||||
useFactory: async (environmentService: EnvironmentService) => {
|
|
||||||
const redisUrl = environmentService.getRedisUrl();
|
|
||||||
|
|
||||||
return {
|
|
||||||
ttl: 5 * 1000,
|
|
||||||
stores: [new KeyvRedis(redisUrl)],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
inject: [EnvironmentService],
|
|
||||||
}),
|
|
||||||
CollaborationModule,
|
CollaborationModule,
|
||||||
WsModule,
|
WsModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import {
|
|||||||
UniqueID,
|
UniqueID,
|
||||||
addUniqueIdsToDoc,
|
addUniqueIdsToDoc,
|
||||||
htmlToMarkdown,
|
htmlToMarkdown,
|
||||||
|
Columns,
|
||||||
|
Column,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||||
@@ -91,6 +93,8 @@ export const tiptapExtensions = [
|
|||||||
Embed,
|
Embed,
|
||||||
Mention,
|
Mention,
|
||||||
Subpages,
|
Subpages,
|
||||||
|
Columns,
|
||||||
|
Column,
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
export function jsonToHtml(tiptapJson: any) {
|
export function jsonToHtml(tiptapJson: any) {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { TokenService } from '../../core/auth/services/token.service';
|
|||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
|
||||||
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
|
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
|
||||||
import { SpaceRole } from '../../common/helpers/types/permission';
|
import { SpaceRole } from '../../common/helpers/types/permission';
|
||||||
import { getPageId } from '../collaboration.util';
|
import { getPageId } from '../collaboration.util';
|
||||||
@@ -24,7 +23,6 @@ export class AuthenticationExtension implements Extension {
|
|||||||
private userRepo: UserRepo,
|
private userRepo: UserRepo,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onAuthenticate(data: onAuthenticatePayload) {
|
async onAuthenticate(data: onAuthenticatePayload) {
|
||||||
@@ -54,7 +52,7 @@ export class AuthenticationExtension implements Extension {
|
|||||||
|
|
||||||
const page = await this.pageRepo.findById(pageId);
|
const page = await this.pageRepo.findById(pageId);
|
||||||
if (!page) {
|
if (!page) {
|
||||||
this.logger.debug(`Page not found: ${pageId}`);
|
this.logger.warn(`Page not found: ${pageId}`);
|
||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,34 +68,9 @@ export class AuthenticationExtension implements Extension {
|
|||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check page-level permissions
|
if (userSpaceRole === SpaceRole.READER) {
|
||||||
const { hasAnyRestriction, canAccess, canEdit } =
|
|
||||||
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
|
|
||||||
|
|
||||||
if (hasAnyRestriction) {
|
|
||||||
if (!canAccess) {
|
|
||||||
this.logger.warn(
|
|
||||||
`User ${user.id} denied page-level access to page: ${pageId}`,
|
|
||||||
);
|
|
||||||
throw new UnauthorizedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!canEdit) {
|
|
||||||
data.connectionConfig.readOnly = true;
|
|
||||||
this.logger.debug(
|
|
||||||
`User ${user.id} granted readonly access to restricted page: ${pageId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No restrictions - use space-level permissions
|
|
||||||
if (userSpaceRole === SpaceRole.READER) {
|
|
||||||
data.connectionConfig.readOnly = true;
|
|
||||||
this.logger.debug(`User granted readonly access to page: ${pageId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page.deletedAt) {
|
|
||||||
data.connectionConfig.readOnly = true;
|
data.connectionConfig.readOnly = true;
|
||||||
|
this.logger.debug(`User granted readonly access to page: ${pageId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
|
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
|
||||||
|
|||||||
@@ -14,12 +14,3 @@ export enum SpaceVisibility {
|
|||||||
OPEN = 'open', // any workspace member can see that it exists and join.
|
OPEN = 'open', // any workspace member can see that it exists and join.
|
||||||
PRIVATE = 'private', // only added space users can see
|
PRIVATE = 'private', // only added space users can see
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PageAccessLevel {
|
|
||||||
RESTRICTED = 'restricted', // only specific users/groups can view or edit
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum PagePermissionRole {
|
|
||||||
READER = 'reader', // can only view content and descendants
|
|
||||||
WRITER = 'writer', // can edit content, descendants, and add new users to permission
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ import { TokenService } from '../auth/services/token.service';
|
|||||||
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { RemoveIconDto } from './dto/attachment.dto';
|
import { RemoveIconDto } from './dto/attachment.dto';
|
||||||
import { PageAccessService } from '../page-access/page-access.service';
|
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AttachmentController {
|
export class AttachmentController {
|
||||||
@@ -68,7 +67,6 @@ export class AttachmentController {
|
|||||||
private readonly attachmentRepo: AttachmentRepo,
|
private readonly attachmentRepo: AttachmentRepo,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
private readonly pageAccessService: PageAccessService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -113,7 +111,13 @@ export class AttachmentController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pageAccessService.validateCanEdit(page, user);
|
const spaceAbility = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
page.spaceId,
|
||||||
|
);
|
||||||
|
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
const spaceId = page.spaceId;
|
const spaceId = page.spaceId;
|
||||||
|
|
||||||
@@ -168,12 +172,14 @@ export class AttachmentController {
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = await this.pageRepo.findById(attachment.pageId);
|
const spaceAbility = await this.spaceAbility.createForUser(
|
||||||
if (!page) {
|
user,
|
||||||
throw new NotFoundException();
|
attachment.spaceId,
|
||||||
}
|
);
|
||||||
|
|
||||||
await this.pageAccessService.validateCanView(page, user);
|
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.sendFileResponse(req, res, attachment, 'private');
|
return await this.sendFileResponse(req, res, attachment, 'private');
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
} from '../casl/interfaces/space-ability.type';
|
} from '../casl/interfaces/space-ability.type';
|
||||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||||
import { PageAccessService } from '../page-access/page-access.service';
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('comments')
|
@Controller('comments')
|
||||||
@@ -34,7 +33,6 @@ export class CommentController {
|
|||||||
private readonly commentRepo: CommentRepo,
|
private readonly commentRepo: CommentRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
private readonly pageAccessService: PageAccessService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -49,7 +47,10 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pageAccessService.validateCanEdit(page, user);
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.commentService.create(
|
return this.commentService.create(
|
||||||
{
|
{
|
||||||
@@ -74,8 +75,10 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pageAccessService.validateCanView(page, user);
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
return this.commentService.findByPageId(page.id, pagination);
|
return this.commentService.findByPageId(page.id, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,13 +90,13 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Comment not found');
|
throw new NotFoundException('Comment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = await this.pageRepo.findById(comment.pageId);
|
const ability = await this.spaceAbility.createForUser(
|
||||||
if (!page) {
|
user,
|
||||||
throw new NotFoundException('Page not found');
|
comment.spaceId,
|
||||||
|
);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pageAccessService.validateCanView(page, user);
|
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +108,17 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Comment not found');
|
throw new NotFoundException('Comment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = await this.pageRepo.findById(comment.pageId);
|
const ability = await this.spaceAbility.createForUser(
|
||||||
if (!page) {
|
user,
|
||||||
throw new NotFoundException('Page not found');
|
comment.spaceId,
|
||||||
}
|
);
|
||||||
|
|
||||||
await this.pageAccessService.validateCanEdit(page, user);
|
// must be a space member with edit permission
|
||||||
|
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'You must have space edit permission to edit comments',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.commentService.update(comment, dto, user);
|
return this.commentService.update(comment, dto, user);
|
||||||
}
|
}
|
||||||
@@ -123,27 +131,41 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Comment not found');
|
throw new NotFoundException('Comment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = await this.pageRepo.findById(comment.pageId);
|
const ability = await this.spaceAbility.createForUser(
|
||||||
if (!page) {
|
user,
|
||||||
throw new NotFoundException('Page not found');
|
comment.spaceId,
|
||||||
}
|
);
|
||||||
|
|
||||||
// Check page-level edit permission first
|
// must be a space member with edit permission
|
||||||
await this.pageAccessService.validateCanEdit(page, user);
|
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user is the comment owner
|
// Check if user is the comment owner
|
||||||
const isOwner = comment.creatorId === user.id;
|
const isOwner = comment.creatorId === user.id;
|
||||||
|
|
||||||
if (isOwner) {
|
if (isOwner) {
|
||||||
|
/*
|
||||||
|
// Check if comment has children from other users
|
||||||
|
const hasChildrenFromOthers =
|
||||||
|
await this.commentRepo.hasChildrenFromOtherUsers(comment.id, user.id);
|
||||||
|
|
||||||
|
// Owner can delete if no children from other users
|
||||||
|
if (!hasChildrenFromOthers) {
|
||||||
|
await this.commentRepo.deleteComment(comment.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If has children from others, only space admin can delete
|
||||||
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Only space admins can delete comments with replies from other users',
|
||||||
|
);
|
||||||
|
}*/
|
||||||
await this.commentRepo.deleteComment(comment.id);
|
await this.commentRepo.deleteComment(comment.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(
|
|
||||||
user,
|
|
||||||
comment.spaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Space admin can delete any comment
|
// Space admin can delete any comment
|
||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { SearchModule } from './search/search.module';
|
|||||||
import { SpaceModule } from './space/space.module';
|
import { SpaceModule } from './space/space.module';
|
||||||
import { GroupModule } from './group/group.module';
|
import { GroupModule } from './group/group.module';
|
||||||
import { CaslModule } from './casl/casl.module';
|
import { CaslModule } from './casl/casl.module';
|
||||||
import { PageAccessModule } from './page-access/page-access.module';
|
|
||||||
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
||||||
import { ShareModule } from './share/share.module';
|
import { ShareModule } from './share/share.module';
|
||||||
import { NotificationModule } from './notification/notification.module';
|
import { NotificationModule } from './notification/notification.module';
|
||||||
@@ -32,7 +31,6 @@ import { WatcherModule } from './watcher/watcher.module';
|
|||||||
SpaceModule,
|
SpaceModule,
|
||||||
GroupModule,
|
GroupModule,
|
||||||
CaslModule,
|
CaslModule,
|
||||||
PageAccessModule,
|
|
||||||
ShareModule,
|
ShareModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
WatcherModule,
|
WatcherModule,
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { NotificationController } from './notification.controller';
|
|||||||
import { NotificationProcessor } from './notification.processor';
|
import { NotificationProcessor } from './notification.processor';
|
||||||
import { CommentNotificationService } from './services/comment.notification';
|
import { CommentNotificationService } from './services/comment.notification';
|
||||||
import { PageNotificationService } from './services/page.notification';
|
import { PageNotificationService } from './services/page.notification';
|
||||||
|
import { WsModule } from '../../ws/ws.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [WsModule],
|
||||||
controllers: [NotificationController],
|
controllers: [NotificationController],
|
||||||
providers: [
|
providers: [
|
||||||
NotificationService,
|
NotificationService,
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { PageAccessService } from './page-access.service';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [PageAccessService],
|
|
||||||
exports: [PageAccessService],
|
|
||||||
})
|
|
||||||
export class PageAccessModule {}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
|
||||||
import { Page, User } from '@docmost/db/types/entity.types';
|
|
||||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
|
||||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
|
||||||
import {
|
|
||||||
SpaceCaslAction,
|
|
||||||
SpaceCaslSubject,
|
|
||||||
} from '../casl/interfaces/space-ability.type';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PageAccessService {
|
|
||||||
constructor(
|
|
||||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate user can view page, throws ForbiddenException if not.
|
|
||||||
* If page has restrictions: page-level permission determines access.
|
|
||||||
* If no restrictions: space-level permission determines access.
|
|
||||||
*/
|
|
||||||
async validateCanView(page: Page, user: User): Promise<void> {
|
|
||||||
// TODO: cache by pageId and userId.
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
|
||||||
|
|
||||||
// User must be at least a space member
|
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
const canAccess = await this.pagePermissionRepo.canUserAccessPage(
|
|
||||||
user.id,
|
|
||||||
page.id,
|
|
||||||
);
|
|
||||||
if (!canAccess) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate user can view page AND return effective canEdit permission.
|
|
||||||
* Combines access check + edit permission in a single query pass.
|
|
||||||
*/
|
|
||||||
async validateCanViewWithPermissions(
|
|
||||||
page: Page,
|
|
||||||
user: User,
|
|
||||||
): Promise<{ canEdit: boolean; hasRestriction: boolean }> {
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
|
||||||
|
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { hasAnyRestriction, canAccess, canEdit } =
|
|
||||||
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
|
|
||||||
|
|
||||||
if (hasAnyRestriction && !canAccess) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
canEdit: hasAnyRestriction
|
|
||||||
? canEdit
|
|
||||||
: ability.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
|
|
||||||
hasRestriction: hasAnyRestriction,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate user can edit page, throws ForbiddenException if not.
|
|
||||||
* If page has restrictions: page-level writer permission determines access.
|
|
||||||
* If no restrictions: space-level edit permission determines access.
|
|
||||||
*/
|
|
||||||
async validateCanEdit(
|
|
||||||
page: Page,
|
|
||||||
user: User,
|
|
||||||
): Promise<{ hasRestriction: boolean }> {
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
|
||||||
|
|
||||||
// User must be at least a space member
|
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { hasAnyRestriction, canEdit } =
|
|
||||||
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
|
|
||||||
|
|
||||||
if (hasAnyRestriction) {
|
|
||||||
// Page has restrictions - use page-level permission
|
|
||||||
if (!canEdit) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No restrictions - use space-level permission
|
|
||||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { hasRestriction: hasAnyRestriction };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PageService } from './services/page.service';
|
import { PageService } from './services/page.service';
|
||||||
import { PageAccessService } from '../page-access/page-access.service';
|
|
||||||
import { CreatePageDto } from './dto/create-page.dto';
|
import { CreatePageDto } from './dto/create-page.dto';
|
||||||
import { UpdatePageDto } from './dto/update-page.dto';
|
import { UpdatePageDto } from './dto/update-page.dto';
|
||||||
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
|
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
|
||||||
@@ -49,7 +48,6 @@ export class PageController {
|
|||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly pageHistoryService: PageHistoryService,
|
private readonly pageHistoryService: PageHistoryService,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
private readonly pageAccessService: PageAccessService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -67,10 +65,10 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canEdit, hasRestriction } =
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
await this.pageAccessService.validateCanViewWithPermissions(page, user);
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
const permissions = { canEdit, hasRestriction };
|
}
|
||||||
|
|
||||||
if (dto.format && dto.format !== 'json' && page.content) {
|
if (dto.format && dto.format !== 'json' && page.content) {
|
||||||
const contentOutput =
|
const contentOutput =
|
||||||
@@ -80,11 +78,10 @@ export class PageController {
|
|||||||
return {
|
return {
|
||||||
...page,
|
...page,
|
||||||
content: contentOutput,
|
content: contentOutput,
|
||||||
permissions,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...page, permissions };
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -94,28 +91,12 @@ export class PageController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
if (createPageDto.parentPageId) {
|
const ability = await this.spaceAbility.createForUser(
|
||||||
// Creating under a parent page - check edit permission on parent
|
user,
|
||||||
const parentPage = await this.pageRepo.findById(
|
createPageDto.spaceId,
|
||||||
createPageDto.parentPageId,
|
);
|
||||||
);
|
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
|
||||||
if (
|
throw new ForbiddenException();
|
||||||
!parentPage ||
|
|
||||||
parentPage.deletedAt ||
|
|
||||||
parentPage.spaceId !== createPageDto.spaceId
|
|
||||||
) {
|
|
||||||
throw new NotFoundException('Parent page not found');
|
|
||||||
}
|
|
||||||
await this.pageAccessService.validateCanEdit(parentPage, user);
|
|
||||||
} else {
|
|
||||||
// Creating at root level - require space-level permission
|
|
||||||
const ability = await this.spaceAbility.createForUser(
|
|
||||||
user,
|
|
||||||
createPageDto.spaceId,
|
|
||||||
);
|
|
||||||
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = await this.pageService.create(
|
const page = await this.pageService.create(
|
||||||
@@ -124,11 +105,6 @@ export class PageController {
|
|||||||
createPageDto,
|
createPageDto,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { canEdit, hasRestriction } =
|
|
||||||
await this.pageAccessService.validateCanViewWithPermissions(page, user);
|
|
||||||
|
|
||||||
const permissions = { canEdit, hasRestriction };
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
createPageDto.format &&
|
createPageDto.format &&
|
||||||
createPageDto.format !== 'json' &&
|
createPageDto.format !== 'json' &&
|
||||||
@@ -138,10 +114,10 @@ export class PageController {
|
|||||||
createPageDto.format === 'markdown'
|
createPageDto.format === 'markdown'
|
||||||
? jsonToMarkdown(page.content)
|
? jsonToMarkdown(page.content)
|
||||||
: jsonToHtml(page.content);
|
: jsonToHtml(page.content);
|
||||||
return { ...page, content: contentOutput, permissions };
|
return { ...page, content: contentOutput };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...page, permissions };
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -153,8 +129,10 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hasRestriction } =
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
await this.pageAccessService.validateCanEdit(page, user);
|
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
const updatedPage = await this.pageService.update(
|
const updatedPage = await this.pageService.update(
|
||||||
page,
|
page,
|
||||||
@@ -162,8 +140,6 @@ export class PageController {
|
|||||||
user,
|
user,
|
||||||
);
|
);
|
||||||
|
|
||||||
const permissions = { canEdit: true, hasRestriction };
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
updatePageDto.format &&
|
updatePageDto.format &&
|
||||||
updatePageDto.format !== 'json' &&
|
updatePageDto.format !== 'json' &&
|
||||||
@@ -173,10 +149,10 @@ export class PageController {
|
|||||||
updatePageDto.format === 'markdown'
|
updatePageDto.format === 'markdown'
|
||||||
? jsonToMarkdown(updatedPage.content)
|
? jsonToMarkdown(updatedPage.content)
|
||||||
: jsonToHtml(updatedPage.content);
|
: jsonToHtml(updatedPage.content);
|
||||||
return { ...updatedPage, content: contentOutput, permissions };
|
return { ...updatedPage, content: contentOutput };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...updatedPage, permissions };
|
return updatedPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -203,9 +179,10 @@ export class PageController {
|
|||||||
}
|
}
|
||||||
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
|
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
|
||||||
} else {
|
} else {
|
||||||
// User with edit permission can delete
|
// Soft delete requires page manage permissions
|
||||||
await this.pageAccessService.validateCanEdit(page, user);
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
await this.pageService.removePage(
|
await this.pageService.removePage(
|
||||||
deletePageDto.pageId,
|
deletePageDto.pageId,
|
||||||
user.id,
|
user.id,
|
||||||
@@ -227,18 +204,11 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
//Todo: currently, this means if they are not admins, they need to add a space admin to the page, which is not possible as it was soft-deleted
|
|
||||||
// so page is virtually lost. Fix.
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: can users with page level edit, but no space level edit restore pages they can edit?
|
|
||||||
|
|
||||||
// Check page-level edit permission (if restoring to a restricted ancestor)
|
|
||||||
await this.pageAccessService.validateCanEdit(page, user);
|
|
||||||
|
|
||||||
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
|
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
|
||||||
|
|
||||||
return this.pageRepo.findById(pageIdDto.pageId, {
|
return this.pageRepo.findById(pageIdDto.pageId, {
|
||||||
@@ -265,7 +235,6 @@ export class PageController {
|
|||||||
|
|
||||||
return this.pageService.getRecentSpacePages(
|
return this.pageService.getRecentSpacePages(
|
||||||
recentPageDto.spaceId,
|
recentPageDto.spaceId,
|
||||||
user.id,
|
|
||||||
pagination,
|
pagination,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -292,7 +261,6 @@ export class PageController {
|
|||||||
|
|
||||||
return this.pageService.getDeletedSpacePages(
|
return this.pageService.getDeletedSpacePages(
|
||||||
deletedPageDto.spaceId,
|
deletedPageDto.spaceId,
|
||||||
user.id,
|
|
||||||
pagination,
|
pagination,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -310,7 +278,10 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pageAccessService.validateCanView(page, user);
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
|
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
|
||||||
}
|
}
|
||||||
@@ -326,14 +297,13 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page history not found');
|
throw new NotFoundException('Page history not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the page to check permissions
|
const ability = await this.spaceAbility.createForUser(
|
||||||
const page = await this.pageRepo.findById(history.pageId);
|
user,
|
||||||
if (!page) {
|
history.spaceId,
|
||||||
throw new NotFoundException('Page not found');
|
);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pageAccessService.validateCanView(page, user);
|
|
||||||
|
|
||||||
return history;
|
return history;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,18 +335,7 @@ export class PageController {
|
|||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
const spaceCanEdit = ability.can(
|
return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId);
|
||||||
SpaceCaslAction.Edit,
|
|
||||||
SpaceCaslSubject.Page,
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.pageService.getSidebarPages(
|
|
||||||
spaceId,
|
|
||||||
pagination,
|
|
||||||
dto.pageId,
|
|
||||||
user.id,
|
|
||||||
spaceCanEdit,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -406,11 +365,7 @@ export class PageController {
|
|||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check page-level edit permission on the source page
|
return this.pageService.movePageToSpace(movedPage, dto.spaceId);
|
||||||
await this.pageAccessService.validateCanEdit(movedPage, user);
|
|
||||||
|
|
||||||
// Moves only accessible pages; inaccessible child pages become root pages in original space
|
|
||||||
return this.pageService.movePageToSpace(movedPage, dto.spaceId, user.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -421,10 +376,6 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page to copy not found');
|
throw new NotFoundException('Page to copy not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check page-level view permission on the source page (need to read to copy)
|
|
||||||
// Inaccessible child branches are automatically skipped during duplication
|
|
||||||
await this.pageAccessService.validateCanView(copiedPage, user);
|
|
||||||
|
|
||||||
// If spaceId is provided, it's a copy to different space
|
// If spaceId is provided, it's a copy to different space
|
||||||
if (dto.spaceId) {
|
if (dto.spaceId) {
|
||||||
const abilities = await Promise.all([
|
const abilities = await Promise.all([
|
||||||
@@ -467,23 +418,10 @@ export class PageController {
|
|||||||
user,
|
user,
|
||||||
movedPage.spaceId,
|
movedPage.spaceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check page-level edit permission
|
|
||||||
await this.pageAccessService.validateCanEdit(movedPage, user);
|
|
||||||
|
|
||||||
// If moving to a new parent, check permission on the target parent
|
|
||||||
if (dto.parentPageId && dto.parentPageId !== movedPage.parentPageId) {
|
|
||||||
const targetParent = await this.pageRepo.findById(dto.parentPageId);
|
|
||||||
if (!targetParent || targetParent.deletedAt) {
|
|
||||||
throw new NotFoundException('Target parent page not found');
|
|
||||||
}
|
|
||||||
await this.pageAccessService.validateCanEdit(targetParent, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.pageService.movePage(dto, movedPage);
|
return this.pageService.movePage(dto, movedPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,8 +433,10 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pageAccessService.validateCanView(page, user);
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
return this.pageService.getPageBreadCrumbs(page.id);
|
return this.pageService.getPageBreadCrumbs(page.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
|
import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
|
||||||
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
|
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
|
||||||
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import {
|
import {
|
||||||
@@ -49,7 +48,6 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
|||||||
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
|
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
|
||||||
import { markdownToHtml } from '@docmost/editor-ext';
|
import { markdownToHtml } from '@docmost/editor-ext';
|
||||||
import { WatcherService } from '../../watcher/watcher.service';
|
import { WatcherService } from '../../watcher/watcher.service';
|
||||||
import { sql } from 'kysely';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageService {
|
export class PageService {
|
||||||
@@ -57,7 +55,6 @@ export class PageService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
private pagePermissionRepo: PagePermissionRepo,
|
|
||||||
private attachmentRepo: AttachmentRepo,
|
private attachmentRepo: AttachmentRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly storageService: StorageService,
|
private readonly storageService: StorageService,
|
||||||
@@ -95,11 +92,7 @@ export class PageService {
|
|||||||
createPageDto.parentPageId,
|
createPageDto.parentPageId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) {
|
||||||
!parentPage ||
|
|
||||||
parentPage.deletedAt ||
|
|
||||||
parentPage.spaceId !== createPageDto.spaceId
|
|
||||||
) {
|
|
||||||
throw new NotFoundException('Parent page not found');
|
throw new NotFoundException('Parent page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,8 +262,6 @@ export class PageService {
|
|||||||
spaceId: string,
|
spaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
pageId?: string,
|
pageId?: string,
|
||||||
userId?: string,
|
|
||||||
spaceCanEdit?: boolean,
|
|
||||||
): Promise<CursorPaginationResult<Partial<Page> & { hasChildren: boolean }>> {
|
): Promise<CursorPaginationResult<Partial<Page> & { hasChildren: boolean }>> {
|
||||||
let query = this.db
|
let query = this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
@@ -295,7 +286,7 @@ export class PageService {
|
|||||||
query = query.where('parentPageId', 'is', null);
|
query = query.where('parentPageId', 'is', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await executeWithCursorPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
perPage: 250,
|
perPage: 250,
|
||||||
cursor: pagination.cursor,
|
cursor: pagination.cursor,
|
||||||
beforeCursor: pagination.beforeCursor,
|
beforeCursor: pagination.beforeCursor,
|
||||||
@@ -312,97 +303,10 @@ export class PageService {
|
|||||||
id: cursor.id,
|
id: cursor.id,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (userId && result.items.length > 0) {
|
|
||||||
const hasRestrictions =
|
|
||||||
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
|
|
||||||
|
|
||||||
if (!hasRestrictions) {
|
|
||||||
result.items = result.items.map((p: any) => ({
|
|
||||||
...p,
|
|
||||||
canEdit: spaceCanEdit ?? true,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
const pageIds = result.items.map((p: any) => p.id);
|
|
||||||
|
|
||||||
const accessiblePages =
|
|
||||||
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
|
|
||||||
pageIds,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const permissionMap = new Map(
|
|
||||||
accessiblePages.map((p) => [p.id, p.canEdit]),
|
|
||||||
);
|
|
||||||
|
|
||||||
result.items = result.items
|
|
||||||
.filter((p: any) => permissionMap.has(p.id))
|
|
||||||
.map((p: any) => ({
|
|
||||||
...p,
|
|
||||||
canEdit: permissionMap.get(p.id),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const pagesWithChildren = result.items.filter(
|
|
||||||
(p: any) => p.hasChildren,
|
|
||||||
);
|
|
||||||
if (pagesWithChildren.length > 0) {
|
|
||||||
const parentIds = pagesWithChildren.map((p: any) => p.id);
|
|
||||||
const parentsWithAccessibleChildren =
|
|
||||||
await this.pagePermissionRepo.getParentIdsWithAccessibleChildren(
|
|
||||||
parentIds,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
const hasAccessibleChildrenSet = new Set(
|
|
||||||
parentsWithAccessibleChildren,
|
|
||||||
);
|
|
||||||
|
|
||||||
result.items = result.items.map((p: any) => ({
|
|
||||||
...p,
|
|
||||||
hasChildren: p.hasChildren && hasAccessibleChildrenSet.has(p.id),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
|
async movePageToSpace(rootPage: Page, spaceId: string) {
|
||||||
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
|
||||||
includeContent: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter to only accessible pages while maintaining tree integrity
|
|
||||||
const accessiblePages = await this.filterAccessibleTreePages(
|
|
||||||
allPages,
|
|
||||||
rootPage.id,
|
|
||||||
userId,
|
|
||||||
rootPage.spaceId,
|
|
||||||
);
|
|
||||||
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
|
|
||||||
|
|
||||||
// Find inaccessible pages whose parent is being moved - these need to be orphaned
|
|
||||||
const pagesToOrphan = allPages.filter(
|
|
||||||
(p) =>
|
|
||||||
!accessibleIds.has(p.id) &&
|
|
||||||
p.parentPageId &&
|
|
||||||
accessibleIds.has(p.parentPageId),
|
|
||||||
);
|
|
||||||
|
|
||||||
await executeTx(this.db, async (trx) => {
|
await executeTx(this.db, async (trx) => {
|
||||||
// Orphan inaccessible child pages (make them root pages in original space)
|
|
||||||
for (const page of pagesToOrphan) {
|
|
||||||
const orphanPosition = await this.nextPagePosition(
|
|
||||||
rootPage.spaceId,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
await this.pageRepo.updatePage(
|
|
||||||
{ parentPageId: null, position: orphanPosition },
|
|
||||||
page.id,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update root page
|
// Update root page
|
||||||
const nextPosition = await this.nextPagePosition(spaceId);
|
const nextPosition = await this.nextPagePosition(spaceId);
|
||||||
await this.pageRepo.updatePage(
|
await this.pageRepo.updatePage(
|
||||||
@@ -410,54 +314,48 @@ export class PageService {
|
|||||||
rootPage.id,
|
rootPage.id,
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
|
const pageIds = await this.pageRepo
|
||||||
const pageIdsToMove = accessiblePages.map((p) => p.id);
|
.getPageAndDescendants(rootPage.id, { includeContent: false })
|
||||||
|
.then((pages) => pages.map((page) => page.id));
|
||||||
if (pageIdsToMove.length > 1) {
|
// The first id is the root page id
|
||||||
// Update sub pages (all accessible pages except root)
|
if (pageIds.length > 1) {
|
||||||
|
// Update sub pages
|
||||||
await this.pageRepo.updatePages(
|
await this.pageRepo.updatePages(
|
||||||
{ spaceId },
|
{ spaceId },
|
||||||
pageIdsToMove.filter((id) => id !== rootPage.id),
|
pageIds.filter((id) => id !== rootPage.id),
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageIdsToMove.length > 0) {
|
if (pageIds.length > 0) {
|
||||||
// Clear page-level permissions - moved pages inherit destination space permissions
|
|
||||||
// (page_permissions cascade deletes via foreign key)
|
|
||||||
await trx
|
|
||||||
.deleteFrom('pageAccess')
|
|
||||||
.where('pageId', 'in', pageIdsToMove)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// update spaceId in shares
|
// update spaceId in shares
|
||||||
await trx
|
await trx
|
||||||
.updateTable('shares')
|
.updateTable('shares')
|
||||||
.set({ spaceId: spaceId })
|
.set({ spaceId: spaceId })
|
||||||
.where('pageId', 'in', pageIdsToMove)
|
.where('pageId', 'in', pageIds)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Update comments
|
// Update comments
|
||||||
await trx
|
await trx
|
||||||
.updateTable('comments')
|
.updateTable('comments')
|
||||||
.set({ spaceId: spaceId })
|
.set({ spaceId: spaceId })
|
||||||
.where('pageId', 'in', pageIdsToMove)
|
.where('pageId', 'in', pageIds)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Update attachments
|
// Update attachments
|
||||||
await this.attachmentRepo.updateAttachmentsByPageId(
|
await this.attachmentRepo.updateAttachmentsByPageId(
|
||||||
{ spaceId },
|
{ spaceId },
|
||||||
pageIdsToMove,
|
pageIds,
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update watchers and remove those without access to new space
|
// Update watchers and remove those without access to new space
|
||||||
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
|
await this.watcherService.movePageWatchersToSpace(pageIds, spaceId, {
|
||||||
trx,
|
trx,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||||
pageId: pageIdsToMove,
|
pageId: pageIds,
|
||||||
workspaceId: rootPage.workspaceId,
|
workspaceId: rootPage.workspaceId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -483,18 +381,10 @@ export class PageService {
|
|||||||
nextPosition = await this.nextPagePosition(spaceId);
|
nextPosition = await this.nextPagePosition(spaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter to only accessible pages while maintaining tree integrity
|
|
||||||
const pages = await this.filterAccessibleTreePages(
|
|
||||||
allPages,
|
|
||||||
rootPage.id,
|
|
||||||
authUser.id,
|
|
||||||
rootPage.spaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const pageMap = new Map<string, CopyPageMapEntry>();
|
const pageMap = new Map<string, CopyPageMapEntry>();
|
||||||
pages.forEach((page) => {
|
pages.forEach((page) => {
|
||||||
pageMap.set(page.id, {
|
pageMap.set(page.id, {
|
||||||
@@ -702,11 +592,7 @@ export class PageService {
|
|||||||
// changing the page's parent
|
// changing the page's parent
|
||||||
if (dto.parentPageId) {
|
if (dto.parentPageId) {
|
||||||
const parentPage = await this.pageRepo.findById(dto.parentPageId);
|
const parentPage = await this.pageRepo.findById(dto.parentPageId);
|
||||||
if (
|
if (!parentPage || parentPage.spaceId !== movedPage.spaceId) {
|
||||||
!parentPage ||
|
|
||||||
parentPage.deletedAt ||
|
|
||||||
parentPage.spaceId !== movedPage.spaceId
|
|
||||||
) {
|
|
||||||
throw new NotFoundException('Parent page not found');
|
throw new NotFoundException('Parent page not found');
|
||||||
}
|
}
|
||||||
parentPageId = parentPage.id;
|
parentPageId = parentPage.id;
|
||||||
@@ -737,6 +623,7 @@ export class PageService {
|
|||||||
'spaceId',
|
'spaceId',
|
||||||
'deletedAt',
|
'deletedAt',
|
||||||
])
|
])
|
||||||
|
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||||
.where('id', '=', childPageId)
|
.where('id', '=', childPageId)
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
.unionAll((exp) =>
|
.unionAll((exp) =>
|
||||||
@@ -752,21 +639,30 @@ export class PageService {
|
|||||||
'p.spaceId',
|
'p.spaceId',
|
||||||
'p.deletedAt',
|
'p.deletedAt',
|
||||||
])
|
])
|
||||||
|
.select(
|
||||||
|
exp
|
||||||
|
.selectFrom('pages as child')
|
||||||
|
.select((eb) =>
|
||||||
|
eb
|
||||||
|
.case()
|
||||||
|
.when(eb.fn.countAll(), '>', 0)
|
||||||
|
.then(true)
|
||||||
|
.else(false)
|
||||||
|
.end()
|
||||||
|
.as('count'),
|
||||||
|
)
|
||||||
|
.whereRef('child.parentPageId', '=', 'id')
|
||||||
|
.where('child.deletedAt', 'is', null)
|
||||||
|
.limit(1)
|
||||||
|
.as('hasChildren'),
|
||||||
|
)
|
||||||
|
//.select((eb) => this.withHasChildren(eb))
|
||||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
||||||
.where('p.deletedAt', 'is', null),
|
.where('p.deletedAt', 'is', null),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.selectFrom('page_ancestors')
|
.selectFrom('page_ancestors')
|
||||||
.selectAll('page_ancestors')
|
.selectAll()
|
||||||
.select((eb) =>
|
|
||||||
eb.exists(
|
|
||||||
eb
|
|
||||||
.selectFrom('pages as child')
|
|
||||||
.select(sql`1`.as('one'))
|
|
||||||
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
|
|
||||||
.where('child.deletedAt', 'is', null),
|
|
||||||
).as('hasChildren'),
|
|
||||||
)
|
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return ancestors.reverse();
|
return ancestors.reverse();
|
||||||
@@ -774,72 +670,23 @@ export class PageService {
|
|||||||
|
|
||||||
async getRecentSpacePages(
|
async getRecentSpacePages(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
userId: string,
|
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<Page>> {
|
): Promise<CursorPaginationResult<Page>> {
|
||||||
const result = await this.pageRepo.getRecentPagesInSpace(
|
return this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
|
||||||
spaceId,
|
|
||||||
pagination,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.items.length > 0) {
|
|
||||||
const pageIds = result.items.map((p) => p.id);
|
|
||||||
const accessibleIds =
|
|
||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
|
||||||
pageIds,
|
|
||||||
userId,
|
|
||||||
spaceId,
|
|
||||||
});
|
|
||||||
const accessibleSet = new Set(accessibleIds);
|
|
||||||
result.items = result.items.filter((p) => accessibleSet.has(p.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecentPages(
|
async getRecentPages(
|
||||||
userId: string,
|
userId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<Page>> {
|
): Promise<CursorPaginationResult<Page>> {
|
||||||
const result = await this.pageRepo.getRecentPages(userId, pagination);
|
return this.pageRepo.getRecentPages(userId, pagination);
|
||||||
|
|
||||||
if (result.items.length > 0) {
|
|
||||||
const pageIds = result.items.map((p) => p.id);
|
|
||||||
const accessibleIds =
|
|
||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
|
||||||
pageIds,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
const accessibleSet = new Set(accessibleIds);
|
|
||||||
result.items = result.items.filter((p) => accessibleSet.has(p.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDeletedSpacePages(
|
async getDeletedSpacePages(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
userId: string,
|
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<Page>> {
|
): Promise<CursorPaginationResult<Page>> {
|
||||||
const result = await this.pageRepo.getDeletedPagesInSpace(
|
return this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
|
||||||
spaceId,
|
|
||||||
pagination,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.items.length > 0) {
|
|
||||||
const pageIds = result.items.map((p) => p.id);
|
|
||||||
const accessibleIds =
|
|
||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
|
||||||
pageIds,
|
|
||||||
userId,
|
|
||||||
spaceId,
|
|
||||||
});
|
|
||||||
const accessibleSet = new Set(accessibleIds);
|
|
||||||
result.items = result.items.filter((p) => accessibleSet.has(p.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async forceDelete(pageId: string, workspaceId: string): Promise<void> {
|
async forceDelete(pageId: string, workspaceId: string): Promise<void> {
|
||||||
@@ -929,61 +776,4 @@ export class PageService {
|
|||||||
|
|
||||||
return prosemirrorJson;
|
return prosemirrorJson;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters a list of pages to only those accessible to the user while maintaining tree integrity.
|
|
||||||
* A page is included only if:
|
|
||||||
* 1. The user has access to it
|
|
||||||
* 2. Its parent is also included (or it's the root page)
|
|
||||||
* This ensures that if a middle page is inaccessible, its entire subtree is excluded.
|
|
||||||
*/
|
|
||||||
private async filterAccessibleTreePages<
|
|
||||||
T extends { id: string; parentPageId: string | null },
|
|
||||||
>(
|
|
||||||
pages: T[],
|
|
||||||
rootPageId: string,
|
|
||||||
userId: string,
|
|
||||||
spaceId?: string,
|
|
||||||
): Promise<T[]> {
|
|
||||||
if (pages.length === 0) return [];
|
|
||||||
|
|
||||||
const pageIds = pages.map((p) => p.id);
|
|
||||||
const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds(
|
|
||||||
{
|
|
||||||
pageIds,
|
|
||||||
userId,
|
|
||||||
spaceId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const accessibleSet = new Set(accessibleIds);
|
|
||||||
|
|
||||||
// Prune: include a page only if it's accessible AND its parent chain to root is included
|
|
||||||
const includedIds = new Set<string>();
|
|
||||||
|
|
||||||
// Process pages in a way that ensures parents are processed before children
|
|
||||||
// We do this by iterating until no more pages can be added
|
|
||||||
let changed = true;
|
|
||||||
while (changed) {
|
|
||||||
changed = false;
|
|
||||||
for (const page of pages) {
|
|
||||||
if (includedIds.has(page.id)) continue;
|
|
||||||
if (!accessibleSet.has(page.id)) continue;
|
|
||||||
|
|
||||||
// Root page: include if accessible
|
|
||||||
if (page.id === rootPageId) {
|
|
||||||
includedIds.add(page.id);
|
|
||||||
changed = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-root: include if parent is already included
|
|
||||||
if (page.parentPageId && includedIds.has(page.parentPageId)) {
|
|
||||||
includedIds.add(page.id);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages.filter((p) => includedIds.has(p.id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { sql } from 'kysely';
|
|||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const tsquery = require('pg-tsquery')();
|
const tsquery = require('pg-tsquery')();
|
||||||
@@ -19,7 +18,6 @@ export class SearchService {
|
|||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
private shareRepo: ShareRepo,
|
private shareRepo: ShareRepo,
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
private pagePermissionRepo: PagePermissionRepo,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async searchPage(
|
async searchPage(
|
||||||
@@ -117,23 +115,10 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
let results: any[] = await queryResults.execute();
|
queryResults = await queryResults.execute();
|
||||||
|
|
||||||
// Filter results by page-level permissions (if user is authenticated)
|
|
||||||
if (opts.userId && results.length > 0) {
|
|
||||||
const pageIds = results.map((r: any) => r.id);
|
|
||||||
const accessibleIds =
|
|
||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
|
||||||
pageIds,
|
|
||||||
userId: opts.userId,
|
|
||||||
spaceId: searchParams.spaceId,
|
|
||||||
});
|
|
||||||
const accessibleSet = new Set(accessibleIds);
|
|
||||||
results = results.filter((r: any) => accessibleSet.has(r.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const searchResults = results.map((result: SearchResponseDto) => {
|
const searchResults = queryResults.map((result: SearchResponseDto) => {
|
||||||
if (result.highlight) {
|
if (result.highlight) {
|
||||||
result.highlight = result.highlight
|
result.highlight = result.highlight
|
||||||
.replace(/\r\n|\r|\n/g, ' ')
|
.replace(/\r\n|\r|\n/g, ' ')
|
||||||
@@ -222,19 +207,6 @@ export class SearchService {
|
|||||||
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
|
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
|
||||||
pages = await pageSearch.execute();
|
pages = await pageSearch.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by page-level permissions
|
|
||||||
if (pages.length > 0) {
|
|
||||||
const pageIds = pages.map((p) => p.id);
|
|
||||||
const accessibleIds =
|
|
||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
|
||||||
pageIds,
|
|
||||||
userId,
|
|
||||||
spaceId: suggestion?.spaceId,
|
|
||||||
});
|
|
||||||
const accessibleSet = new Set(accessibleIds);
|
|
||||||
pages = pages.filter((p) => accessibleSet.has(p.id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { users, groups, pages };
|
return { users, groups, pages };
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from '../casl/interfaces/space-ability.type';
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
|
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||||
import { ShareService } from './share.service';
|
import { ShareService } from './share.service';
|
||||||
import {
|
import {
|
||||||
CreateShareDto,
|
CreateShareDto,
|
||||||
@@ -21,8 +26,6 @@ import {
|
|||||||
UpdateShareDto,
|
UpdateShareDto,
|
||||||
} from './dto/share.dto';
|
} from './dto/share.dto';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
|
||||||
import { PageAccessService } from '../page-access/page-access.service';
|
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { Public } from '../../common/decorators/public.decorator';
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
@@ -35,10 +38,9 @@ import { hasLicenseOrEE } from '../../common/helpers';
|
|||||||
export class ShareController {
|
export class ShareController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly shareService: ShareService,
|
private readonly shareService: ShareService,
|
||||||
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
private readonly shareRepo: ShareRepo,
|
private readonly shareRepo: ShareRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
|
||||||
private readonly pageAccessService: PageAccessService,
|
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -117,7 +119,10 @@ export class ShareController {
|
|||||||
throw new NotFoundException('Shared page not found');
|
throw new NotFoundException('Shared page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pageAccessService.validateCanView(page, user);
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Share)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.shareService.getShareForPage(page.id, workspace.id);
|
return this.shareService.getShareForPage(page.id, workspace.id);
|
||||||
}
|
}
|
||||||
@@ -135,17 +140,9 @@ export class ShareController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// User must be able to edit the page to create a share
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
//TODO: i dont think this is neccessary if we prevent restricted pages from getting shared
|
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) {
|
||||||
// rather, use space level permission and workspace/space level sharing restriction
|
throw new ForbiddenException();
|
||||||
await this.pageAccessService.validateCanEdit(page, user);
|
|
||||||
|
|
||||||
// Prevent sharing restricted pages
|
|
||||||
const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
|
|
||||||
page.id,
|
|
||||||
);
|
|
||||||
if (isRestricted) {
|
|
||||||
throw new BadRequestException('Cannot share a restricted page');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharingAllowed = await this.shareService.isSharingAllowed(
|
const sharingAllowed = await this.shareService.isSharingAllowed(
|
||||||
@@ -173,14 +170,11 @@ export class ShareController {
|
|||||||
throw new NotFoundException('Share not found');
|
throw new NotFoundException('Share not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = await this.pageRepo.findById(share.pageId);
|
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
|
||||||
if (!page) {
|
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) {
|
||||||
throw new NotFoundException('Page not found');
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
// User must be able to edit the page to update its share
|
|
||||||
await this.pageAccessService.validateCanEdit(page, user);
|
|
||||||
|
|
||||||
return this.shareService.updateShare(share.id, updateShareDto);
|
return this.shareService.updateShare(share.id, updateShareDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,14 +187,11 @@ export class ShareController {
|
|||||||
throw new NotFoundException('Share not found');
|
throw new NotFoundException('Share not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = await this.pageRepo.findById(share.pageId);
|
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
|
||||||
if (!page) {
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) {
|
||||||
throw new NotFoundException('Page not found');
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
// User must be able to edit the page to delete its share
|
|
||||||
await this.pageAccessService.validateCanEdit(page, user);
|
|
||||||
|
|
||||||
await this.shareRepo.deleteShare(share.id);
|
await this.shareRepo.deleteShare(share.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} from '../../common/helpers/prosemirror/utils';
|
} from '../../common/helpers/prosemirror/utils';
|
||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
|
||||||
import { updateAttachmentAttr } from './share.util';
|
import { updateAttachmentAttr } from './share.util';
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
@@ -32,7 +31,6 @@ export class ShareService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly shareRepo: ShareRepo,
|
private readonly shareRepo: ShareRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
) {}
|
) {}
|
||||||
@@ -43,20 +41,12 @@ export class ShareService {
|
|||||||
throw new NotFoundException('Share not found');
|
throw new NotFoundException('Share not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRestricted =
|
|
||||||
await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
|
|
||||||
if (isRestricted) {
|
|
||||||
throw new NotFoundException('Share not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (share.includeSubPages) {
|
if (share.includeSubPages) {
|
||||||
const pageTree =
|
const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, {
|
||||||
await this.pageRepo.getPageAndDescendantsExcludingRestricted(
|
includeContent: false,
|
||||||
share.pageId,
|
});
|
||||||
{ includeContent: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
return { share, pageTree };
|
return { share, pageTree: pageList };
|
||||||
} else {
|
} else {
|
||||||
return { share, pageTree: [] };
|
return { share, pageTree: [] };
|
||||||
}
|
}
|
||||||
@@ -122,13 +112,6 @@ export class ShareService {
|
|||||||
throw new NotFoundException('Shared page not found');
|
throw new NotFoundException('Shared page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block access to restricted pages
|
|
||||||
const isRestricted =
|
|
||||||
await this.pagePermissionRepo.hasRestrictedAncestor(page.id);
|
|
||||||
if (isRestricted) {
|
|
||||||
throw new NotFoundException('Shared page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
page.content = await this.updatePublicAttachments(page);
|
page.content = await this.updatePublicAttachments(page);
|
||||||
|
|
||||||
return { page, share };
|
return { page, share };
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
|||||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { PageRepo } from './repos/page/page.repo';
|
import { PageRepo } from './repos/page/page.repo';
|
||||||
import { PagePermissionRepo } from './repos/page/page-permission.repo';
|
|
||||||
import { CommentRepo } from './repos/comment/comment.repo';
|
import { CommentRepo } from './repos/comment/comment.repo';
|
||||||
import { PageHistoryRepo } from './repos/page/page-history.repo';
|
import { PageHistoryRepo } from './repos/page/page-history.repo';
|
||||||
import { AttachmentRepo } from './repos/attachment/attachment.repo';
|
import { AttachmentRepo } from './repos/attachment/attachment.repo';
|
||||||
@@ -77,7 +76,6 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
SpaceRepo,
|
SpaceRepo,
|
||||||
SpaceMemberRepo,
|
SpaceMemberRepo,
|
||||||
PageRepo,
|
PageRepo,
|
||||||
PagePermissionRepo,
|
|
||||||
PageHistoryRepo,
|
PageHistoryRepo,
|
||||||
CommentRepo,
|
CommentRepo,
|
||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
@@ -96,7 +94,6 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
SpaceRepo,
|
SpaceRepo,
|
||||||
SpaceMemberRepo,
|
SpaceMemberRepo,
|
||||||
PageRepo,
|
PageRepo,
|
||||||
PagePermissionRepo,
|
|
||||||
PageHistoryRepo,
|
PageHistoryRepo,
|
||||||
CommentRepo,
|
CommentRepo,
|
||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import { Kysely, sql } from 'kysely';
|
|
||||||
|
|
||||||
export async function up(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema
|
|
||||||
.createTable('page_access')
|
|
||||||
.addColumn('id', 'uuid', (col) =>
|
|
||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
|
||||||
)
|
|
||||||
.addColumn('page_id', 'uuid', (col) =>
|
|
||||||
col.notNull().unique().references('pages.id').onDelete('cascade'),
|
|
||||||
)
|
|
||||||
.addColumn('workspace_id', 'uuid', (col) =>
|
|
||||||
col.notNull().references('workspaces.id').onDelete('cascade'),
|
|
||||||
)
|
|
||||||
.addColumn('access_level', 'varchar', (col) => col.notNull())
|
|
||||||
.addColumn('creator_id', 'uuid', (col) =>
|
|
||||||
col.references('users.id').onDelete('set null'),
|
|
||||||
)
|
|
||||||
.addColumn('created_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createTable('page_permissions')
|
|
||||||
.addColumn('id', 'uuid', (col) =>
|
|
||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
|
||||||
)
|
|
||||||
.addColumn('page_access_id', 'uuid', (col) =>
|
|
||||||
col.notNull().references('page_access.id').onDelete('cascade'),
|
|
||||||
)
|
|
||||||
.addColumn('user_id', 'uuid', (col) =>
|
|
||||||
col.references('users.id').onDelete('cascade'),
|
|
||||||
)
|
|
||||||
.addColumn('group_id', 'uuid', (col) =>
|
|
||||||
col.references('groups.id').onDelete('cascade'),
|
|
||||||
)
|
|
||||||
.addColumn('role', 'varchar', (col) => col.notNull())
|
|
||||||
.addColumn('added_by_id', 'uuid', (col) =>
|
|
||||||
col.references('users.id').onDelete('set null'),
|
|
||||||
)
|
|
||||||
.addColumn('created_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addUniqueConstraint('page_access_user_unique', [
|
|
||||||
'page_access_id',
|
|
||||||
'user_id',
|
|
||||||
])
|
|
||||||
.addUniqueConstraint('page_access_group_unique', [
|
|
||||||
'page_access_id',
|
|
||||||
'group_id',
|
|
||||||
])
|
|
||||||
.addCheckConstraint(
|
|
||||||
'allow_either_user_id_or_group_id_check',
|
|
||||||
sql`((user_id IS NOT NULL AND group_id IS NULL) OR (user_id IS NULL AND group_id IS NOT NULL))`,
|
|
||||||
)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createIndex('idx_page_access_workspace')
|
|
||||||
.on('page_access')
|
|
||||||
.column('workspace_id')
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createIndex('idx_page_permissions_page_access')
|
|
||||||
.on('page_permissions')
|
|
||||||
.column('page_access_id')
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createIndex('idx_page_permissions_user')
|
|
||||||
.on('page_permissions')
|
|
||||||
.column('user_id')
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createIndex('idx_page_permissions_group')
|
|
||||||
.on('page_permissions')
|
|
||||||
.column('group_id')
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema.dropTable('page_permissions').ifExists().execute();
|
|
||||||
await db.schema.dropTable('page_access').ifExists().execute();
|
|
||||||
}
|
|
||||||
@@ -306,21 +306,6 @@ export function defaultEncodeCursor<
|
|||||||
return Buffer.from(cursor.toString(), 'utf8').toString('base64url');
|
return Buffer.from(cursor.toString(), 'utf8').toString('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyCursorPaginationResult<T>(
|
|
||||||
limit: number,
|
|
||||||
): CursorPaginationResult<T> {
|
|
||||||
return {
|
|
||||||
items: [],
|
|
||||||
meta: {
|
|
||||||
limit,
|
|
||||||
hasNextPage: false,
|
|
||||||
hasPrevPage: false,
|
|
||||||
nextCursor: null,
|
|
||||||
prevCursor: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultDecodeCursor<
|
export function defaultDecodeCursor<
|
||||||
DB,
|
DB,
|
||||||
TB extends keyof DB,
|
TB extends keyof DB,
|
||||||
|
|||||||
@@ -175,14 +175,4 @@ export class GroupUserRepo {
|
|||||||
.where('groupId', '=', groupId)
|
.where('groupId', '=', groupId)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserGroupIds(userId: string): Promise<string[]> {
|
|
||||||
const results = await this.db
|
|
||||||
.selectFrom('groupUsers')
|
|
||||||
.select('groupId')
|
|
||||||
.where('userId', '=', userId)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
return results.map((r) => r.groupId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -175,13 +175,11 @@ export class PageRepo {
|
|||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(['id'])
|
.select(['id'])
|
||||||
.where('id', '=', pageId)
|
.where('id', '=', pageId)
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.unionAll((exp) =>
|
.unionAll((exp) =>
|
||||||
exp
|
exp
|
||||||
.selectFrom('pages as p')
|
.selectFrom('pages as p')
|
||||||
.select(['p.id'])
|
.select(['p.id'])
|
||||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
||||||
.where('p.deletedAt', 'is', null),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.selectFrom('page_descendants')
|
.selectFrom('page_descendants')
|
||||||
@@ -199,7 +197,6 @@ export class PageRepo {
|
|||||||
deletedAt: currentDate,
|
deletedAt: currentDate,
|
||||||
})
|
})
|
||||||
.where('id', 'in', pageIds)
|
.where('id', 'in', pageIds)
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute();
|
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute();
|
||||||
@@ -475,75 +472,4 @@ export class PageRepo {
|
|||||||
.selectAll()
|
.selectAll()
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get page and all descendants, excluding restricted pages and their subtrees.
|
|
||||||
* More efficient than getPageAndDescendants + filtering because:
|
|
||||||
* 1. Single DB query (no separate restricted IDs query)
|
|
||||||
* 2. Stops traversing at restricted pages (doesn't fetch data to discard)
|
|
||||||
* 3. No in-memory filtering needed
|
|
||||||
*/
|
|
||||||
async getPageAndDescendantsExcludingRestricted(
|
|
||||||
parentPageId: string,
|
|
||||||
opts: { includeContent: boolean },
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
this.db
|
|
||||||
.withRecursive('page_hierarchy', (db) =>
|
|
||||||
db
|
|
||||||
.selectFrom('pages')
|
|
||||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
|
|
||||||
.select([
|
|
||||||
'pages.id',
|
|
||||||
'pages.slugId',
|
|
||||||
'pages.title',
|
|
||||||
'pages.icon',
|
|
||||||
'pages.position',
|
|
||||||
'pages.parentPageId',
|
|
||||||
'pages.spaceId',
|
|
||||||
'pages.workspaceId',
|
|
||||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
|
||||||
])
|
|
||||||
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
|
|
||||||
.where('pages.id', '=', parentPageId)
|
|
||||||
.where('pages.deletedAt', 'is', null)
|
|
||||||
.unionAll((exp) =>
|
|
||||||
exp
|
|
||||||
.selectFrom('pages as p')
|
|
||||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
|
||||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
|
|
||||||
.select([
|
|
||||||
'p.id',
|
|
||||||
'p.slugId',
|
|
||||||
'p.title',
|
|
||||||
'p.icon',
|
|
||||||
'p.position',
|
|
||||||
'p.parentPageId',
|
|
||||||
'p.spaceId',
|
|
||||||
'p.workspaceId',
|
|
||||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
|
||||||
])
|
|
||||||
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
|
||||||
.where('p.deletedAt', 'is', null)
|
|
||||||
// Only recurse into children of non-restricted pages
|
|
||||||
.where('ph.isRestricted', '=', false),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.selectFrom('page_hierarchy')
|
|
||||||
.select([
|
|
||||||
'id',
|
|
||||||
'slugId',
|
|
||||||
'title',
|
|
||||||
'icon',
|
|
||||||
'position',
|
|
||||||
'parentPageId',
|
|
||||||
'spaceId',
|
|
||||||
'workspaceId',
|
|
||||||
])
|
|
||||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
|
||||||
// Filter out restricted pages from the result
|
|
||||||
.where('isRestricted', '=', false)
|
|
||||||
.execute()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
type PagePermissionUserMember = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
type: 'user';
|
|
||||||
role: string;
|
|
||||||
createdAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PagePermissionGroupMember = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
memberCount: number;
|
|
||||||
isDefault: boolean;
|
|
||||||
type: 'group';
|
|
||||||
role: string;
|
|
||||||
createdAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PagePermissionMember =
|
|
||||||
| PagePermissionUserMember
|
|
||||||
| PagePermissionGroupMember;
|
|
||||||
-24
@@ -390,27 +390,6 @@ export interface Watchers {
|
|||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageAccess {
|
|
||||||
id: Generated<string>;
|
|
||||||
pageId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
accessLevel: string;
|
|
||||||
creatorId: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PagePermissions {
|
|
||||||
id: Generated<string>;
|
|
||||||
pageAccessId: string;
|
|
||||||
userId: string | null;
|
|
||||||
groupId: string | null;
|
|
||||||
role: string;
|
|
||||||
addedById: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DB {
|
export interface DB {
|
||||||
apiKeys: ApiKeys;
|
apiKeys: ApiKeys;
|
||||||
attachments: Attachments;
|
attachments: Attachments;
|
||||||
@@ -423,10 +402,7 @@ export interface DB {
|
|||||||
groups: Groups;
|
groups: Groups;
|
||||||
groupUsers: GroupUsers;
|
groupUsers: GroupUsers;
|
||||||
notifications: Notifications;
|
notifications: Notifications;
|
||||||
pageAccess: PageAccess;
|
|
||||||
pageHierarchy: PageHierarchy;
|
|
||||||
pageHistory: PageHistory;
|
pageHistory: PageHistory;
|
||||||
pagePermissions: PagePermissions;
|
|
||||||
pages: Pages;
|
pages: Pages;
|
||||||
shares: Shares;
|
shares: Shares;
|
||||||
spaceMembers: SpaceMembers;
|
spaceMembers: SpaceMembers;
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import {
|
|||||||
Comments,
|
Comments,
|
||||||
Groups,
|
Groups,
|
||||||
Notifications,
|
Notifications,
|
||||||
PageAccess as _PageAccess,
|
|
||||||
PagePermissions as _PagePermissions,
|
|
||||||
Pages,
|
Pages,
|
||||||
Spaces,
|
Spaces,
|
||||||
Users,
|
Users,
|
||||||
@@ -145,13 +143,3 @@ export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
|
|||||||
export type Watcher = Selectable<Watchers>;
|
export type Watcher = Selectable<Watchers>;
|
||||||
export type InsertableWatcher = Insertable<Watchers>;
|
export type InsertableWatcher = Insertable<Watchers>;
|
||||||
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
|
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
|
||||||
|
|
||||||
// Page Access
|
|
||||||
export type PageAccess = Selectable<_PageAccess>;
|
|
||||||
export type InsertablePageAccess = Insertable<_PageAccess>;
|
|
||||||
export type UpdatablePageAccess = Updateable<Omit<_PageAccess, 'id'>>;
|
|
||||||
|
|
||||||
// Page Permission
|
|
||||||
export type PagePermission = Selectable<_PagePermissions>;
|
|
||||||
export type InsertablePagePermission = Insertable<_PagePermissions>;
|
|
||||||
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
|
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 028e31724e...71b4323d1b
@@ -16,7 +16,6 @@ import { User } from '@docmost/db/types/entity.types';
|
|||||||
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { PageAccessService } from '../../core/page-access/page-access.service';
|
|
||||||
import {
|
import {
|
||||||
SpaceCaslAction,
|
SpaceCaslAction,
|
||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
@@ -33,7 +32,6 @@ export class ExportController {
|
|||||||
private readonly exportService: ExportService,
|
private readonly exportService: ExportService,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
private readonly pageAccessService: PageAccessService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -52,14 +50,16 @@ export class ExportController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pageAccessService.validateCanView(page, user);
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
const zipFileStream = await this.exportService.exportPages(
|
const zipFileStream = await this.exportService.exportPages(
|
||||||
dto.pageId,
|
dto.pageId,
|
||||||
dto.format,
|
dto.format,
|
||||||
dto.includeAttachments,
|
dto.includeAttachments,
|
||||||
dto.includeChildren,
|
dto.includeChildren,
|
||||||
user.id,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const fileName = sanitize(page.title || 'untitled') + '.zip';
|
const fileName = sanitize(page.title || 'untitled') + '.zip';
|
||||||
@@ -90,7 +90,6 @@ export class ExportController {
|
|||||||
dto.spaceId,
|
dto.spaceId,
|
||||||
dto.format,
|
dto.format,
|
||||||
dto.includeAttachments,
|
dto.includeAttachments,
|
||||||
user.id,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
res.headers({
|
res.headers({
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
ExportPageMetadata,
|
ExportPageMetadata,
|
||||||
} from '../../common/helpers/types/export-metadata.types';
|
} from '../../common/helpers/types/export-metadata.types';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
|
||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
import { EditorState } from '@tiptap/pm/state';
|
import { EditorState } from '@tiptap/pm/state';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
@@ -45,7 +44,6 @@ export class ExportService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly storageService: StorageService,
|
private readonly storageService: StorageService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
@@ -102,8 +100,6 @@ export class ExportService {
|
|||||||
format: string,
|
format: string,
|
||||||
includeAttachments: boolean,
|
includeAttachments: boolean,
|
||||||
includeChildren: boolean,
|
includeChildren: boolean,
|
||||||
userId?: string,
|
|
||||||
ignorePermissions = false,
|
|
||||||
) {
|
) {
|
||||||
let pages: Page[];
|
let pages: Page[];
|
||||||
|
|
||||||
@@ -117,7 +113,7 @@ export class ExportService {
|
|||||||
const page = await this.pageRepo.findById(pageId, {
|
const page = await this.pageRepo.findById(pageId, {
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
});
|
});
|
||||||
if (page) {
|
if (page){
|
||||||
pages = [page];
|
pages = [page];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,38 +122,14 @@ export class ExportService {
|
|||||||
throw new BadRequestException('No pages to export');
|
throw new BadRequestException('No pages to export');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ignorePermissions && userId) {
|
|
||||||
pages = await this.filterPagesForExport(
|
|
||||||
pages,
|
|
||||||
pageId,
|
|
||||||
userId,
|
|
||||||
pages[0].spaceId,
|
|
||||||
);
|
|
||||||
if (pages.length === 0) {
|
|
||||||
throw new BadRequestException('No accessible pages to export');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentPageIndex = pages.findIndex((obj) => obj.id === pageId);
|
const parentPageIndex = pages.findIndex((obj) => obj.id === pageId);
|
||||||
|
|
||||||
//After filtering by permissions, if the root page itself is not accessible to the user, findIndex returns -1
|
|
||||||
if (parentPageIndex === -1) {
|
|
||||||
throw new BadRequestException('Root page is not accessible');
|
|
||||||
}
|
|
||||||
// set to null to make export of pages with parentId work
|
// set to null to make export of pages with parentId work
|
||||||
pages[parentPageIndex].parentPageId = null;
|
pages[parentPageIndex].parentPageId = null;
|
||||||
|
|
||||||
const tree = buildTree(pages as Page[]);
|
const tree = buildTree(pages as Page[]);
|
||||||
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
await this.zipPages(
|
await this.zipPages(tree, format, zip, includeAttachments);
|
||||||
tree,
|
|
||||||
format,
|
|
||||||
zip,
|
|
||||||
includeAttachments,
|
|
||||||
userId,
|
|
||||||
ignorePermissions,
|
|
||||||
);
|
|
||||||
|
|
||||||
const zipFile = zip.generateNodeStream({
|
const zipFile = zip.generateNodeStream({
|
||||||
type: 'nodebuffer',
|
type: 'nodebuffer',
|
||||||
@@ -172,8 +144,6 @@ export class ExportService {
|
|||||||
spaceId: string,
|
spaceId: string,
|
||||||
format: string,
|
format: string,
|
||||||
includeAttachments: boolean,
|
includeAttachments: boolean,
|
||||||
userId?: string,
|
|
||||||
ignorePermissions = false,
|
|
||||||
) {
|
) {
|
||||||
const space = await this.db
|
const space = await this.db
|
||||||
.selectFrom('spaces')
|
.selectFrom('spaces')
|
||||||
@@ -185,7 +155,7 @@ export class ExportService {
|
|||||||
throw new NotFoundException('Space not found');
|
throw new NotFoundException('Space not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
let pages = await this.db
|
const pages = await this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select([
|
.select([
|
||||||
'pages.id',
|
'pages.id',
|
||||||
@@ -204,30 +174,11 @@ export class ExportService {
|
|||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
if (!ignorePermissions && userId) {
|
|
||||||
pages = await this.filterPagesForExport(
|
|
||||||
pages as Page[],
|
|
||||||
null,
|
|
||||||
userId,
|
|
||||||
spaceId,
|
|
||||||
);
|
|
||||||
if (pages.length === 0) {
|
|
||||||
throw new BadRequestException('No accessible pages to export');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tree = buildTree(pages as Page[]);
|
const tree = buildTree(pages as Page[]);
|
||||||
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
|
|
||||||
await this.zipPages(
|
await this.zipPages(tree, format, zip, includeAttachments);
|
||||||
tree,
|
|
||||||
format,
|
|
||||||
zip,
|
|
||||||
includeAttachments,
|
|
||||||
userId,
|
|
||||||
ignorePermissions,
|
|
||||||
);
|
|
||||||
|
|
||||||
const zipFile = zip.generateNodeStream({
|
const zipFile = zip.generateNodeStream({
|
||||||
type: 'nodebuffer',
|
type: 'nodebuffer',
|
||||||
@@ -247,8 +198,6 @@ export class ExportService {
|
|||||||
format: string,
|
format: string,
|
||||||
zip: JSZip,
|
zip: JSZip,
|
||||||
includeAttachments: boolean,
|
includeAttachments: boolean,
|
||||||
userId?: string,
|
|
||||||
ignorePermissions = false,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const slugIdToPath: Record<string, string> = {};
|
const slugIdToPath: Record<string, string> = {};
|
||||||
const pageIdToFilePath: Record<string, string> = {};
|
const pageIdToFilePath: Record<string, string> = {};
|
||||||
@@ -270,8 +219,6 @@ export class ExportService {
|
|||||||
const prosemirrorJson = await this.turnPageMentionsToLinks(
|
const prosemirrorJson = await this.turnPageMentionsToLinks(
|
||||||
getProsemirrorContent(page.content),
|
getProsemirrorContent(page.content),
|
||||||
page.workspaceId,
|
page.workspaceId,
|
||||||
userId,
|
|
||||||
ignorePermissions,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentPagePath = slugIdToPath[page.slugId];
|
const currentPagePath = slugIdToPath[page.slugId];
|
||||||
@@ -356,15 +303,10 @@ export class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async turnPageMentionsToLinks(
|
async turnPageMentionsToLinks(prosemirrorJson: any, workspaceId: string) {
|
||||||
prosemirrorJson: any,
|
|
||||||
workspaceId: string,
|
|
||||||
userId?: string,
|
|
||||||
ignorePermissions = false,
|
|
||||||
) {
|
|
||||||
const doc = jsonToNode(prosemirrorJson);
|
const doc = jsonToNode(prosemirrorJson);
|
||||||
|
|
||||||
let pageMentionIds: string[] = [];
|
const pageMentionIds = [];
|
||||||
|
|
||||||
doc.descendants((node: Node) => {
|
doc.descendants((node: Node) => {
|
||||||
if (node.type.name === 'mention' && node.attrs.entityType === 'page') {
|
if (node.type.name === 'mention' && node.attrs.entityType === 'page') {
|
||||||
@@ -378,32 +320,13 @@ export class ExportService {
|
|||||||
return prosemirrorJson;
|
return prosemirrorJson;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to only accessible pages if permissions are enforced
|
const pages = await this.db
|
||||||
if (!ignorePermissions && userId) {
|
.selectFrom('pages')
|
||||||
pageMentionIds =
|
.select(['id', 'slugId', 'title', 'creatorId', 'spaceId', 'workspaceId'])
|
||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
.select((eb) => this.pageRepo.withSpace(eb))
|
||||||
pageIds: pageMentionIds,
|
.where('id', 'in', pageMentionIds)
|
||||||
userId,
|
.where('workspaceId', '=', workspaceId)
|
||||||
});
|
.execute();
|
||||||
}
|
|
||||||
|
|
||||||
const pages =
|
|
||||||
pageMentionIds.length > 0
|
|
||||||
? await this.db
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select([
|
|
||||||
'id',
|
|
||||||
'slugId',
|
|
||||||
'title',
|
|
||||||
'creatorId',
|
|
||||||
'spaceId',
|
|
||||||
'workspaceId',
|
|
||||||
])
|
|
||||||
.select((eb) => this.pageRepo.withSpace(eb))
|
|
||||||
.where('id', 'in', pageMentionIds)
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.execute()
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const pageMap = new Map(pages.map((page) => [page.id, page]));
|
const pageMap = new Map(pages.map((page) => [page.id, page]));
|
||||||
|
|
||||||
@@ -475,51 +398,4 @@ export class ExportService {
|
|||||||
|
|
||||||
return updatedDoc.toJSON();
|
return updatedDoc.toJSON();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async filterPagesForExport(
|
|
||||||
pages: Page[],
|
|
||||||
rootPageId: string | null,
|
|
||||||
userId: string,
|
|
||||||
spaceId: string,
|
|
||||||
): Promise<Page[]> {
|
|
||||||
if (pages.length === 0) return [];
|
|
||||||
|
|
||||||
const pageIds = pages.map((p) => p.id);
|
|
||||||
const accessibleIds =
|
|
||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
|
||||||
pageIds,
|
|
||||||
userId,
|
|
||||||
spaceId,
|
|
||||||
});
|
|
||||||
const accessibleSet = new Set(accessibleIds);
|
|
||||||
|
|
||||||
const includedIds = new Set<string>();
|
|
||||||
|
|
||||||
let changed = true;
|
|
||||||
while (changed) {
|
|
||||||
changed = false;
|
|
||||||
for (const page of pages) {
|
|
||||||
if (includedIds.has(page.id)) continue;
|
|
||||||
if (!accessibleSet.has(page.id)) continue;
|
|
||||||
|
|
||||||
// Root page or top-level page in space export
|
|
||||||
if (
|
|
||||||
page.id === rootPageId ||
|
|
||||||
(rootPageId === null && page.parentPageId === null)
|
|
||||||
) {
|
|
||||||
includedIds.add(page.id);
|
|
||||||
changed = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-root: include if parent is already included
|
|
||||||
if (page.parentPageId && includedIds.has(page.parentPageId)) {
|
|
||||||
includedIds.add(page.id);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages.filter((p) => includedIds.has(p.id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { MentionNode } from '../../../common/helpers/prosemirror/utils';
|
import { MentionNode } from "../../../common/helpers/prosemirror/utils";
|
||||||
|
|
||||||
|
|
||||||
export interface IPageBacklinkJob {
|
export interface IPageBacklinkJob {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
|
||||||
import { WsService } from './ws.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class WsTreeService {
|
|
||||||
constructor(private readonly wsService: WsService) {}
|
|
||||||
|
|
||||||
async notifyPageRestricted(page: Page, excludeUserId: string): Promise<void> {
|
|
||||||
await this.wsService.emitToSpaceExceptUsers(page.spaceId, [excludeUserId], {
|
|
||||||
operation: 'deleteTreeNode',
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
payload: {
|
|
||||||
node: {
|
|
||||||
id: page.id,
|
|
||||||
slugId: page.slugId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async notifyPermissionGranted(page: Page, userIds: string[]): Promise<void> {
|
|
||||||
if (userIds.length === 0) return;
|
|
||||||
|
|
||||||
await this.wsService.emitToUsers(userIds, {
|
|
||||||
operation: 'addTreeNode',
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
payload: {
|
|
||||||
parentId: page.parentPageId ?? null,
|
|
||||||
index: 0,
|
|
||||||
data: {
|
|
||||||
id: page.id,
|
|
||||||
slugId: page.slugId,
|
|
||||||
name: page.title ?? '',
|
|
||||||
title: page.title,
|
|
||||||
icon: page.icon,
|
|
||||||
position: page.position,
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
parentPageId: page.parentPageId,
|
|
||||||
creatorId: page.creatorId,
|
|
||||||
hasChildren: false,
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
MessageBody,
|
MessageBody,
|
||||||
OnGatewayConnection,
|
OnGatewayConnection,
|
||||||
OnGatewayInit,
|
|
||||||
SubscribeMessage,
|
SubscribeMessage,
|
||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
@@ -11,30 +10,20 @@ import { TokenService } from '../core/auth/services/token.service';
|
|||||||
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
|
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
|
||||||
import { OnModuleDestroy } from '@nestjs/common';
|
import { OnModuleDestroy } from '@nestjs/common';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { WsService } from './ws.service';
|
|
||||||
import { getSpaceRoomName, getUserRoomName } from './ws.utils';
|
|
||||||
import * as cookie from 'cookie';
|
import * as cookie from 'cookie';
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: { origin: '*' },
|
cors: { origin: '*' },
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
})
|
})
|
||||||
export class WsGateway
|
export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
||||||
implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy
|
|
||||||
{
|
|
||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
server: Server;
|
server: Server;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private tokenService: TokenService,
|
private tokenService: TokenService,
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
private wsService: WsService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit(server: Server): void {
|
|
||||||
this.wsService.setServer(server);
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
|
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cookies = cookie.parse(client.handshake.headers.cookie);
|
const cookies = cookie.parse(client.handshake.headers.cookie);
|
||||||
@@ -46,13 +35,11 @@ export class WsGateway
|
|||||||
const userId = token.sub;
|
const userId = token.sub;
|
||||||
const workspaceId = token.workspaceId;
|
const workspaceId = token.workspaceId;
|
||||||
|
|
||||||
client.data.userId = userId;
|
|
||||||
|
|
||||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||||
|
|
||||||
const userRoom = getUserRoomName(userId);
|
const userRoom = `user-${userId}`;
|
||||||
const workspaceRoom = `workspace-${workspaceId}`;
|
const workspaceRoom = `workspace-${workspaceId}`;
|
||||||
const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
|
const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
|
||||||
|
|
||||||
client.join([userRoom, workspaceRoom, ...spaceRooms]);
|
client.join([userRoom, workspaceRoom, ...spaceRooms]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -62,9 +49,17 @@ export class WsGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('message')
|
@SubscribeMessage('message')
|
||||||
async handleMessage(client: Socket, data: any): Promise<void> {
|
handleMessage(client: Socket, data: any): void {
|
||||||
if (this.wsService.isTreeEvent(data)) {
|
const spaceEvents = [
|
||||||
await this.wsService.handleTreeEvent(client, data);
|
'updateOne',
|
||||||
|
'addTreeNode',
|
||||||
|
'moveTreeNode',
|
||||||
|
'deleteTreeNode',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (spaceEvents.includes(data?.operation) && data?.spaceId) {
|
||||||
|
const room = this.getSpaceRoomName(data.spaceId);
|
||||||
|
client.broadcast.to(room).emit('message', data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,4 +82,8 @@ export class WsGateway
|
|||||||
this.server.close();
|
this.server.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSpaceRoomName(spaceId: string): string {
|
||||||
|
return `space-${spaceId}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { WsGateway } from './ws.gateway';
|
import { WsGateway } from './ws.gateway';
|
||||||
import { WsService } from './ws.service';
|
|
||||||
import { WsTreeService } from './ws-tree.service';
|
|
||||||
import { TokenModule } from '../core/auth/token.module';
|
import { TokenModule } from '../core/auth/token.module';
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TokenModule],
|
imports: [TokenModule],
|
||||||
providers: [WsGateway, WsService, WsTreeService],
|
providers: [WsGateway],
|
||||||
exports: [WsGateway, WsService, WsTreeService],
|
exports: [WsGateway],
|
||||||
})
|
})
|
||||||
export class WsModule {}
|
export class WsModule {}
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
|
||||||
import { Cache } from 'cache-manager';
|
|
||||||
import { Server, Socket } from 'socket.io';
|
|
||||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
|
||||||
import {
|
|
||||||
TREE_EVENTS,
|
|
||||||
WS_SPACE_RESTRICTION_CACHE_PREFIX,
|
|
||||||
WS_CACHE_TTL_MS,
|
|
||||||
getSpaceRoomName,
|
|
||||||
getUserRoomName,
|
|
||||||
} from './ws.utils';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class WsService {
|
|
||||||
private server: Server;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
|
||||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
setServer(server: Server): void {
|
|
||||||
this.server = server;
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleTreeEvent(client: Socket, data: any): Promise<void> {
|
|
||||||
const room = getSpaceRoomName(data.spaceId);
|
|
||||||
|
|
||||||
const hasRestrictions = await this.spaceHasRestrictions(data.spaceId);
|
|
||||||
if (!hasRestrictions) {
|
|
||||||
client.broadcast.to(room).emit('message', data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageId = this.extractPageId(data);
|
|
||||||
if (!pageId) {
|
|
||||||
client.broadcast.to(room).emit('message', data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRestricted =
|
|
||||||
await this.pagePermissionRepo.hasRestrictedAncestor(pageId);
|
|
||||||
if (!isRestricted) {
|
|
||||||
client.broadcast.to(room).emit('message', data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.broadcastToAuthorizedUsers(client, room, pageId, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async invalidateSpaceRestrictionCache(spaceId: string): Promise<void> {
|
|
||||||
await this.cacheManager.del(
|
|
||||||
`${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async emitToUsers(userIds: string[], data: any): Promise<void> {
|
|
||||||
if (userIds.length === 0) return;
|
|
||||||
const rooms = userIds.map((id) => getUserRoomName(id));
|
|
||||||
this.server.to(rooms).emit('message', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async emitToSpaceExceptUsers(
|
|
||||||
spaceId: string,
|
|
||||||
excludeUserIds: string[],
|
|
||||||
data: any,
|
|
||||||
): Promise<void> {
|
|
||||||
const room = getSpaceRoomName(spaceId);
|
|
||||||
const sockets = await this.server.in(room).fetchSockets();
|
|
||||||
const excludeSet = new Set(excludeUserIds);
|
|
||||||
|
|
||||||
for (const socket of sockets) {
|
|
||||||
const userId = socket.data.userId as string;
|
|
||||||
if (userId && !excludeSet.has(userId)) {
|
|
||||||
socket.emit('message', data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isTreeEvent(data: any): boolean {
|
|
||||||
return TREE_EVENTS.has(data?.operation) && !!data?.spaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async broadcastToAuthorizedUsers(
|
|
||||||
sender: Socket,
|
|
||||||
room: string,
|
|
||||||
pageId: string,
|
|
||||||
data: any,
|
|
||||||
): Promise<void> {
|
|
||||||
const sockets = await this.server.in(room).fetchSockets();
|
|
||||||
|
|
||||||
const otherSockets = sockets.filter((s) => s.id !== sender.id);
|
|
||||||
if (otherSockets.length === 0) return;
|
|
||||||
|
|
||||||
const userSocketMap = new Map<string, typeof otherSockets>();
|
|
||||||
for (const socket of otherSockets) {
|
|
||||||
const userId = socket.data.userId as string;
|
|
||||||
if (!userId) continue;
|
|
||||||
const existing = userSocketMap.get(userId);
|
|
||||||
if (existing) {
|
|
||||||
existing.push(socket);
|
|
||||||
} else {
|
|
||||||
userSocketMap.set(userId, [socket]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidateUserIds = Array.from(userSocketMap.keys());
|
|
||||||
if (candidateUserIds.length === 0) return;
|
|
||||||
|
|
||||||
const authorizedUserIds =
|
|
||||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(
|
|
||||||
pageId,
|
|
||||||
candidateUserIds,
|
|
||||||
);
|
|
||||||
|
|
||||||
const authorizedSet = new Set(authorizedUserIds);
|
|
||||||
for (const [userId, userSockets] of userSocketMap) {
|
|
||||||
if (authorizedSet.has(userId)) {
|
|
||||||
for (const socket of userSockets) {
|
|
||||||
socket.emit('message', data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async spaceHasRestrictions(spaceId: string): Promise<boolean> {
|
|
||||||
const cacheKey = `${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`;
|
|
||||||
|
|
||||||
const cached = await this.cacheManager.get<boolean>(cacheKey);
|
|
||||||
if (cached !== undefined && cached !== null) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasRestrictions =
|
|
||||||
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
|
|
||||||
|
|
||||||
await this.cacheManager.set(cacheKey, hasRestrictions, WS_CACHE_TTL_MS);
|
|
||||||
|
|
||||||
return hasRestrictions;
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractPageId(data: any): string | null {
|
|
||||||
switch (data.operation) {
|
|
||||||
case 'addTreeNode':
|
|
||||||
return data.payload?.data?.id ?? null;
|
|
||||||
case 'moveTreeNode':
|
|
||||||
return data.payload?.id ?? null;
|
|
||||||
case 'deleteTreeNode':
|
|
||||||
return data.payload?.node?.id ?? null;
|
|
||||||
case 'updateOne':
|
|
||||||
return data.id ?? null;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export const WS_CACHE_TTL_MS = 30_000;
|
|
||||||
export const WS_SPACE_RESTRICTION_CACHE_PREFIX = 'ws:space-restrictions:';
|
|
||||||
|
|
||||||
export function getSpaceRoomName(spaceId: string): string {
|
|
||||||
return `space-${spaceId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUserRoomName(userId: string): string {
|
|
||||||
return `user-${userId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TREE_EVENTS = new Set([
|
|
||||||
'updateOne',
|
|
||||||
'addTreeNode',
|
|
||||||
'moveTreeNode',
|
|
||||||
'deleteTreeNode',
|
|
||||||
]);
|
|
||||||
@@ -5,10 +5,5 @@
|
|||||||
"testRegex": ".e2e-spec.ts$",
|
"testRegex": ".e2e-spec.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
|
||||||
"moduleNameMapper": {
|
|
||||||
"^@docmost/db/(.*)$": "<rootDir>/../src/database/$1",
|
|
||||||
"^@docmost/transactional/(.*)$": "<rootDir>/../src/integrations/transactional/$1",
|
|
||||||
"^@docmost/ee/(.*)$": "<rootDir>/../src/ee/$1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -96,7 +96,8 @@
|
|||||||
"packageManager": "pnpm@10.4.0",
|
"packageManager": "pnpm@10.4.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch"
|
"react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch",
|
||||||
|
"@tiptap/core": "patches/@tiptap__core.patch"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"jsdom": "25.0.1",
|
"jsdom": "25.0.1",
|
||||||
|
|||||||
@@ -25,3 +25,4 @@ export * from "./lib/heading/heading";
|
|||||||
export * from "./lib/unique-id";
|
export * from "./lib/unique-id";
|
||||||
export * from "./lib/shared-storage";
|
export * from "./lib/shared-storage";
|
||||||
export * from "./lib/recreate-transform";
|
export * from "./lib/recreate-transform";
|
||||||
|
export * from "./lib/columns";
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
export type CalloutType = "default" | "info" | "success" | "warning" | "danger";
|
export type CalloutType =
|
||||||
const validCalloutTypes = ["default", "info", "success", "warning", "danger"];
|
| 'default'
|
||||||
|
| 'info'
|
||||||
|
| 'note'
|
||||||
|
| 'success'
|
||||||
|
| 'warning'
|
||||||
|
| 'danger';
|
||||||
|
const validCalloutTypes = [
|
||||||
|
'default',
|
||||||
|
'info',
|
||||||
|
'note',
|
||||||
|
'success',
|
||||||
|
'warning',
|
||||||
|
'danger',
|
||||||
|
];
|
||||||
|
|
||||||
export function getValidCalloutType(value: string): string {
|
export function getValidCalloutType(value: string): string {
|
||||||
if (value) {
|
if (value) {
|
||||||
return validCalloutTypes.includes(value) ? value : "info";
|
return validCalloutTypes.includes(value) ? value : 'info';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
|
||||||
|
import { TextSelection } from "prosemirror-state";
|
||||||
|
|
||||||
|
export interface ColumnOptions {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnAttributes {
|
||||||
|
width?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
column: {
|
||||||
|
setColumnWidth: (width: number | null) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Column = Node.create<ColumnOptions>({
|
||||||
|
name: "column",
|
||||||
|
group: "block",
|
||||||
|
content: "block+",
|
||||||
|
defining: true,
|
||||||
|
isolating: true,
|
||||||
|
selectable: false,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
width: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const value = element.getAttribute("data-width");
|
||||||
|
return value ? parseFloat(value) : null;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes: ColumnAttributes) => {
|
||||||
|
if (!attributes.width) return {};
|
||||||
|
return {
|
||||||
|
"data-width": attributes.width,
|
||||||
|
style: `flex: ${attributes.width}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `div[data-type="${this.name}"]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"div",
|
||||||
|
mergeAttributes(
|
||||||
|
{ "data-type": this.name },
|
||||||
|
this.options.HTMLAttributes,
|
||||||
|
HTMLAttributes,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
const jumpToColumn = (direction: 1 | -1) => () => {
|
||||||
|
const { state, dispatch } = this.editor.view;
|
||||||
|
|
||||||
|
const columns = findParentNode(
|
||||||
|
(node) => node.type.name === "columns",
|
||||||
|
)(state.selection);
|
||||||
|
if (!columns) return false;
|
||||||
|
|
||||||
|
const column = findParentNode(
|
||||||
|
(node) => node.type.name === "column",
|
||||||
|
)(state.selection);
|
||||||
|
if (!column) return false;
|
||||||
|
|
||||||
|
let currentIndex = -1;
|
||||||
|
columns.node.forEach((_child, offset, index) => {
|
||||||
|
if (columns.pos + 1 + offset === column.pos) {
|
||||||
|
currentIndex = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetIndex = currentIndex + direction;
|
||||||
|
if (targetIndex < 0 || targetIndex >= columns.node.childCount) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
for (let j = 0; j < targetIndex; j++) {
|
||||||
|
offset += columns.node.child(j).nodeSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPos = columns.pos + 1 + offset + 1 + 1;
|
||||||
|
if (dispatch) {
|
||||||
|
dispatch(
|
||||||
|
state.tr.setSelection(TextSelection.create(state.doc, targetPos)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
Tab: jumpToColumn(1),
|
||||||
|
"Shift-Tab": jumpToColumn(-1),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setColumnWidth:
|
||||||
|
(width) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("column", { width }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
|
||||||
|
import { Fragment, Node as PMNode } from "prosemirror-model";
|
||||||
|
import { TextSelection } from "prosemirror-state";
|
||||||
|
|
||||||
|
export type ColumnsLayout =
|
||||||
|
| "two_equal"
|
||||||
|
| "two_left_sidebar"
|
||||||
|
| "two_right_sidebar"
|
||||||
|
| "three_equal"
|
||||||
|
| "three_left_wide"
|
||||||
|
| "three_right_wide"
|
||||||
|
| "three_with_sidebars"
|
||||||
|
| "four_equal"
|
||||||
|
| "five_equal";
|
||||||
|
|
||||||
|
export interface ColumnsOptions {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WidthMode = "normal" | "wide";
|
||||||
|
|
||||||
|
export interface ColumnsAttributes {
|
||||||
|
layout?: ColumnsLayout;
|
||||||
|
widthMode?: WidthMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
columns: {
|
||||||
|
insertColumns: (attributes?: ColumnsAttributes) => ReturnType;
|
||||||
|
setColumnsWidthMode: (widthMode: WidthMode) => ReturnType;
|
||||||
|
setColumnCount: (count: number) => ReturnType;
|
||||||
|
setColumnsLayout: (layout: ColumnsLayout) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnCountFromLayout(layout: string): number {
|
||||||
|
if (layout.startsWith("five")) return 5;
|
||||||
|
if (layout.startsWith("four")) return 4;
|
||||||
|
if (layout.startsWith("three")) return 3;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultLayoutForCount(count: number): ColumnsLayout {
|
||||||
|
if (count === 3) return "three_equal";
|
||||||
|
if (count === 4) return "four_equal";
|
||||||
|
if (count === 5) return "five_equal";
|
||||||
|
return "two_equal";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Columns = Node.create<ColumnsOptions>({
|
||||||
|
name: "columns",
|
||||||
|
group: "block",
|
||||||
|
content: "column+",
|
||||||
|
defining: true,
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
layout: {
|
||||||
|
default: "two_equal",
|
||||||
|
parseHTML: (element) => element.getAttribute("data-layout"),
|
||||||
|
renderHTML: (attributes: ColumnsAttributes) => ({
|
||||||
|
"data-layout": attributes.layout,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
widthMode: {
|
||||||
|
default: "normal",
|
||||||
|
parseHTML: (element) =>
|
||||||
|
element.getAttribute("data-width-mode") || "normal",
|
||||||
|
renderHTML: (attributes: ColumnsAttributes) => {
|
||||||
|
if (!attributes.widthMode || attributes.widthMode === "normal")
|
||||||
|
return {};
|
||||||
|
return { "data-width-mode": attributes.widthMode };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `div[data-type="${this.name}"]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"div",
|
||||||
|
mergeAttributes(
|
||||||
|
{ "data-type": this.name },
|
||||||
|
this.options.HTMLAttributes,
|
||||||
|
HTMLAttributes,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertColumns:
|
||||||
|
(attributes) =>
|
||||||
|
({ tr, state, dispatch }) => {
|
||||||
|
const layout = attributes?.layout || "two_equal";
|
||||||
|
const count = columnCountFromLayout(layout);
|
||||||
|
|
||||||
|
const columnType = state.schema.nodes.column;
|
||||||
|
const paraType = state.schema.nodes.paragraph;
|
||||||
|
const children = Array.from({ length: count }, () =>
|
||||||
|
columnType.create(null, paraType.create()),
|
||||||
|
);
|
||||||
|
const columnsNode = this.type.create(
|
||||||
|
attributes,
|
||||||
|
Fragment.from(children),
|
||||||
|
);
|
||||||
|
|
||||||
|
const stepsBefore = tr.steps.length;
|
||||||
|
tr.replaceSelectionWith(columnsNode);
|
||||||
|
|
||||||
|
if (tr.steps.length > stepsBefore) {
|
||||||
|
const stepMap = tr.steps[tr.steps.length - 1].getMap();
|
||||||
|
let insertStart = 0;
|
||||||
|
stepMap.forEach((_from, _to, newFrom) => {
|
||||||
|
insertStart = newFrom;
|
||||||
|
});
|
||||||
|
tr.setSelection(
|
||||||
|
TextSelection.near(tr.doc.resolve(insertStart + 1), 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatch) dispatch(tr);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
setColumnsWidthMode:
|
||||||
|
(widthMode) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("columns", { widthMode }),
|
||||||
|
|
||||||
|
setColumnCount:
|
||||||
|
(count: number) =>
|
||||||
|
({ tr, state }) => {
|
||||||
|
const predicate = (node: PMNode) => node.type.name === "columns";
|
||||||
|
const parent = findParentNode(predicate)(state.selection);
|
||||||
|
if (!parent) return false;
|
||||||
|
|
||||||
|
const { node: columnsNode, pos: parentPos } = parent;
|
||||||
|
const currentCount = columnsNode.childCount;
|
||||||
|
if (count === currentCount || count < 2 || count > 5) return false;
|
||||||
|
|
||||||
|
const columnType = state.schema.nodes.column;
|
||||||
|
const paraType = state.schema.nodes.paragraph;
|
||||||
|
const newChildren: PMNode[] = [];
|
||||||
|
|
||||||
|
if (count > currentCount) {
|
||||||
|
for (let i = 0; i < currentCount; i++) {
|
||||||
|
newChildren.push(columnsNode.child(i));
|
||||||
|
}
|
||||||
|
for (let i = currentCount; i < count; i++) {
|
||||||
|
newChildren.push(columnType.create(null, paraType.create()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < count - 1; i++) {
|
||||||
|
newChildren.push(columnsNode.child(i));
|
||||||
|
}
|
||||||
|
let mergedContent = columnsNode.child(count - 1).content;
|
||||||
|
for (let j = count; j < currentCount; j++) {
|
||||||
|
mergedContent = mergedContent.append(columnsNode.child(j).content);
|
||||||
|
}
|
||||||
|
newChildren.push(columnType.create(null, mergedContent));
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLayout = defaultLayoutForCount(count);
|
||||||
|
const newNode = columnsNode.type.create(
|
||||||
|
{ ...columnsNode.attrs, layout: newLayout },
|
||||||
|
Fragment.from(newChildren),
|
||||||
|
);
|
||||||
|
tr.replaceWith(parentPos, parentPos + columnsNode.nodeSize, newNode);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
setColumnsLayout:
|
||||||
|
(layout) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("columns", { layout }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { Columns } from "./columns";
|
||||||
|
export type { ColumnsOptions, ColumnsAttributes, ColumnsLayout, WidthMode } from "./columns";
|
||||||
|
export { Column } from "./column";
|
||||||
|
export type { ColumnOptions, ColumnAttributes } from "./column";
|
||||||
@@ -1,15 +1,35 @@
|
|||||||
import { Node, mergeAttributes } from "@tiptap/core";
|
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
||||||
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
|
||||||
|
export type DrawioResizeOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
directions?: ResizableNodeViewDirection[];
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
alwaysPreserveAspectRatio?: boolean;
|
||||||
|
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
||||||
|
className?: {
|
||||||
|
container?: string;
|
||||||
|
wrapper?: string;
|
||||||
|
handle?: string;
|
||||||
|
resizing?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface DrawioOptions {
|
export interface DrawioOptions {
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
view: any;
|
view: any;
|
||||||
|
resize: DrawioResizeOptions | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DrawioAttributes {
|
export interface DrawioAttributes {
|
||||||
src?: string;
|
src?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
width?: string;
|
width?: number | string;
|
||||||
|
height?: number;
|
||||||
|
aspectRatio?: number;
|
||||||
align?: string;
|
align?: string;
|
||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
}
|
}
|
||||||
@@ -18,6 +38,8 @@ declare module "@tiptap/core" {
|
|||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
drawio: {
|
drawio: {
|
||||||
setDrawio: (attributes?: DrawioAttributes) => ReturnType;
|
setDrawio: (attributes?: DrawioAttributes) => ReturnType;
|
||||||
|
setDrawioAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||||
|
setDrawioSize: (width: number, height: number) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,6 +57,7 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
return {
|
return {
|
||||||
HTMLAttributes: {},
|
HTMLAttributes: {},
|
||||||
view: null,
|
view: null,
|
||||||
|
resize: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -55,12 +78,30 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: "100%",
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-width"),
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("data-width");
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.endsWith("%")) return raw;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
renderHTML: (attributes: DrawioAttributes) => ({
|
renderHTML: (attributes: DrawioAttributes) => ({
|
||||||
"data-width": attributes.width,
|
"data-width": attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
height: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("data-height");
|
||||||
|
if (!raw) return null;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes: DrawioAttributes) => ({
|
||||||
|
"data-height": attributes.height,
|
||||||
|
}),
|
||||||
|
},
|
||||||
size: {
|
size: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-size"),
|
parseHTML: (element) => element.getAttribute("data-size"),
|
||||||
@@ -68,6 +109,13 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
"data-size": attributes.size,
|
"data-size": attributes.size,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
aspectRatio: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
|
||||||
|
renderHTML: (attributes: DrawioAttributes) => ({
|
||||||
|
"data-aspect-ratio": attributes.aspectRatio,
|
||||||
|
}),
|
||||||
|
},
|
||||||
align: {
|
align: {
|
||||||
default: "center",
|
default: "center",
|
||||||
parseHTML: (element) => element.getAttribute("data-align"),
|
parseHTML: (element) => element.getAttribute("data-align"),
|
||||||
@@ -99,7 +147,7 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
mergeAttributes(
|
mergeAttributes(
|
||||||
{ "data-type": this.name },
|
{ "data-type": this.name },
|
||||||
this.options.HTMLAttributes,
|
this.options.HTMLAttributes,
|
||||||
HTMLAttributes
|
HTMLAttributes,
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
"img",
|
"img",
|
||||||
@@ -122,13 +170,172 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
attrs: attrs,
|
attrs: attrs,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setDrawioAlign:
|
||||||
|
(align) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("drawio", { align }),
|
||||||
|
|
||||||
|
setDrawioSize:
|
||||||
|
(width, height) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("drawio", { width, height }),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
const resize = this.options.resize;
|
||||||
this.editor.isInitialized = true;
|
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
if (!resize || !resize.enabled) {
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
directions,
|
||||||
|
minWidth,
|
||||||
|
minHeight,
|
||||||
|
alwaysPreserveAspectRatio,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
} = resize;
|
||||||
|
|
||||||
|
return (props) => {
|
||||||
|
const { node, getPos, HTMLAttributes, editor } = props;
|
||||||
|
|
||||||
|
if (!node.attrs.src) {
|
||||||
|
editor.isInitialized = true;
|
||||||
|
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||||
|
const view = reactView(props);
|
||||||
|
|
||||||
|
const originalUpdate = view.update?.bind(view);
|
||||||
|
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||||
|
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (originalUpdate) {
|
||||||
|
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.createElement("img");
|
||||||
|
el.src = node.attrs.src;
|
||||||
|
el.alt = node.attrs.title || "";
|
||||||
|
el.style.display = "block";
|
||||||
|
el.style.maxWidth = "100%";
|
||||||
|
el.style.borderRadius = "8px";
|
||||||
|
|
||||||
|
let currentNode = node;
|
||||||
|
|
||||||
|
const nodeView = new ResizableNodeView({
|
||||||
|
element: el,
|
||||||
|
editor,
|
||||||
|
node,
|
||||||
|
getPos,
|
||||||
|
onResize: (w, h) => {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
},
|
||||||
|
onCommit: () => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos === undefined) return;
|
||||||
|
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.setNodeSelection(pos)
|
||||||
|
.updateAttributes(this.name, {
|
||||||
|
width: Math.round(el.offsetWidth),
|
||||||
|
height: Math.round(el.offsetHeight),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
|
||||||
|
if (updatedNode.type !== currentNode.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedNode.attrs.src !== currentNode.attrs.src) {
|
||||||
|
el.src = updatedNode.attrs.src || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = updatedNode.attrs.width;
|
||||||
|
const h = updatedNode.attrs.height;
|
||||||
|
if (w != null) {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
}
|
||||||
|
if (h != null) {
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const align = updatedNode.attrs.align || "center";
|
||||||
|
const container = nodeView.dom as HTMLElement;
|
||||||
|
applyAlignment(container, align);
|
||||||
|
|
||||||
|
currentNode = updatedNode;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
directions,
|
||||||
|
min: {
|
||||||
|
width: minWidth,
|
||||||
|
height: minHeight,
|
||||||
|
},
|
||||||
|
preserveAspectRatio: alwaysPreserveAspectRatio === true,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dom = nodeView.dom as HTMLElement;
|
||||||
|
|
||||||
|
applyAlignment(dom, node.attrs.align || "center");
|
||||||
|
|
||||||
|
// Handle percentage width backward compat
|
||||||
|
const widthAttr = node.attrs.width;
|
||||||
|
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const parentEl = dom.parentElement;
|
||||||
|
if (parentEl) {
|
||||||
|
const containerWidth = parentEl.clientWidth;
|
||||||
|
const pctValue = parseInt(widthAttr, 10);
|
||||||
|
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||||
|
const pxWidth = Math.round(
|
||||||
|
containerWidth * (pctValue / 100),
|
||||||
|
);
|
||||||
|
el.style.width = `${pxWidth}px`;
|
||||||
|
if (node.attrs.aspectRatio) {
|
||||||
|
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide until image loads
|
||||||
|
dom.style.visibility = "hidden";
|
||||||
|
dom.style.pointerEvents = "none";
|
||||||
|
el.onload = () => {
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return nodeView;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyAlignment(container: HTMLElement, align: string) {
|
||||||
|
if (align === "left") {
|
||||||
|
container.style.justifyContent = "flex-start";
|
||||||
|
} else if (align === "right") {
|
||||||
|
container.style.justifyContent = "flex-end";
|
||||||
|
} else {
|
||||||
|
container.style.justifyContent = "center";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
import { Node, mergeAttributes } from "@tiptap/core";
|
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
||||||
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
|
||||||
|
export type ExcalidrawResizeOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
directions?: ResizableNodeViewDirection[];
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
alwaysPreserveAspectRatio?: boolean;
|
||||||
|
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
||||||
|
className?: {
|
||||||
|
container?: string;
|
||||||
|
wrapper?: string;
|
||||||
|
handle?: string;
|
||||||
|
resizing?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface ExcalidrawOptions {
|
export interface ExcalidrawOptions {
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
view: any;
|
view: any;
|
||||||
|
resize: ExcalidrawResizeOptions | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExcalidrawAttributes {
|
export interface ExcalidrawAttributes {
|
||||||
src?: string;
|
src?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
width?: string;
|
width?: number | string;
|
||||||
|
height?: number;
|
||||||
|
aspectRatio?: number;
|
||||||
align?: string;
|
align?: string;
|
||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
}
|
}
|
||||||
@@ -18,6 +38,8 @@ declare module "@tiptap/core" {
|
|||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
excalidraw: {
|
excalidraw: {
|
||||||
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
|
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
|
||||||
|
setExcalidrawAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||||
|
setExcalidrawSize: (width: number, height: number) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,8 +57,10 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
return {
|
return {
|
||||||
HTMLAttributes: {},
|
HTMLAttributes: {},
|
||||||
view: null,
|
view: null,
|
||||||
|
resize: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
src: {
|
src: {
|
||||||
@@ -54,12 +78,30 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: "100%",
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-width"),
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("data-width");
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.endsWith("%")) return raw;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||||
"data-width": attributes.width,
|
"data-width": attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
height: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("data-height");
|
||||||
|
if (!raw) return null;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||||
|
"data-height": attributes.height,
|
||||||
|
}),
|
||||||
|
},
|
||||||
size: {
|
size: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-size"),
|
parseHTML: (element) => element.getAttribute("data-size"),
|
||||||
@@ -67,6 +109,13 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
"data-size": attributes.size,
|
"data-size": attributes.size,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
aspectRatio: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
|
||||||
|
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||||
|
"data-aspect-ratio": attributes.aspectRatio,
|
||||||
|
}),
|
||||||
|
},
|
||||||
align: {
|
align: {
|
||||||
default: "center",
|
default: "center",
|
||||||
parseHTML: (element) => element.getAttribute("data-align"),
|
parseHTML: (element) => element.getAttribute("data-align"),
|
||||||
@@ -98,7 +147,7 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
mergeAttributes(
|
mergeAttributes(
|
||||||
{ "data-type": this.name },
|
{ "data-type": this.name },
|
||||||
this.options.HTMLAttributes,
|
this.options.HTMLAttributes,
|
||||||
HTMLAttributes
|
HTMLAttributes,
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
"img",
|
"img",
|
||||||
@@ -121,13 +170,172 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
attrs: attrs,
|
attrs: attrs,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setExcalidrawAlign:
|
||||||
|
(align) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("excalidraw", { align }),
|
||||||
|
|
||||||
|
setExcalidrawSize:
|
||||||
|
(width, height) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("excalidraw", { width, height }),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
const resize = this.options.resize;
|
||||||
this.editor.isInitialized = true;
|
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
if (!resize || !resize.enabled) {
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
directions,
|
||||||
|
minWidth,
|
||||||
|
minHeight,
|
||||||
|
alwaysPreserveAspectRatio,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
} = resize;
|
||||||
|
|
||||||
|
return (props) => {
|
||||||
|
const { node, getPos, HTMLAttributes, editor } = props;
|
||||||
|
|
||||||
|
if (!node.attrs.src) {
|
||||||
|
editor.isInitialized = true;
|
||||||
|
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||||
|
const view = reactView(props);
|
||||||
|
|
||||||
|
const originalUpdate = view.update?.bind(view);
|
||||||
|
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||||
|
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (originalUpdate) {
|
||||||
|
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.createElement("img");
|
||||||
|
el.src = node.attrs.src;
|
||||||
|
el.alt = node.attrs.title || "";
|
||||||
|
el.style.display = "block";
|
||||||
|
el.style.maxWidth = "100%";
|
||||||
|
el.style.borderRadius = "8px";
|
||||||
|
|
||||||
|
let currentNode = node;
|
||||||
|
|
||||||
|
const nodeView = new ResizableNodeView({
|
||||||
|
element: el,
|
||||||
|
editor,
|
||||||
|
node,
|
||||||
|
getPos,
|
||||||
|
onResize: (w, h) => {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
},
|
||||||
|
onCommit: () => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos === undefined) return;
|
||||||
|
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.setNodeSelection(pos)
|
||||||
|
.updateAttributes(this.name, {
|
||||||
|
width: Math.round(el.offsetWidth),
|
||||||
|
height: Math.round(el.offsetHeight),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
|
||||||
|
if (updatedNode.type !== currentNode.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedNode.attrs.src !== currentNode.attrs.src) {
|
||||||
|
el.src = updatedNode.attrs.src || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = updatedNode.attrs.width;
|
||||||
|
const h = updatedNode.attrs.height;
|
||||||
|
if (w != null) {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
}
|
||||||
|
if (h != null) {
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const align = updatedNode.attrs.align || "center";
|
||||||
|
const container = nodeView.dom as HTMLElement;
|
||||||
|
applyAlignment(container, align);
|
||||||
|
|
||||||
|
currentNode = updatedNode;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
directions,
|
||||||
|
min: {
|
||||||
|
width: minWidth,
|
||||||
|
height: minHeight,
|
||||||
|
},
|
||||||
|
preserveAspectRatio: alwaysPreserveAspectRatio === true,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dom = nodeView.dom as HTMLElement;
|
||||||
|
|
||||||
|
applyAlignment(dom, node.attrs.align || "center");
|
||||||
|
|
||||||
|
// Handle percentage width backward compat
|
||||||
|
const widthAttr = node.attrs.width;
|
||||||
|
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const parentEl = dom.parentElement;
|
||||||
|
if (parentEl) {
|
||||||
|
const containerWidth = parentEl.clientWidth;
|
||||||
|
const pctValue = parseInt(widthAttr, 10);
|
||||||
|
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||||
|
const pxWidth = Math.round(
|
||||||
|
containerWidth * (pctValue / 100),
|
||||||
|
);
|
||||||
|
el.style.width = `${pxWidth}px`;
|
||||||
|
if (node.attrs.aspectRatio) {
|
||||||
|
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide until image loads
|
||||||
|
dom.style.visibility = "hidden";
|
||||||
|
dom.style.pointerEvents = "none";
|
||||||
|
el.onload = () => {
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return nodeView;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyAlignment(container: HTMLElement, align: string) {
|
||||||
|
if (align === "left") {
|
||||||
|
container.style.justifyContent = "flex-start";
|
||||||
|
} else if (align === "right") {
|
||||||
|
container.style.justifyContent = "flex-end";
|
||||||
|
} else {
|
||||||
|
container.style.justifyContent = "center";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
import Image from "@tiptap/extension-image";
|
import Image from "@tiptap/extension-image";
|
||||||
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
|
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
import { mergeAttributes, Range } from "@tiptap/core";
|
import {
|
||||||
|
mergeAttributes,
|
||||||
|
Range,
|
||||||
|
ResizableNodeView,
|
||||||
|
} from "@tiptap/core";
|
||||||
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
|
|
||||||
|
export type ImageResizeOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
directions?: ResizableNodeViewDirection[];
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
alwaysPreserveAspectRatio?: boolean;
|
||||||
|
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
||||||
|
className?: {
|
||||||
|
container?: string;
|
||||||
|
wrapper?: string;
|
||||||
|
handle?: string;
|
||||||
|
resizing?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface ImageOptions extends DefaultImageOptions {
|
export interface ImageOptions extends DefaultImageOptions {
|
||||||
view: any;
|
view: any;
|
||||||
|
resize: ImageResizeOptions | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageAttributes {
|
export interface ImageAttributes {
|
||||||
src?: string;
|
src?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
align?: string;
|
align?: string;
|
||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
width?: number;
|
width?: number | string;
|
||||||
|
height?: number;
|
||||||
aspectRatio?: number;
|
aspectRatio?: number;
|
||||||
placeholder?: {
|
placeholder?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,10 +48,11 @@ declare module "@tiptap/core" {
|
|||||||
imageBlock: {
|
imageBlock: {
|
||||||
setImage: (attributes: ImageAttributes) => ReturnType;
|
setImage: (attributes: ImageAttributes) => ReturnType;
|
||||||
setImageAt: (
|
setImageAt: (
|
||||||
attributes: ImageAttributes & { pos: number | Range }
|
attributes: ImageAttributes & { pos: number | Range },
|
||||||
) => ReturnType;
|
) => ReturnType;
|
||||||
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
|
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||||
setImageWidth: (width: number) => ReturnType;
|
setImageWidth: (width: number) => ReturnType;
|
||||||
|
setImageSize: (width: number, height: number) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,6 +70,7 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
|||||||
return {
|
return {
|
||||||
...this.parent?.(),
|
...this.parent?.(),
|
||||||
view: null,
|
view: null,
|
||||||
|
resize: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -59,12 +84,30 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: "100%",
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("width"),
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("width");
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.endsWith("%")) return raw;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
renderHTML: (attributes: ImageAttributes) => ({
|
renderHTML: (attributes: ImageAttributes) => ({
|
||||||
width: attributes.width,
|
width: attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
height: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("height");
|
||||||
|
if (!raw) return null;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes: ImageAttributes) => ({
|
||||||
|
height: attributes.height,
|
||||||
|
}),
|
||||||
|
},
|
||||||
align: {
|
align: {
|
||||||
default: "center",
|
default: "center",
|
||||||
parseHTML: (element) => element.getAttribute("data-align"),
|
parseHTML: (element) => element.getAttribute("data-align"),
|
||||||
@@ -142,16 +185,192 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
|||||||
setImageWidth:
|
setImageWidth:
|
||||||
(width) =>
|
(width) =>
|
||||||
({ commands }) =>
|
({ commands }) =>
|
||||||
commands.updateAttributes("image", {
|
commands.updateAttributes("image", { width }),
|
||||||
width: `${Math.max(0, Math.min(100, width))}%`,
|
|
||||||
}),
|
setImageSize:
|
||||||
|
(width, height) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("image", { width, height }),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
const resize = this.options.resize;
|
||||||
this.editor.isInitialized = true;
|
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
if (!resize || !resize.enabled) {
|
||||||
|
// Fallback to React node view (existing behavior)
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
directions,
|
||||||
|
minWidth,
|
||||||
|
minHeight,
|
||||||
|
alwaysPreserveAspectRatio,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
} = resize;
|
||||||
|
|
||||||
|
return (props) => {
|
||||||
|
const { node, getPos, HTMLAttributes, editor } = props;
|
||||||
|
|
||||||
|
// If no src yet (placeholder/uploading), use React view for loading UI
|
||||||
|
if (!HTMLAttributes.src) {
|
||||||
|
editor.isInitialized = true;
|
||||||
|
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||||
|
const view = reactView(props);
|
||||||
|
|
||||||
|
// When the node gets a src, return false from update to force rebuild
|
||||||
|
const originalUpdate = view.update?.bind(view);
|
||||||
|
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||||
|
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (originalUpdate) {
|
||||||
|
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has src — use ResizableNodeView
|
||||||
|
const el = document.createElement("img");
|
||||||
|
|
||||||
|
Object.entries(HTMLAttributes).forEach(([key, value]) => {
|
||||||
|
if (value != null) {
|
||||||
|
switch (key) {
|
||||||
|
case "width":
|
||||||
|
case "height":
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
el.setAttribute(key, String(value));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
el.src = HTMLAttributes.src;
|
||||||
|
el.style.display = "block";
|
||||||
|
el.style.maxWidth = "100%";
|
||||||
|
el.style.borderRadius = "8px";
|
||||||
|
|
||||||
|
let currentNode = node;
|
||||||
|
|
||||||
|
const nodeView = new ResizableNodeView({
|
||||||
|
element: el,
|
||||||
|
editor,
|
||||||
|
node,
|
||||||
|
getPos,
|
||||||
|
onResize: (w, h) => {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
},
|
||||||
|
onCommit: () => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos === undefined) return;
|
||||||
|
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.setNodeSelection(pos)
|
||||||
|
.updateAttributes(this.name, {
|
||||||
|
width: Math.round(el.offsetWidth),
|
||||||
|
height: Math.round(el.offsetHeight),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
|
||||||
|
if (updatedNode.type !== currentNode.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedNode.attrs.src !== currentNode.attrs.src) {
|
||||||
|
el.src = updatedNode.attrs.src || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedNode.attrs.alt !== currentNode.attrs.alt) {
|
||||||
|
el.alt = updatedNode.attrs.alt || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = updatedNode.attrs.width;
|
||||||
|
const h = updatedNode.attrs.height;
|
||||||
|
if (w != null) {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
}
|
||||||
|
if (h != null) {
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update alignment on container
|
||||||
|
const align = updatedNode.attrs.align || "center";
|
||||||
|
const container = nodeView.dom as HTMLElement;
|
||||||
|
applyAlignment(container, align);
|
||||||
|
|
||||||
|
currentNode = updatedNode;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
directions,
|
||||||
|
min: {
|
||||||
|
width: minWidth,
|
||||||
|
height: minHeight,
|
||||||
|
},
|
||||||
|
preserveAspectRatio: alwaysPreserveAspectRatio === true,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dom = nodeView.dom as HTMLElement;
|
||||||
|
|
||||||
|
// Apply initial alignment
|
||||||
|
applyAlignment(dom, node.attrs.align || "center");
|
||||||
|
|
||||||
|
// Handle percentage width backward compat
|
||||||
|
const widthAttr = node.attrs.width;
|
||||||
|
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||||
|
// Defer conversion until we can measure the container
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const parentEl = dom.parentElement;
|
||||||
|
if (parentEl) {
|
||||||
|
const containerWidth = parentEl.clientWidth;
|
||||||
|
const pctValue = parseInt(widthAttr, 10);
|
||||||
|
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||||
|
const pxWidth = Math.round(
|
||||||
|
containerWidth * (pctValue / 100),
|
||||||
|
);
|
||||||
|
el.style.width = `${pxWidth}px`;
|
||||||
|
if (node.attrs.aspectRatio) {
|
||||||
|
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide until image loads (official TipTap pattern)
|
||||||
|
dom.style.visibility = "hidden";
|
||||||
|
dom.style.pointerEvents = "none";
|
||||||
|
el.onload = () => {
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return nodeView;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyAlignment(container: HTMLElement, align: string) {
|
||||||
|
if (align === "left") {
|
||||||
|
container.style.justifyContent = "flex-start";
|
||||||
|
} else if (align === "right") {
|
||||||
|
container.style.justifyContent = "flex-end";
|
||||||
|
} else {
|
||||||
|
container.style.justifyContent = "center";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,35 @@
|
|||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
import { Range, Node } from "@tiptap/core";
|
import { Range, Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
||||||
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
|
|
||||||
|
export type VideoResizeOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
directions?: ResizableNodeViewDirection[];
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
alwaysPreserveAspectRatio?: boolean;
|
||||||
|
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
||||||
|
className?: {
|
||||||
|
container?: string;
|
||||||
|
wrapper?: string;
|
||||||
|
handle?: string;
|
||||||
|
resizing?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface VideoOptions {
|
export interface VideoOptions {
|
||||||
view: any;
|
view: any;
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
|
resize: VideoResizeOptions | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoAttributes {
|
export interface VideoAttributes {
|
||||||
src?: string;
|
src?: string;
|
||||||
align?: string;
|
align?: string;
|
||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
width?: number;
|
width?: number | string;
|
||||||
|
height?: number;
|
||||||
aspectRatio?: number;
|
aspectRatio?: number;
|
||||||
placeholder?: {
|
placeholder?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +46,7 @@ declare module "@tiptap/core" {
|
|||||||
) => ReturnType;
|
) => ReturnType;
|
||||||
setVideoAlign: (align: "left" | "center" | "right") => ReturnType;
|
setVideoAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||||
setVideoWidth: (width: number) => ReturnType;
|
setVideoWidth: (width: number) => ReturnType;
|
||||||
|
setVideoSize: (width: number, height: number) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,6 +64,7 @@ export const TiptapVideo = Node.create<VideoOptions>({
|
|||||||
return {
|
return {
|
||||||
view: null,
|
view: null,
|
||||||
HTMLAttributes: {},
|
HTMLAttributes: {},
|
||||||
|
resize: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -64,12 +85,30 @@ export const TiptapVideo = Node.create<VideoOptions>({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: "100%",
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("width"),
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("width");
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.endsWith("%")) return raw;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
renderHTML: (attributes: VideoAttributes) => ({
|
renderHTML: (attributes: VideoAttributes) => ({
|
||||||
width: attributes.width,
|
width: attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
height: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("height");
|
||||||
|
if (!raw) return null;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes: VideoAttributes) => ({
|
||||||
|
height: attributes.height,
|
||||||
|
}),
|
||||||
|
},
|
||||||
size: {
|
size: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-size"),
|
parseHTML: (element) => element.getAttribute("data-size"),
|
||||||
@@ -136,13 +175,168 @@ export const TiptapVideo = Node.create<VideoOptions>({
|
|||||||
commands.updateAttributes("video", {
|
commands.updateAttributes("video", {
|
||||||
width: `${Math.max(0, Math.min(100, width))}%`,
|
width: `${Math.max(0, Math.min(100, width))}%`,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setVideoSize:
|
||||||
|
(width, height) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("video", { width, height }),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
const resize = this.options.resize;
|
||||||
this.editor.isInitialized = true;
|
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
if (!resize || !resize.enabled) {
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
directions,
|
||||||
|
minWidth,
|
||||||
|
minHeight,
|
||||||
|
alwaysPreserveAspectRatio,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
} = resize;
|
||||||
|
|
||||||
|
return (props) => {
|
||||||
|
const { node, getPos, HTMLAttributes, editor } = props;
|
||||||
|
|
||||||
|
if (!node.attrs.src) {
|
||||||
|
editor.isInitialized = true;
|
||||||
|
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||||
|
const view = reactView(props);
|
||||||
|
|
||||||
|
const originalUpdate = view.update?.bind(view);
|
||||||
|
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||||
|
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (originalUpdate) {
|
||||||
|
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.createElement("video");
|
||||||
|
el.src = node.attrs.src;
|
||||||
|
el.controls = true;
|
||||||
|
el.preload = "metadata";
|
||||||
|
el.style.display = "block";
|
||||||
|
el.style.maxWidth = "100%";
|
||||||
|
el.style.borderRadius = "8px";
|
||||||
|
|
||||||
|
let currentNode = node;
|
||||||
|
|
||||||
|
const nodeView = new ResizableNodeView({
|
||||||
|
element: el,
|
||||||
|
editor,
|
||||||
|
node,
|
||||||
|
getPos,
|
||||||
|
onResize: (w, h) => {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
},
|
||||||
|
onCommit: () => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos === undefined) return;
|
||||||
|
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.setNodeSelection(pos)
|
||||||
|
.updateAttributes(this.name, {
|
||||||
|
width: Math.round(el.offsetWidth),
|
||||||
|
height: Math.round(el.offsetHeight),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
|
||||||
|
if (updatedNode.type !== currentNode.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedNode.attrs.src !== currentNode.attrs.src) {
|
||||||
|
el.src = updatedNode.attrs.src || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = updatedNode.attrs.width;
|
||||||
|
const h = updatedNode.attrs.height;
|
||||||
|
if (w != null) {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
}
|
||||||
|
if (h != null) {
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const align = updatedNode.attrs.align || "center";
|
||||||
|
const container = nodeView.dom as HTMLElement;
|
||||||
|
applyAlignment(container, align);
|
||||||
|
|
||||||
|
currentNode = updatedNode;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
directions,
|
||||||
|
min: {
|
||||||
|
width: minWidth,
|
||||||
|
height: minHeight,
|
||||||
|
},
|
||||||
|
preserveAspectRatio: alwaysPreserveAspectRatio === true,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dom = nodeView.dom as HTMLElement;
|
||||||
|
|
||||||
|
applyAlignment(dom, node.attrs.align || "center");
|
||||||
|
|
||||||
|
// Handle percentage width backward compat
|
||||||
|
const widthAttr = node.attrs.width;
|
||||||
|
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const parentEl = dom.parentElement;
|
||||||
|
if (parentEl) {
|
||||||
|
const containerWidth = parentEl.clientWidth;
|
||||||
|
const pctValue = parseInt(widthAttr, 10);
|
||||||
|
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||||
|
const pxWidth = Math.round(
|
||||||
|
containerWidth * (pctValue / 100),
|
||||||
|
);
|
||||||
|
el.style.width = `${pxWidth}px`;
|
||||||
|
if (node.attrs.aspectRatio) {
|
||||||
|
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide until video metadata loads
|
||||||
|
dom.style.visibility = "hidden";
|
||||||
|
dom.style.pointerEvents = "none";
|
||||||
|
el.onloadedmetadata = () => {
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return nodeView;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyAlignment(container: HTMLElement, align: string) {
|
||||||
|
if (align === "left") {
|
||||||
|
container.style.justifyContent = "flex-start";
|
||||||
|
} else if (align === "right") {
|
||||||
|
container.style.justifyContent = "flex-end";
|
||||||
|
} else {
|
||||||
|
container.style.justifyContent = "center";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
diff --git a/dist/index.cjs b/dist/index.cjs
|
||||||
|
index 01d6999642c5ae990083798a1bf0ef87068e4192..891b13c6901f28a6ab413c6dbae0ea726a76a196 100644
|
||||||
|
--- a/dist/index.cjs
|
||||||
|
+++ b/dist/index.cjs
|
||||||
|
@@ -5463,7 +5463,10 @@ var ResizableNodeView = class {
|
||||||
|
this.container.classList.remove(this.classNames.resizing);
|
||||||
|
}
|
||||||
|
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||||
|
+ document.removeEventListener("touchmove", this.handleTouchMove);
|
||||||
|
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||||
|
+ document.removeEventListener("touchend", this.handleMouseUp);
|
||||||
|
+ window.removeEventListener("blur", this.handleMouseUp);
|
||||||
|
document.removeEventListener("keydown", this.handleKeyDown);
|
||||||
|
document.removeEventListener("keyup", this.handleKeyUp);
|
||||||
|
};
|
||||||
|
@@ -5593,7 +5596,10 @@ var ResizableNodeView = class {
|
||||||
|
this.container.classList.remove(this.classNames.resizing);
|
||||||
|
}
|
||||||
|
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||||
|
+ document.removeEventListener("touchmove", this.handleTouchMove);
|
||||||
|
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||||
|
+ document.removeEventListener("touchend", this.handleMouseUp);
|
||||||
|
+ window.removeEventListener("blur", this.handleMouseUp);
|
||||||
|
document.removeEventListener("keydown", this.handleKeyDown);
|
||||||
|
document.removeEventListener("keyup", this.handleKeyUp);
|
||||||
|
this.isResizing = false;
|
||||||
|
@@ -5796,6 +5802,8 @@ var ResizableNodeView = class {
|
||||||
|
document.addEventListener("mousemove", this.handleMouseMove);
|
||||||
|
document.addEventListener("touchmove", this.handleTouchMove);
|
||||||
|
document.addEventListener("mouseup", this.handleMouseUp);
|
||||||
|
+ document.addEventListener("touchend", this.handleMouseUp);
|
||||||
|
+ window.addEventListener("blur", this.handleMouseUp);
|
||||||
|
document.addEventListener("keydown", this.handleKeyDown);
|
||||||
|
document.addEventListener("keyup", this.handleKeyUp);
|
||||||
|
}
|
||||||
|
diff --git a/dist/index.js b/dist/index.js
|
||||||
|
index 6f357a03b038abeb5ed86967b7fc7c3e5eb1d2d6..2d2742532860821984e1ba82625821504538ebbe 100644
|
||||||
|
--- a/dist/index.js
|
||||||
|
+++ b/dist/index.js
|
||||||
|
@@ -5330,7 +5330,10 @@ var ResizableNodeView = class {
|
||||||
|
this.container.classList.remove(this.classNames.resizing);
|
||||||
|
}
|
||||||
|
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||||
|
+ document.removeEventListener("touchmove", this.handleTouchMove);
|
||||||
|
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||||
|
+ document.removeEventListener("touchend", this.handleMouseUp);
|
||||||
|
+ window.removeEventListener("blur", this.handleMouseUp);
|
||||||
|
document.removeEventListener("keydown", this.handleKeyDown);
|
||||||
|
document.removeEventListener("keyup", this.handleKeyUp);
|
||||||
|
};
|
||||||
|
@@ -5460,7 +5463,10 @@ var ResizableNodeView = class {
|
||||||
|
this.container.classList.remove(this.classNames.resizing);
|
||||||
|
}
|
||||||
|
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||||
|
+ document.removeEventListener("touchmove", this.handleTouchMove);
|
||||||
|
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||||
|
+ document.removeEventListener("touchend", this.handleMouseUp);
|
||||||
|
+ window.removeEventListener("blur", this.handleMouseUp);
|
||||||
|
document.removeEventListener("keydown", this.handleKeyDown);
|
||||||
|
document.removeEventListener("keyup", this.handleKeyUp);
|
||||||
|
this.isResizing = false;
|
||||||
|
@@ -5663,6 +5669,8 @@ var ResizableNodeView = class {
|
||||||
|
document.addEventListener("mousemove", this.handleMouseMove);
|
||||||
|
document.addEventListener("touchmove", this.handleTouchMove);
|
||||||
|
document.addEventListener("mouseup", this.handleMouseUp);
|
||||||
|
+ document.addEventListener("touchend", this.handleMouseUp);
|
||||||
|
+ window.addEventListener("blur", this.handleMouseUp);
|
||||||
|
document.addEventListener("keydown", this.handleKeyDown);
|
||||||
|
document.addEventListener("keyup", this.handleKeyUp);
|
||||||
|
}
|
||||||
|
diff --git a/src/lib/ResizableNodeView.ts b/src/lib/ResizableNodeView.ts
|
||||||
|
index f13e210b0aa46aefe7c31105deee3d2aa8a26cd5..9bac138dbf17c6ae6c3c129cbedb3a81bd39b60c 100644
|
||||||
|
--- a/src/lib/ResizableNodeView.ts
|
||||||
|
+++ b/src/lib/ResizableNodeView.ts
|
||||||
|
@@ -523,7 +523,10 @@ export class ResizableNodeView {
|
||||||
|
}
|
||||||
|
|
||||||
|
document.removeEventListener('mousemove', this.handleMouseMove)
|
||||||
|
+ document.removeEventListener('touchmove', this.handleTouchMove)
|
||||||
|
document.removeEventListener('mouseup', this.handleMouseUp)
|
||||||
|
+ document.removeEventListener('touchend', this.handleMouseUp)
|
||||||
|
+ window.removeEventListener('blur', this.handleMouseUp)
|
||||||
|
document.removeEventListener('keydown', this.handleKeyDown)
|
||||||
|
document.removeEventListener('keyup', this.handleKeyUp)
|
||||||
|
this.isResizing = false
|
||||||
|
@@ -774,6 +777,8 @@ export class ResizableNodeView {
|
||||||
|
document.addEventListener('mousemove', this.handleMouseMove)
|
||||||
|
document.addEventListener('touchmove', this.handleTouchMove)
|
||||||
|
document.addEventListener('mouseup', this.handleMouseUp)
|
||||||
|
+ document.addEventListener('touchend', this.handleMouseUp)
|
||||||
|
+ window.addEventListener('blur', this.handleMouseUp)
|
||||||
|
document.addEventListener('keydown', this.handleKeyDown)
|
||||||
|
document.addEventListener('keyup', this.handleKeyUp)
|
||||||
|
}
|
||||||
|
@@ -859,7 +864,10 @@ export class ResizableNodeView {
|
||||||
|
|
||||||
|
// Clean up document-level listeners
|
||||||
|
document.removeEventListener('mousemove', this.handleMouseMove)
|
||||||
|
+ document.removeEventListener('touchmove', this.handleTouchMove)
|
||||||
|
document.removeEventListener('mouseup', this.handleMouseUp)
|
||||||
|
+ document.removeEventListener('touchend', this.handleMouseUp)
|
||||||
|
+ window.removeEventListener('blur', this.handleMouseUp)
|
||||||
|
document.removeEventListener('keydown', this.handleKeyDown)
|
||||||
|
document.removeEventListener('keyup', this.handleKeyUp)
|
||||||
|
}
|
||||||
Generated
+141
-189
@@ -29,6 +29,9 @@ overrides:
|
|||||||
'@tiptap/extension-code': 3.17.1
|
'@tiptap/extension-code': 3.17.1
|
||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
|
'@tiptap/core':
|
||||||
|
hash: efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00
|
||||||
|
path: patches/@tiptap__core.patch
|
||||||
react-arborist@3.4.0:
|
react-arborist@3.4.0:
|
||||||
hash: 419b3b02e24afe928cc006a006f6e906666aff19aa6fd7daaa788ccc2202678a
|
hash: 419b3b02e24afe928cc006a006f6e906666aff19aa6fd7daaa788ccc2202678a
|
||||||
path: patches/react-arborist@3.4.0.patch
|
path: patches/react-arborist@3.4.0.patch
|
||||||
@@ -57,7 +60,7 @@ importers:
|
|||||||
version: 3.4.4(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)
|
version: 3.4.4(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)
|
||||||
'@hocuspocus/transformer':
|
'@hocuspocus/transformer':
|
||||||
specifier: 3.4.4
|
specifier: 3.4.4
|
||||||
version: 3.4.4(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)
|
version: 3.4.4(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)
|
||||||
'@joplin/turndown':
|
'@joplin/turndown':
|
||||||
specifier: ^4.0.74
|
specifier: ^4.0.74
|
||||||
version: 4.0.74
|
version: 4.0.74
|
||||||
@@ -69,85 +72,85 @@ importers:
|
|||||||
version: 1.1.0
|
version: 1.1.0
|
||||||
'@tiptap/core':
|
'@tiptap/core':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/pm@3.17.1)
|
version: 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/extension-code-block':
|
'@tiptap/extension-code-block':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/extension-collaboration':
|
'@tiptap/extension-collaboration':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)
|
||||||
'@tiptap/extension-collaboration-caret':
|
'@tiptap/extension-collaboration-caret':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))
|
||||||
'@tiptap/extension-color':
|
'@tiptap/extension-color':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)))
|
version: 3.17.1(@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)))
|
||||||
'@tiptap/extension-document':
|
'@tiptap/extension-document':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-heading':
|
'@tiptap/extension-heading':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-highlight':
|
'@tiptap/extension-highlight':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-history':
|
'@tiptap/extension-history':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
version: 3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-image':
|
'@tiptap/extension-image':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-link':
|
'@tiptap/extension-link':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/extension-list':
|
'@tiptap/extension-list':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/extension-placeholder':
|
'@tiptap/extension-placeholder':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
version: 3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-subscript':
|
'@tiptap/extension-subscript':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/extension-superscript':
|
'@tiptap/extension-superscript':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/extension-table':
|
'@tiptap/extension-table':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/extension-text':
|
'@tiptap/extension-text':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-text-align':
|
'@tiptap/extension-text-align':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-text-style':
|
'@tiptap/extension-text-style':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-typography':
|
'@tiptap/extension-typography':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-unique-id':
|
'@tiptap/extension-unique-id':
|
||||||
specifier: ^3.17.1
|
specifier: ^3.17.1
|
||||||
version: 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
version: 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/extension-youtube':
|
'@tiptap/extension-youtube':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/html':
|
'@tiptap/html':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(happy-dom@20.1.0)
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(happy-dom@20.1.0)
|
||||||
'@tiptap/pm':
|
'@tiptap/pm':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1
|
version: 3.17.1
|
||||||
'@tiptap/react':
|
'@tiptap/react':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@tiptap/starter-kit':
|
'@tiptap/starter-kit':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1
|
version: 3.17.1
|
||||||
'@tiptap/suggestion':
|
'@tiptap/suggestion':
|
||||||
specifier: 3.17.1
|
specifier: 3.17.1
|
||||||
version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/y-tiptap':
|
'@tiptap/y-tiptap':
|
||||||
specifier: ^3.0.2
|
specifier: ^3.0.2
|
||||||
version: 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)
|
version: 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)
|
||||||
@@ -474,9 +477,6 @@ importers:
|
|||||||
'@fastify/static':
|
'@fastify/static':
|
||||||
specifier: ^9.0.0
|
specifier: ^9.0.0
|
||||||
version: 9.0.0
|
version: 9.0.0
|
||||||
'@keyv/redis':
|
|
||||||
specifier: ^5.1.6
|
|
||||||
version: 5.1.6(keyv@5.6.0)
|
|
||||||
'@langchain/core':
|
'@langchain/core':
|
||||||
specifier: 1.1.18
|
specifier: 1.1.18
|
||||||
version: 1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))
|
version: 1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))
|
||||||
@@ -489,9 +489,6 @@ importers:
|
|||||||
'@nestjs/bullmq':
|
'@nestjs/bullmq':
|
||||||
specifier: ^11.0.4
|
specifier: ^11.0.4
|
||||||
version: 11.0.4(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.65.0)
|
version: 11.0.4(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.65.0)
|
||||||
'@nestjs/cache-manager':
|
|
||||||
specifier: ^3.1.0
|
|
||||||
version: 3.1.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)
|
|
||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^11.1.11
|
specifier: ^11.1.11
|
||||||
version: 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -2570,12 +2567,6 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.9':
|
'@jridgewell/trace-mapping@0.3.9':
|
||||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||||
|
|
||||||
'@keyv/redis@5.1.6':
|
|
||||||
resolution: {integrity: sha512-eKvW6pspvVaU5dxigaIDZr635/Uw6urTXL3gNbY9WTR8d3QigZQT+r8gxYSEOsw4+1cCBsC4s7T2ptR0WC9LfQ==}
|
|
||||||
engines: {node: '>= 18'}
|
|
||||||
peerDependencies:
|
|
||||||
keyv: ^5.6.0
|
|
||||||
|
|
||||||
'@keyv/serialize@1.1.1':
|
'@keyv/serialize@1.1.1':
|
||||||
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
|
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
|
||||||
|
|
||||||
@@ -2779,15 +2770,6 @@ packages:
|
|||||||
'@nestjs/core': ^10.0.0 || ^11.0.0
|
'@nestjs/core': ^10.0.0 || ^11.0.0
|
||||||
bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0
|
bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0
|
||||||
|
|
||||||
'@nestjs/cache-manager@3.1.0':
|
|
||||||
resolution: {integrity: sha512-pEIqYZrBcE8UdkJmZRduurvoUfdU+3kRPeO1R2muiMbZnRuqlki5klFFNllO9LyYWzrx98bd1j0PSPKSJk1Wbw==}
|
|
||||||
peerDependencies:
|
|
||||||
'@nestjs/common': ^9.0.0 || ^10.0.0 || ^11.0.0
|
|
||||||
'@nestjs/core': ^9.0.0 || ^10.0.0 || ^11.0.0
|
|
||||||
cache-manager: '>=6'
|
|
||||||
keyv: '>=5'
|
|
||||||
rxjs: ^7.8.1
|
|
||||||
|
|
||||||
'@nestjs/cli@11.0.16':
|
'@nestjs/cli@11.0.16':
|
||||||
resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==}
|
resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==}
|
||||||
engines: {node: '>= 20.11'}
|
engines: {node: '>= 20.11'}
|
||||||
@@ -4047,15 +4029,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
|
||||||
'@redis/client@5.11.0':
|
|
||||||
resolution: {integrity: sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==}
|
|
||||||
engines: {node: '>= 18'}
|
|
||||||
peerDependencies:
|
|
||||||
'@node-rs/xxhash': ^1.1.0
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@node-rs/xxhash':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@remirror/core-constants@3.0.0':
|
'@remirror/core-constants@3.0.0':
|
||||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||||
|
|
||||||
@@ -12362,9 +12335,9 @@ snapshots:
|
|||||||
- bufferutil
|
- bufferutil
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
'@hocuspocus/transformer@3.4.4(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)':
|
'@hocuspocus/transformer@3.4.4(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
'@tiptap/starter-kit': 3.17.1
|
'@tiptap/starter-kit': 3.17.1
|
||||||
y-prosemirror: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)
|
y-prosemirror: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)
|
||||||
@@ -12897,15 +12870,6 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
|
|
||||||
'@keyv/redis@5.1.6(keyv@5.6.0)':
|
|
||||||
dependencies:
|
|
||||||
'@redis/client': 5.11.0
|
|
||||||
cluster-key-slot: 1.1.2
|
|
||||||
hookified: 1.15.1
|
|
||||||
keyv: 5.6.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@node-rs/xxhash'
|
|
||||||
|
|
||||||
'@keyv/serialize@1.1.1': {}
|
'@keyv/serialize@1.1.1': {}
|
||||||
|
|
||||||
'@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))':
|
'@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))':
|
||||||
@@ -13098,14 +13062,6 @@ snapshots:
|
|||||||
bullmq: 5.65.0
|
bullmq: 5.65.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@nestjs/cache-manager@3.1.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)':
|
|
||||||
dependencies:
|
|
||||||
'@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
'@nestjs/core': 11.1.13(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
cache-manager: 7.2.8
|
|
||||||
keyv: 5.6.0
|
|
||||||
rxjs: 7.8.2
|
|
||||||
|
|
||||||
'@nestjs/cli@11.0.16(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@22.13.4)':
|
'@nestjs/cli@11.0.16(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@22.13.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@angular-devkit/core': 19.2.19(chokidar@4.0.3)
|
'@angular-devkit/core': 19.2.19(chokidar@4.0.3)
|
||||||
@@ -14412,10 +14368,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
'@redis/client@5.11.0':
|
|
||||||
dependencies:
|
|
||||||
cluster-key-slot: 1.1.2
|
|
||||||
|
|
||||||
'@remirror/core-constants@3.0.0': {}
|
'@remirror/core-constants@3.0.0': {}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.47': {}
|
'@rolldown/pluginutils@1.0.0-beta.47': {}
|
||||||
@@ -14946,191 +14898,191 @@ snapshots:
|
|||||||
'@tanstack/query-core': 5.90.17
|
'@tanstack/query-core': 5.90.17
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
'@tiptap/core@3.17.1(@tiptap/pm@3.17.1)':
|
'@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
|
|
||||||
'@tiptap/extension-blockquote@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-blockquote@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-bold@3.17.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-bold@3.17.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-bubble-menu@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
'@tiptap/extension-bubble-menu@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/dom': 1.7.4
|
'@floating-ui/dom': 1.7.4
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tiptap/extension-bullet-list@3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-bullet-list@3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-code-block@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
'@tiptap/extension-code-block@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
|
|
||||||
'@tiptap/extension-code@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-code@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-collaboration-caret@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))':
|
'@tiptap/extension-collaboration-caret@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
'@tiptap/y-tiptap': 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)
|
'@tiptap/y-tiptap': 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)
|
||||||
|
|
||||||
'@tiptap/extension-collaboration@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)':
|
'@tiptap/extension-collaboration@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
'@tiptap/y-tiptap': 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)
|
'@tiptap/y-tiptap': 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)
|
||||||
yjs: 13.6.29
|
yjs: 13.6.29
|
||||||
|
|
||||||
'@tiptap/extension-color@3.17.1(@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)))':
|
'@tiptap/extension-color@3.17.1(@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/extension-text-style': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
'@tiptap/extension-text-style': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
|
|
||||||
'@tiptap/extension-document@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-document@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-dropcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-dropcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
'@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/dom': 1.7.3
|
'@floating-ui/dom': 1.7.3
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tiptap/extension-gapcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-gapcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-hard-break@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-hard-break@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-heading@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-heading@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-highlight@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-highlight@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-history@3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-history@3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-horizontal-rule@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
'@tiptap/extension-horizontal-rule@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
|
|
||||||
'@tiptap/extension-image@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-image@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-italic@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-italic@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-link@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
'@tiptap/extension-link@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
linkifyjs: 4.3.2
|
linkifyjs: 4.3.2
|
||||||
|
|
||||||
'@tiptap/extension-list-item@3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-list-item@3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-list-keymap@3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-list-keymap@3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
'@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
|
|
||||||
'@tiptap/extension-ordered-list@3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-ordered-list@3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-paragraph@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-paragraph@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-placeholder@3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-placeholder@3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-strike@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-strike@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-subscript@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
'@tiptap/extension-subscript@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
|
|
||||||
'@tiptap/extension-superscript@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
'@tiptap/extension-superscript@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
|
|
||||||
'@tiptap/extension-table@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
'@tiptap/extension-table@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
|
|
||||||
'@tiptap/extension-text-align@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-text-align@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-text@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-text@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-typography@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-typography@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-underline@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-underline@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extension-unique-id@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
'@tiptap/extension-unique-id@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
uuid: 10.0.0
|
uuid: 10.0.0
|
||||||
|
|
||||||
'@tiptap/extension-youtube@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))':
|
'@tiptap/extension-youtube@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
|
|
||||||
'@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
'@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
|
|
||||||
'@tiptap/html@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(happy-dom@20.1.0)':
|
'@tiptap/html@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(happy-dom@20.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
happy-dom: 20.1.0
|
happy-dom: 20.1.0
|
||||||
|
|
||||||
@@ -15155,9 +15107,9 @@ snapshots:
|
|||||||
prosemirror-transform: 1.10.4
|
prosemirror-transform: 1.10.4
|
||||||
prosemirror-view: 1.40.0
|
prosemirror-view: 1.40.0
|
||||||
|
|
||||||
'@tiptap/react@3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@tiptap/react@3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
'@types/react': 18.3.12
|
'@types/react': 18.3.12
|
||||||
'@types/react-dom': 18.3.1
|
'@types/react-dom': 18.3.1
|
||||||
@@ -15167,41 +15119,41 @@ snapshots:
|
|||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
use-sync-external-store: 1.6.0(react@18.3.1)
|
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@tiptap/extension-bubble-menu': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extension-bubble-menu': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/extension-floating-menu': 3.19.0(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extension-floating-menu': 3.19.0(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@floating-ui/dom'
|
- '@floating-ui/dom'
|
||||||
|
|
||||||
'@tiptap/starter-kit@3.17.1':
|
'@tiptap/starter-kit@3.17.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/extension-blockquote': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
'@tiptap/extension-blockquote': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-bold': 3.17.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
'@tiptap/extension-bold': 3.17.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-bullet-list': 3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
'@tiptap/extension-bullet-list': 3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-code': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
'@tiptap/extension-code': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-code-block': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extension-code-block': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/extension-document': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
'@tiptap/extension-document': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-dropcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
'@tiptap/extension-dropcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-gapcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
'@tiptap/extension-gapcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-hard-break': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
'@tiptap/extension-hard-break': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-heading': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
'@tiptap/extension-heading': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-horizontal-rule': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extension-horizontal-rule': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/extension-italic': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
'@tiptap/extension-italic': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-link': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extension-link': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/extension-list-item': 3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
'@tiptap/extension-list-item': 3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-list-keymap': 3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
'@tiptap/extension-list-keymap': 3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-ordered-list': 3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
'@tiptap/extension-ordered-list': 3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-paragraph': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
'@tiptap/extension-paragraph': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-strike': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
'@tiptap/extension-strike': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-text': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
'@tiptap/extension-text': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extension-underline': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))
|
'@tiptap/extension-underline': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))
|
||||||
'@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
'@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
|
|
||||||
'@tiptap/suggestion@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
'@tiptap/suggestion@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.1(@tiptap/pm@3.17.1)
|
'@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)
|
||||||
'@tiptap/pm': 3.17.1
|
'@tiptap/pm': 3.17.1
|
||||||
|
|
||||||
'@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)':
|
'@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)':
|
||||||
|
|||||||
Reference in New Issue
Block a user