Compare commits

..

6 Commits

Author SHA1 Message Date
Philipinho c0df96d4bb update packages 2026-02-25 23:04:18 +00:00
Philipinho 22f33bab7c cleanups 2026-02-25 22:41:54 +00:00
Philipinho e0a8521566 enhance columns 2026-02-25 22:31:01 +00:00
Philip Okugbe b5803f42da xwiki html import cleanup (#1969) 2026-02-24 15:53:38 +00:00
Olivier Lambert 5de1c8e3ed fix: inline code input rule deletes character before opening backtick (#1923)
The upstream TipTap Code extension input rule regex /(^|[^`])`([^`]+)`(?!`)$/
uses a capture group (^|[^`]) that includes the character preceding the
opening backtick in the full match. When markInputRule processes this,
it deletes everything from the match start to the code content, which
removes that preceding character along with the backtick delimiters.

For example, typing foo(`bar` would result in foo`bar` (formatted)
instead of the expected foo(`bar` (formatted) — the ( is lost.

Fix: disable the built-in Code extension from StarterKit and register it
separately with a corrected regex that uses a lookbehind assertion
(?:^|(?<=[^`])) instead of a capture group. The lookbehind asserts the
preceding character without including it in the match, so markInputRule
only deletes the backtick delimiters.

Functionally tested on Firefox and Chrome.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:51:24 +00:00
Philip Okugbe ef87210b3d feat: editor UI refresh and enhancements (#1968)
* feat: new image menu
* switch to resizable side handles
* use pixels

* refactor excalidraw and drawio menu

* support image resize undo

* video resize

* callout menu refresh

* refresh table menus

* fix color scheme

* fix: patch @tiptap/core ResizableNodeView to prevent resize sticking after mouseup

* feat: columns

* notes callout

* focus on first column

* capture tab key in column

* fix print

* hide columns menu when some nodes are focused

* fix print

* fix columns

* selective placeholder

* fix blockquote

* quote

* fix callout in columns
2026-02-24 15:22:37 +00:00
98 changed files with 4933 additions and 5839 deletions
+1 -1
View File
@@ -59,7 +59,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.16.0", "@eslint/js": "^9.16.0",
"@tanstack/eslint-plugin-query": "^5.62.1", "@tanstack/eslint-plugin-query": "^5.91.4",
"@types/blueimp-load-image": "^5.16.0", "@types/blueimp-load-image": "^5.16.0",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
@@ -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 (
@@ -1,22 +1,25 @@
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 } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "@tiptap/pm/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 {
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,361 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef, useState } from "react";
import { DOMSerializer, Node as PMNode } from "@tiptap/pm/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,
IconCopy,
IconTrash,
} 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 [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
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 handleCopy = useCallback(() => {
const { state } = editor;
const parent = findParentNode(
(node: PMNode) => node.type.name === "columns",
)(state.selection);
if (!parent) return;
const serializer = DOMSerializer.fromSchema(state.schema);
const dom = serializer.serializeNode(parent.node);
const wrapper = document.createElement("div");
wrapper.appendChild(dom);
const onSuccess = () => {
clearTimeout(copyTimerRef.current);
setCopied(true);
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
};
if (navigator.clipboard?.write) {
navigator.clipboard
.write([
new ClipboardItem({
"text/html": new Blob([wrapper.innerHTML], { type: "text/html" }),
"text/plain": new Blob([parent.node.textContent], {
type: "text/plain",
}),
}),
])
.then(onSuccess)
.catch(execCommandFallback);
} else {
execCommandFallback();
}
function execCommandFallback() {
wrapper.style.position = "fixed";
wrapper.style.left = "-9999px";
document.body.appendChild(wrapper);
const range = document.createRange();
range.selectNodeContents(wrapper);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand("copy");
sel?.removeAllRanges();
document.body.removeChild(wrapper);
editor.view.focus();
onSuccess();
}
}, [editor]);
const handleDelete = useCallback(() => {
const parent = findParentNode(
(node: PMNode) => node.type.name === "columns",
)(editor.state.selection);
if (!parent) return;
editor.chain().focus().setNodeSelection(parent.pos).deleteSelection().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 className={classes.divider} />
<Tooltip
position="top"
label={copied ? t("Copied") : t("Copy")}
withinPortal={false}
>
<ActionIcon
onClick={handleCopy}
size="lg"
aria-label={t("Copy")}
variant="subtle"
>
{copied ? (
<IconCheck size={18} color="var(--mantine-color-green-6)" />
) : (
<IconCopy size={18} />
)}
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash 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 "@tiptap/pm/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 "@tiptap/pm/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 "@tiptap/pm/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 "@tiptap/pm/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>
); );
} }
@@ -1,4 +1,6 @@
import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit"; import { StarterKit } from "@tiptap/starter-kit";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align"; import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list"; import { TaskList, TaskItem } from "@tiptap/extension-list";
import { Placeholder, CharacterCount } from "@tiptap/extensions"; import { Placeholder, CharacterCount } from "@tiptap/extensions";
@@ -43,6 +45,8 @@ import {
Highlight, Highlight,
UniqueID, UniqueID,
SharedStorage, SharedStorage,
Columns,
Column,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -52,6 +56,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 +103,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,
@@ -102,10 +115,24 @@ export const mainExtensions = [
color: "#70CFF8", color: "#70CFF8",
}, },
codeBlock: false, codeBlock: false,
code: { code: false,
HTMLAttributes: { }),
spellcheck: false, // Override TipTap's Code extension to fix the inline code input rule.
}, // The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule
// to delete it. Using a lookbehind avoids including it in the match.
Code.configure({
HTMLAttributes: {
spellcheck: false,
},
}).extend({
addInputRules() {
return [
markInputRule({
find: /(?:^|(?<=[^`]))`([^`]+)`(?!`)$/,
type: this.type,
}),
];
}, },
}), }),
SharedStorage, SharedStorage,
@@ -115,7 +142,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 +150,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 +238,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 +279,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 +331,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,124 @@
div[data-type="columns"] {
display: flex;
margin: 0.75rem 0;
padding: 0.5em 0;
}
div[data-type="columns"] > div[data-type="column"] {
flex: 1;
min-width: 0;
padding-right: 1rem;
}
div[data-type="columns"] > div[data-type="column"]:last-child {
padding-right: 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"],
div[data-type="columns"].has-focus
> 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: 680px) {
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%;
}
}
+11 -14
View File
@@ -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}
+1
View File
@@ -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);
+18 -3
View File
@@ -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>
+10 -17
View File
@@ -33,30 +33,28 @@
"@ai-sdk/google": "^3.0.29", "@ai-sdk/google": "^3.0.29",
"@ai-sdk/openai": "^3.0.29", "@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30", "@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-s3": "3.982.0", "@aws-sdk/client-s3": "3.998.0",
"@aws-sdk/lib-storage": "3.982.0", "@aws-sdk/lib-storage": "3.998.0",
"@aws-sdk/s3-request-presigner": "3.982.0", "@aws-sdk/s3-request-presigner": "3.998.0",
"@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.14",
"@nestjs/common": "^11.1.11", "@nestjs/config": "^4.0.3",
"@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.14",
"@nestjs/core": "^11.1.13",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0", "@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.13", "@nestjs/platform-fastify": "^11.1.14",
"@nestjs/platform-socket.io": "^11.1.13", "@nestjs/platform-socket.io": "^11.1.14",
"@nestjs/schedule": "^6.1.0", "@nestjs/schedule": "^6.1.0",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.13", "@nestjs/websockets": "^11.1.14",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "1.0.7", "@react-email/components": "1.0.7",
"@react-email/render": "2.0.4", "@react-email/render": "2.0.4",
@@ -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"
}
} }
} }
-15
View File
@@ -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(
-2
View File
@@ -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 };
}
}
+39 -99
View File
@@ -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));
}
} }
+2 -30
View File
@@ -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 };
+19 -28
View File
@@ -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);
} }
+4 -21
View File
@@ -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
View File
@@ -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'>>;
@@ -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));
}
} }
@@ -50,6 +50,7 @@ export async function formatImportHtml(opts: {
} }
notionFormatter($, $root); notionFormatter($, $root);
xwikiFormatter($, $root);
defaultHtmlFormatter($, $root); defaultHtmlFormatter($, $root);
const backlinks = await rewriteInternalLinksToMentionHtml( const backlinks = await rewriteInternalLinksToMentionHtml(
@@ -69,6 +70,14 @@ export async function formatImportHtml(opts: {
}; };
} }
export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
const $content = $root.find('#xwikicontent');
if ($content.length) {
$root.children().remove();
$root.append($content.contents());
}
}
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) { export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
$root.find('a[href]').each((_, el) => { $root.find('a[href]').each((_, el) => {
const $el = $(el); const $el = $(el);
@@ -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;
-47
View File
@@ -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: [],
},
},
});
}
}
+18 -19
View File
@@ -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}`;
}
} }
+3 -6
View File
@@ -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 {}
-157
View File
@@ -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;
}
}
}
-17
View File
@@ -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
View File
@@ -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"
} }
} }
+7 -5
View File
@@ -79,13 +79,13 @@
"yjs": "^13.6.29" "yjs": "^13.6.29"
}, },
"devDependencies": { "devDependencies": {
"@nx/js": "22.5.0", "@nx/js": "22.5.2",
"@types/bytes": "^3.1.5", "@types/bytes": "^3.1.5",
"@types/turndown": "^5.0.6", "@types/turndown": "^5.0.6",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"concurrently": "^9.1.2", "concurrently": "^9.2.1",
"nx": "22.5.0", "nx": "22.5.2",
"tsx": "^4.19.3" "tsx": "^4.21.0"
}, },
"workspaces": { "workspaces": {
"packages": [ "packages": [
@@ -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",
@@ -112,6 +113,7 @@
"tmp": "0.2.5", "tmp": "0.2.5",
"lodash-es": "4.17.23", "lodash-es": "4.17.23",
"markdown-it": "14.1.1", "markdown-it": "14.1.1",
"ajv": "8.18.0",
"@tiptap/core": "3.17.1", "@tiptap/core": "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",
+1
View File
@@ -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";
+16 -3
View File
@@ -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 "@tiptap/pm/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,237 @@
import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
import { Fragment, Node as PMNode } from "@tiptap/pm/model";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
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++) {
const col = columnsNode.child(j);
const nonEmpty: PMNode[] = [];
col.content.forEach((child) => {
if (
child.type.name !== "paragraph" ||
child.content.size > 0
) {
nonEmpty.push(child);
}
});
if (nonEmpty.length > 0) {
mergedContent = mergedContent.append(
Fragment.from(nonEmpty),
);
}
}
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);
tr.setSelection(
TextSelection.near(tr.doc.resolve(parentPos + 1), 1),
);
return true;
},
setColumnsLayout:
(layout) =>
({ commands }) =>
commands.updateAttributes("columns", { layout }),
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("columnsFocus"),
props: {
decorations: (state) => {
const parent = findParentNode(
(node) => node.type.name === "columns",
)(state.selection);
if (!parent) return DecorationSet.empty;
return DecorationSet.create(state.doc, [
Decoration.node(
parent.pos,
parent.pos + parent.node.nodeSize,
{ class: "has-focus" },
),
]);
},
},
}),
];
},
});
@@ -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";
+215 -8
View File
@@ -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";
}
}
+216 -8
View File
@@ -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";
}
}
@@ -2,8 +2,8 @@ import TiptapHeading, {
HeadingOptions as TiptapHeadingOptions, HeadingOptions as TiptapHeadingOptions,
} from "@tiptap/extension-heading"; } from "@tiptap/extension-heading";
import { mergeAttributes } from "@tiptap/react"; import { mergeAttributes } from "@tiptap/react";
import { Decoration, DecorationSet } from "prosemirror-view"; import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { Plugin } from "prosemirror-state"; import { Plugin } from "@tiptap/pm/state";
import { copyToClipboard } from "../utils"; import { copyToClipboard } from "../utils";
const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`; const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`;
+230 -11
View File
@@ -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";
}
}
+201 -7
View File
@@ -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";
}
}
+105
View File
@@ -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)
}
+1538 -1618
View File
File diff suppressed because it is too large Load Diff