mirror of
https://github.com/docmost/docmost.git
synced 2026-05-12 01:41:12 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4c3d06a08 | |||
| 8aa45815f4 | |||
| 660eb4a944 | |||
| 0fdd7ef6d6 | |||
| 1586f4be13 | |||
| 6fdab5fe70 | |||
| fadeeaa59d |
@@ -54,6 +54,7 @@
|
|||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
|
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -416,6 +416,7 @@
|
|||||||
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
||||||
"Default page edit mode": "Default page edit mode",
|
"Default page edit mode": "Default page edit mode",
|
||||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
|
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
|
||||||
|
"Choose {{format}} file": "Choose {{format}} file",
|
||||||
"Reading": "Reading",
|
"Reading": "Reading",
|
||||||
"Delete member": "Delete member",
|
"Delete member": "Delete member",
|
||||||
"Member deleted successfully": "Member deleted successfully",
|
"Member deleted successfully": "Member deleted successfully",
|
||||||
@@ -901,16 +902,29 @@
|
|||||||
"This action cannot be undone. Your identity provider will stop syncing immediately.": "This action cannot be undone. Your identity provider will stop syncing immediately.",
|
"This action cannot be undone. Your identity provider will stop syncing immediately.": "This action cannot be undone. Your identity provider will stop syncing immediately.",
|
||||||
"Toggle SCIM provisioning": "Toggle SCIM provisioning",
|
"Toggle SCIM provisioning": "Toggle SCIM provisioning",
|
||||||
"Token": "Token",
|
"Token": "Token",
|
||||||
"Sync block": "Sync block",
|
"Page menu": "Page menu",
|
||||||
"Create a block that stays in sync across pages.": "Create a block that stays in sync across pages.",
|
"Expand": "Expand",
|
||||||
"Sync block name": "Sync block name",
|
"Collapse": "Collapse",
|
||||||
"Editing original": "Editing original",
|
"Comment menu": "Comment menu",
|
||||||
"Copy synced block": "Copy synced block",
|
"Group menu": "Group menu",
|
||||||
"Unsync": "Unsync",
|
"Show hidden breadcrumbs": "Show hidden breadcrumbs",
|
||||||
"Delete sync block": "Delete sync block",
|
"Breadcrumbs": "Breadcrumbs",
|
||||||
"Synced to {{count}} other page_one": "Synced to {{count}} other page",
|
"Page actions": "Page actions",
|
||||||
"Synced to {{count}} other page_other": "Synced to {{count}} other pages",
|
"Pick emoji": "Pick emoji",
|
||||||
"ORIGINAL": "ORIGINAL",
|
"Template menu": "Template menu",
|
||||||
"THIS PAGE": "THIS PAGE",
|
"Chat menu": "Chat menu",
|
||||||
"No pages": "No pages"
|
"API key menu": "API key menu",
|
||||||
|
"Jump to comment selection": "Jump to comment selection",
|
||||||
|
"Slash commands": "Slash commands",
|
||||||
|
"Mention suggestions": "Mention suggestions",
|
||||||
|
"Link suggestions": "Link suggestions",
|
||||||
|
"Diagram editor": "Diagram editor",
|
||||||
|
"Add comment": "Add comment",
|
||||||
|
"Find and replace": "Find and replace",
|
||||||
|
"Main navigation": "Main navigation",
|
||||||
|
"Space navigation": "Space navigation",
|
||||||
|
"Settings navigation": "Settings navigation",
|
||||||
|
"AI navigation": "AI navigation",
|
||||||
|
"Breadcrumb": "Breadcrumb",
|
||||||
|
"Skip to main content": "Skip to main content"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ export default function AvatarUploader({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ariaLabel = {
|
||||||
|
[AvatarIconType.AVATAR]: t("Change avatar"),
|
||||||
|
[AvatarIconType.SPACE_ICON]: t("Change space icon"),
|
||||||
|
[AvatarIconType.WORKSPACE_ICON]: t("Change workspace icon"),
|
||||||
|
}[type];
|
||||||
|
|
||||||
const handleRemove = async () => {
|
const handleRemove = async () => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
|
||||||
@@ -104,6 +110,8 @@ export default function AvatarUploader({
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileInputChange}
|
onChange={handleFileInputChange}
|
||||||
accept="image/png,image/jpeg,image/jpg"
|
accept="image/png,image/jpeg,image/jpg"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
tabIndex={-1}
|
||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -115,6 +123,8 @@ export default function AvatarUploader({
|
|||||||
size={size}
|
size={size}
|
||||||
avatarUrl={currentImageUrl}
|
avatarUrl={currentImageUrl}
|
||||||
name={fallbackName}
|
name={fallbackName}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-haspopup="menu"
|
||||||
style={{
|
style={{
|
||||||
cursor: disabled || isLoading ? "default" : "pointer",
|
cursor: disabled || isLoading ? "default" : "pointer",
|
||||||
opacity: isLoading ? 0.6 : 1,
|
opacity: isLoading ? 0.6 : 1,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default function CopyTextButton({ text, size }: CopyProps) {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={copy}
|
onClick={copy}
|
||||||
size={size}
|
size={size}
|
||||||
|
aria-label={copied ? t("Copied") : t("Copy")}
|
||||||
>
|
>
|
||||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
Badge,
|
Badge,
|
||||||
Table,
|
Table,
|
||||||
ActionIcon,
|
ThemeIcon,
|
||||||
Button,
|
Button,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -49,9 +49,9 @@ export default function RecentChanges({ spaceId }: Props) {
|
|||||||
>
|
>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{page.icon || (
|
{page.icon || (
|
||||||
<ActionIcon variant="transparent" color="gray" size={18}>
|
<ThemeIcon variant="transparent" color="gray" size={18}>
|
||||||
<IconFileDescription size={18} />
|
<IconFileDescription size={18} />
|
||||||
</ActionIcon>
|
</ThemeIcon>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text fw={500} size="md" lineClamp={1}>
|
<Text fw={500} size="md" lineClamp={1}>
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
export interface SearchInputProps {
|
export interface SearchInputProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
debounceDelay?: number;
|
debounceDelay?: number;
|
||||||
onSearch: (value: string) => void;
|
onSearch: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchInput({
|
export function SearchInput({
|
||||||
placeholder,
|
placeholder,
|
||||||
|
ariaLabel,
|
||||||
debounceDelay = 500,
|
debounceDelay = 500,
|
||||||
onSearch,
|
onSearch,
|
||||||
}: SearchInputProps) {
|
}: SearchInputProps) {
|
||||||
@@ -28,6 +30,7 @@ export function SearchInput({
|
|||||||
<TextInput
|
<TextInput
|
||||||
size="sm"
|
size="sm"
|
||||||
placeholder={placeholder || t("Search...")}
|
placeholder={placeholder || t("Search...")}
|
||||||
|
aria-label={ariaLabel || placeholder || t("Search")}
|
||||||
leftSection={<IconSearch size={16} />}
|
leftSection={<IconSearch size={16} />}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.currentTarget.value)}
|
onChange={(e) => setValue(e.currentTarget.value)}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ActionIcon, rem } from "@mantine/core";
|
import { ThemeIcon } from "@mantine/core";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { IconUsersGroup } from "@tabler/icons-react";
|
import { IconUsersGroup } from "@tabler/icons-react";
|
||||||
|
|
||||||
export function IconGroupCircle() {
|
export function IconGroupCircle() {
|
||||||
return (
|
return (
|
||||||
<ActionIcon variant="light" size="lg" color="gray" radius="xl">
|
<ThemeIcon variant="light" size="lg" color="gray" radius="xl">
|
||||||
<IconUsersGroup stroke={1.5} />
|
<IconUsersGroup stroke={1.5} />
|
||||||
</ActionIcon>
|
</ThemeIcon>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,4 +28,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skipLink {
|
||||||
|
position: fixed;
|
||||||
|
left: 8px;
|
||||||
|
top: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--mantine-color-blue-6);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
z-index: 1000;
|
||||||
|
transform: translateY(-150%);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
transform: translateY(0);
|
||||||
|
outline: 2px solid var(--mantine-color-blue-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AppShell, Container } from "@mantine/core";
|
import { AppShell, Container } from "@mantine/core";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
@@ -23,11 +24,12 @@ export default function GlobalAppShell({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
useTrialEndAction();
|
useTrialEndAction();
|
||||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
const [{ isAsideOpen, tab: asideTab }] = useAtom(asideStateAtom);
|
||||||
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const sidebarRef = useRef(null);
|
const sidebarRef = useRef(null);
|
||||||
@@ -79,7 +81,11 @@ export default function GlobalAppShell({
|
|||||||
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
|
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<>
|
||||||
|
<a href="#main-content" className={classes.skipLink}>
|
||||||
|
{t("Skip to main content")}
|
||||||
|
</a>
|
||||||
|
<AppShell
|
||||||
header={{ height: 45 }}
|
header={{ height: 45 }}
|
||||||
navbar={{
|
navbar={{
|
||||||
width: isSpaceRoute ? sidebarWidth : 300,
|
width: isSpaceRoute ? sidebarWidth : 300,
|
||||||
@@ -105,6 +111,15 @@ export default function GlobalAppShell({
|
|||||||
className={classes.navbar}
|
className={classes.navbar}
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
|
aria-label={
|
||||||
|
isSpaceRoute
|
||||||
|
? t("Space navigation")
|
||||||
|
: isSettingsRoute
|
||||||
|
? t("Settings navigation")
|
||||||
|
: isAiRoute
|
||||||
|
? t("AI navigation")
|
||||||
|
: t("Main navigation")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isSpaceRoute && (
|
{isSpaceRoute && (
|
||||||
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||||
@@ -114,7 +129,7 @@ export default function GlobalAppShell({
|
|||||||
{isAiRoute && <AiChatSidebar />}
|
{isAiRoute && <AiChatSidebar />}
|
||||||
{showGlobalSidebar && <GlobalSidebar />}
|
{showGlobalSidebar && <GlobalSidebar />}
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
<AppShell.Main>
|
<AppShell.Main id="main-content">
|
||||||
{isSettingsRoute ? (
|
{isSettingsRoute ? (
|
||||||
<Container size={900} pb={80}>
|
<Container size={900} pb={80}>
|
||||||
{children}
|
{children}
|
||||||
@@ -125,10 +140,24 @@ export default function GlobalAppShell({
|
|||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
|
|
||||||
{isPageRoute && (
|
{isPageRoute && (
|
||||||
<AppShell.Aside className={classes.aside} p="md" withBorder={false}>
|
<AppShell.Aside
|
||||||
|
className={classes.aside}
|
||||||
|
p="md"
|
||||||
|
withBorder={false}
|
||||||
|
aria-label={
|
||||||
|
asideTab === "comments"
|
||||||
|
? t("Comments")
|
||||||
|
: asideTab === "toc"
|
||||||
|
? t("Table of contents")
|
||||||
|
: asideTab === "chat"
|
||||||
|
? t("AI Chat")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<Aside />
|
<Aside />
|
||||||
</AppShell.Aside>
|
</AppShell.Aside>
|
||||||
)}
|
)}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
.sectionHeader {
|
.sectionHeader {
|
||||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||||
font-size: var(--mantine-font-size-xs);
|
font-size: var(--mantine-font-size-xs);
|
||||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
|
color: var(--mantine-color-dimmed);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ScrollArea, Text, Divider, Modal } from "@mantine/core";
|
import { ScrollArea, Text, Divider, Modal, UnstyledButton } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconHome,
|
IconHome,
|
||||||
IconClock,
|
IconClock,
|
||||||
@@ -119,17 +119,13 @@ export default function GlobalSidebar() {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<div className={classes.bottomSection}>
|
<div className={classes.bottomSection}>
|
||||||
<a
|
<UnstyledButton
|
||||||
className={classes.link}
|
className={classes.link}
|
||||||
onClick={(e) => {
|
onClick={openInvite}
|
||||||
e.preventDefault();
|
|
||||||
openInvite();
|
|
||||||
}}
|
|
||||||
href="#"
|
|
||||||
>
|
>
|
||||||
<IconUserPlus className={classes.linkIcon} stroke={2} />
|
<IconUserPlus className={classes.linkIcon} stroke={2} />
|
||||||
<span>{t("Invite People")}</span>
|
<span>{t("Invite People")}</span>
|
||||||
</a>
|
</UnstyledButton>
|
||||||
<Link
|
<Link
|
||||||
className={classes.link}
|
className={classes.link}
|
||||||
data-active={active.startsWith("/settings") || undefined}
|
data-active={active.startsWith("/settings") || undefined}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function AppVersion() {
|
|||||||
>
|
>
|
||||||
<Indicator
|
<Indicator
|
||||||
label={t("New update")}
|
label={t("New update")}
|
||||||
color="gray"
|
color="dark"
|
||||||
inline
|
inline
|
||||||
size={16}
|
size={16}
|
||||||
position="middle-end"
|
position="middle-end"
|
||||||
|
|||||||
@@ -230,32 +230,6 @@ export default function SettingsSidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isDisabled = isItemDisabled(item);
|
const isDisabled = isItemDisabled(item);
|
||||||
const linkElement = (
|
|
||||||
<Link
|
|
||||||
onMouseEnter={!isDisabled ? prefetchHandler : undefined}
|
|
||||||
className={classes.link}
|
|
||||||
data-active={active.startsWith(item.path) || undefined}
|
|
||||||
data-disabled={isDisabled || undefined}
|
|
||||||
key={item.label}
|
|
||||||
to={isDisabled ? "#" : item.path}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (isDisabled) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mobileSidebarOpened) {
|
|
||||||
toggleMobileSidebar();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
opacity: isDisabled ? 0.5 : 1,
|
|
||||||
cursor: isDisabled ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<item.icon className={classes.linkIcon} stroke={2} />
|
|
||||||
<span>{t(item.label)}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isDisabled) {
|
if (isDisabled) {
|
||||||
return (
|
return (
|
||||||
@@ -265,12 +239,41 @@ export default function SettingsSidebar() {
|
|||||||
position="right"
|
position="right"
|
||||||
withArrow
|
withArrow
|
||||||
>
|
>
|
||||||
{linkElement}
|
<span
|
||||||
|
className={classes.link}
|
||||||
|
data-disabled
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
tabIndex={0}
|
||||||
|
style={{
|
||||||
|
opacity: 0.5,
|
||||||
|
cursor: "not-allowed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t(item.label)}</span>
|
||||||
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return linkElement;
|
return (
|
||||||
|
<Link
|
||||||
|
onMouseEnter={prefetchHandler}
|
||||||
|
className={classes.link}
|
||||||
|
data-active={active.startsWith(item.path) || undefined}
|
||||||
|
key={item.label}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => {
|
||||||
|
if (mobileSidebarOpened) {
|
||||||
|
toggleMobileSidebar();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t(item.label)}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -288,7 +291,7 @@ export default function SettingsSidebar() {
|
|||||||
}}
|
}}
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
c="gray"
|
c="gray"
|
||||||
aria-label="Back"
|
aria-label={t("Back")}
|
||||||
>
|
>
|
||||||
<IconArrowLeft stroke={2} />
|
<IconArrowLeft stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Avatar } from "@mantine/core";
|
import { Avatar, MantineColor } from "@mantine/core";
|
||||||
import { getAvatarUrl } from "@/lib/config.ts";
|
import { getAvatarUrl } from "@/lib/config.ts";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
@@ -16,11 +16,39 @@ interface CustomAvatarProps {
|
|||||||
mt?: string | number;
|
mt?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `color.shade` pairs whose filled background meets WCAG AA (4.5:1) against
|
||||||
|
// white text. Avoids lime/yellow/green/orange — even their dark shades have
|
||||||
|
// weak white-text contrast.
|
||||||
|
const SAFE_INITIALS_COLORS: MantineColor[] = [
|
||||||
|
"blue.8",
|
||||||
|
"cyan.9",
|
||||||
|
"grape.7",
|
||||||
|
"indigo.7",
|
||||||
|
"pink.8",
|
||||||
|
"red.8",
|
||||||
|
"violet.7",
|
||||||
|
];
|
||||||
|
|
||||||
|
function hashName(input: string) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
hash = (hash << 5) - hash + input.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickInitialsColor(name: string) {
|
||||||
|
return SAFE_INITIALS_COLORS[hashName(name) % SAFE_INITIALS_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
export const CustomAvatar = React.forwardRef<
|
export const CustomAvatar = React.forwardRef<
|
||||||
HTMLInputElement,
|
HTMLInputElement,
|
||||||
CustomAvatarProps
|
CustomAvatarProps
|
||||||
>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => {
|
>(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => {
|
||||||
const avatarLink = getAvatarUrl(avatarUrl, type);
|
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||||
|
const resolvedColor =
|
||||||
|
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -28,7 +56,7 @@ export const CustomAvatar = React.forwardRef<
|
|||||||
src={avatarLink}
|
src={avatarLink}
|
||||||
name={name}
|
name={name}
|
||||||
alt={name}
|
alt={name}
|
||||||
color="initials"
|
color={resolvedColor}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -74,7 +74,18 @@ export function PageChildren({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{hasNextPage && (
|
{hasNextPage && (
|
||||||
<div className={classes.loadMore} onClick={() => fetchNextPage()}>
|
<div
|
||||||
|
className={classes.loadMore}
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
{t("Load more")}
|
{t("Load more")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -70,11 +70,14 @@ function EmojiPicker({
|
|||||||
closeOnEscape={true}
|
closeOnEscape={true}
|
||||||
>
|
>
|
||||||
<Popover.Target ref={setTarget}>
|
<Popover.Target ref={setTarget}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
c={actionIconProps?.c || "gray"}
|
c={actionIconProps?.c || "gray"}
|
||||||
variant={actionIconProps?.variant || "transparent"}
|
variant={actionIconProps?.variant || "transparent"}
|
||||||
size={actionIconProps?.size}
|
size={actionIconProps?.size}
|
||||||
onClick={handlers.toggle}
|
onClick={handlers.toggle}
|
||||||
|
aria-label={t("Pick emoji")}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={opened}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export default function AiChatSidebarItem({
|
|||||||
size="xs"
|
size="xs"
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={(e) => e.preventDefault()}
|
onClick={(e) => e.preventDefault()}
|
||||||
|
aria-label={t("Chat menu")}
|
||||||
>
|
>
|
||||||
<IconDots size={14} />
|
<IconDots size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function ApiKeyTable({
|
|||||||
<Table.Th>{t("Last used")}</Table.Th>
|
<Table.Th>{t("Last used")}</Table.Th>
|
||||||
<Table.Th>{t("Expires")}</Table.Th>
|
<Table.Th>{t("Expires")}</Table.Th>
|
||||||
<Table.Th>{t("Created")}</Table.Th>
|
<Table.Th>{t("Created")}</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
<Table.Th aria-label={t("Action")} />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
@@ -106,7 +106,11 @@ export function ApiKeyTable({
|
|||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Menu position="bottom-end" withinPortal>
|
<Menu position="bottom-end" withinPortal>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="subtle" color="gray">
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={t("API key menu")}
|
||||||
|
>
|
||||||
<IconDots size={16} />
|
<IconDots size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { ActionIcon, Group, Menu, Modal, Text, Tooltip } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
Modal,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconRosetteDiscountCheckFilled,
|
IconRosetteDiscountCheckFilled,
|
||||||
@@ -38,6 +46,7 @@ export function PageVerificationModal({
|
|||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
aria-label={status === "none" ? t("Set up verification") : t("Verify page")}
|
||||||
title={
|
title={
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<IconShieldCheck
|
<IconShieldCheck
|
||||||
@@ -97,9 +106,9 @@ export function PageVerificationBadge({
|
|||||||
withArrow
|
withArrow
|
||||||
openDelay={250}
|
openDelay={250}
|
||||||
>
|
>
|
||||||
<ActionIcon variant="subtle" color="gray">
|
<ThemeIcon variant="subtle" color="gray">
|
||||||
<IconShieldCheck size={20} stroke={1.5} />
|
<IconShieldCheck size={20} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ThemeIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,7 +139,12 @@ export function PageVerificationBadge({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : !readOnly ? (
|
) : !readOnly ? (
|
||||||
<Tooltip label={t("Set up verification")} withArrow openDelay={250}>
|
<Tooltip label={t("Set up verification")} withArrow openDelay={250}>
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={open}>
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={t("Set up verification")}
|
||||||
|
onClick={open}
|
||||||
|
>
|
||||||
<IconShieldCheck size={20} stroke={1.5} />
|
<IconShieldCheck size={20} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function ScimTokenTable({
|
|||||||
<Table.Th>{t("Created by")}</Table.Th>
|
<Table.Th>{t("Created by")}</Table.Th>
|
||||||
<Table.Th>{t("Last used")}</Table.Th>
|
<Table.Th>{t("Last used")}</Table.Th>
|
||||||
<Table.Th>{t("Created")}</Table.Th>
|
<Table.Th>{t("Created")}</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
<Table.Th aria-label={t("Action")} />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export default function SsoProviderList() {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
|
aria-label={t("Edit {{name}}", { name: provider.name })}
|
||||||
onClick={() => handleEdit(provider)}
|
onClick={() => handleEdit(provider)}
|
||||||
>
|
>
|
||||||
<IconPencil size={16} />
|
<IconPencil size={16} />
|
||||||
@@ -152,7 +153,13 @@ export default function SsoProviderList() {
|
|||||||
withinPortal
|
withinPortal
|
||||||
>
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="subtle" color="gray">
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={t("More actions for {{name}}", {
|
||||||
|
name: provider.name,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<IconDots size={16} />
|
<IconDots size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export default function TemplateCard({
|
|||||||
color="gray"
|
color="gray"
|
||||||
className={classes.menuTarget}
|
className={classes.menuTarget}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={t("Template menu")}
|
||||||
>
|
>
|
||||||
<IconDots size={16} />
|
<IconDots size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function TemplatePreviewModal({
|
|||||||
const title = template?.title || t("Untitled");
|
const title = template?.title || t("Untitled");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal.Root size={1200} opened={opened} onClose={onClose}>
|
<Modal.Root size={1200} opened={opened} onClose={onClose} aria-label={title}>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
|||||||
withCloseButton
|
withCloseButton
|
||||||
withBorder
|
withBorder
|
||||||
data-comment-dialog
|
data-comment-dialog
|
||||||
|
aria-label={t("Add comment")}
|
||||||
>
|
>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
@@ -173,6 +173,15 @@ function CommentListItem({
|
|||||||
<Box
|
<Box
|
||||||
className={classes.textSelection}
|
className={classes.textSelection}
|
||||||
onClick={() => handleCommentClick(comment)}
|
onClick={() => handleCommentClick(comment)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCommentClick(comment);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={t("Jump to comment selection")}
|
||||||
>
|
>
|
||||||
<Text size="sm">{comment?.selection}</Text>
|
<Text size="sm">{comment?.selection}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ function CommentMenu({
|
|||||||
return (
|
return (
|
||||||
<Menu shadow="md" width={200}>
|
<Menu shadow="md" width={200}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="default" style={{ border: "none" }}>
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
style={{ border: "none" }}
|
||||||
|
aria-label={t("Comment menu")}
|
||||||
|
>
|
||||||
<IconDots size={20} stroke={2} />
|
<IconDots size={20} stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export default function AudioView(props: NodeViewProps) {
|
|||||||
preload="metadata"
|
preload="metadata"
|
||||||
controls
|
controls
|
||||||
src={safeSrc}
|
src={safeSrc}
|
||||||
|
aria-label={placeholder?.name || t("Audio")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!safeSrc && previewSrc && (
|
{!safeSrc && previewSrc && (
|
||||||
@@ -45,6 +46,7 @@ export default function AudioView(props: NodeViewProps) {
|
|||||||
preload="metadata"
|
preload="metadata"
|
||||||
controls
|
controls
|
||||||
src={previewSrc}
|
src={previewSrc}
|
||||||
|
aria-label={placeholder?.name || t("Audio")}
|
||||||
/>
|
/>
|
||||||
<Loader size={20} pos="absolute" top={6} right={6} />
|
<Loader size={20} pos="absolute" top={6} right={6} />
|
||||||
</Group>
|
</Group>
|
||||||
@@ -60,7 +62,7 @@ export default function AudioView(props: NodeViewProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
{!safeSrc && !previewSrc && !placeholder && (
|
{!safeSrc && !previewSrc && !placeholder && (
|
||||||
<audio className={classes.audio} controls />
|
<audio className={classes.audio} controls aria-label={t("Audio")} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
|
|||||||
@@ -172,6 +172,9 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: rem(16),
|
fontSize: rem(16),
|
||||||
}}
|
}}
|
||||||
|
aria-label={t("Text color")}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
A
|
A
|
||||||
</Button>
|
</Button>
|
||||||
@@ -186,20 +189,32 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
{t("Text color")}
|
{t("Text color")}
|
||||||
</Text>
|
</Text>
|
||||||
<SimpleGrid cols={5} spacing="xs">
|
<SimpleGrid cols={5} spacing="xs">
|
||||||
{TEXT_COLORS.map(({ name, color }, index) => (
|
{TEXT_COLORS.map(({ name, color }, index) => {
|
||||||
|
const applyTextColor = () => {
|
||||||
|
if (name === "Default") {
|
||||||
|
editor.commands.unsetColor();
|
||||||
|
} else {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.setColor(color || "")
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
return (
|
||||||
<Tooltip key={index} label={t(name)} withArrow>
|
<Tooltip key={index} label={t(name)} withArrow>
|
||||||
<Box
|
<Box
|
||||||
onClick={() => {
|
role="button"
|
||||||
if (name === "Default") {
|
tabIndex={0}
|
||||||
editor.commands.unsetColor();
|
aria-label={t(name)}
|
||||||
} else {
|
aria-pressed={!!editorState[`text_${color}`]}
|
||||||
editor
|
onClick={applyTextColor}
|
||||||
.chain()
|
onKeyDown={(e) => {
|
||||||
.focus()
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
.setColor(color || "")
|
e.preventDefault();
|
||||||
.run();
|
applyTextColor();
|
||||||
}
|
}
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: rem(28),
|
width: rem(28),
|
||||||
@@ -221,7 +236,8 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
A
|
A
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -230,23 +246,35 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
{t("Highlight color")}
|
{t("Highlight color")}
|
||||||
</Text>
|
</Text>
|
||||||
<SimpleGrid cols={5} spacing="xs">
|
<SimpleGrid cols={5} spacing="xs">
|
||||||
{HIGHLIGHT_COLORS.map(({ name, color }, index) => (
|
{HIGHLIGHT_COLORS.map(({ name, color }, index) => {
|
||||||
|
const applyHighlight = () => {
|
||||||
|
if (name === "Default") {
|
||||||
|
editor.commands.unsetHighlight();
|
||||||
|
} else {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.toggleMark("highlight", {
|
||||||
|
color: color || "",
|
||||||
|
colorName: name.toLowerCase() || "",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
return (
|
||||||
<Tooltip key={index} label={t(name)} withArrow>
|
<Tooltip key={index} label={t(name)} withArrow>
|
||||||
<Box
|
<Box
|
||||||
onClick={() => {
|
role="button"
|
||||||
if (name === "Default") {
|
tabIndex={0}
|
||||||
editor.commands.unsetHighlight();
|
aria-label={t(name)}
|
||||||
} else {
|
aria-pressed={!!editorState[`highlight_${color}`]}
|
||||||
editor
|
onClick={applyHighlight}
|
||||||
.chain()
|
onKeyDown={(e) => {
|
||||||
.focus()
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
.toggleMark("highlight", {
|
e.preventDefault();
|
||||||
color: color || "",
|
applyHighlight();
|
||||||
colorName: name.toLowerCase() || "",
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: rem(28),
|
width: rem(28),
|
||||||
@@ -274,7 +302,8 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconList,
|
IconList,
|
||||||
IconListNumbers,
|
IconListNumbers,
|
||||||
IconQuote,
|
|
||||||
IconTypography,
|
IconTypography,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core";
|
import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core";
|
||||||
@@ -60,7 +59,6 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
isCodeBlock: ctx.editor.isActive("codeBlock"),
|
isCodeBlock: ctx.editor.isActive("codeBlock"),
|
||||||
isCallout: ctx.editor.isActive("callout"),
|
isCallout: ctx.editor.isActive("callout"),
|
||||||
isDetails: ctx.editor.isActive("details"),
|
isDetails: ctx.editor.isActive("details"),
|
||||||
isTransclusion: ctx.editor.isActive("transclusion"),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -142,12 +140,6 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
command: () => editor.chain().focus().setDetails().run(),
|
command: () => editor.chain().focus().setDetails().run(),
|
||||||
isActive: () => editorState?.isDetails,
|
isActive: () => editorState?.isDetails,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Sync block",
|
|
||||||
icon: IconQuote,
|
|
||||||
command: () => editor.chain().focus().toggleTransclusion().run(),
|
|
||||||
isActive: () => editorState?.isTransclusion,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
||||||
@@ -165,6 +157,9 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
radius="0"
|
radius="0"
|
||||||
rightSection={<IconChevronDown size={16} />}
|
rightSection={<IconChevronDown size={16} />}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
aria-label={t("Turn into")}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
{t(activeItem?.name)}
|
{t(activeItem?.name)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
|
|||||||
radius="0"
|
radius="0"
|
||||||
rightSection={<IconChevronDown size={16} />}
|
rightSection={<IconChevronDown size={16} />}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
aria-label={t("Text align")}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
|
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -137,7 +137,13 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper data-drag-handle>
|
<NodeViewWrapper data-drag-handle>
|
||||||
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
|
<Modal.Root
|
||||||
|
opened={opened}
|
||||||
|
onClose={handleClose}
|
||||||
|
fullScreen
|
||||||
|
closeOnEscape={false}
|
||||||
|
aria-label={t("Diagram editor")}
|
||||||
|
>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
<Modal.Body pos="relative">
|
<Modal.Body pos="relative">
|
||||||
|
|||||||
@@ -107,7 +107,17 @@ const EmojiList = ({
|
|||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
|
|
||||||
return items.length > 0 || isLoading ? (
|
return items.length > 0 || isLoading ? (
|
||||||
<Paper id="emoji-command" p="0" shadow="md" withBorder>
|
<Paper
|
||||||
|
id="emoji-command"
|
||||||
|
p="0"
|
||||||
|
shadow="md"
|
||||||
|
withBorder
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Emoji results"
|
||||||
|
aria-activedescendant={
|
||||||
|
items.length > 0 ? `emoji-command-option-${selectedIndex}` : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{isLoading && <Loader m="xs" color="blue" type="dots" />}
|
{isLoading && <Loader m="xs" color="blue" type="dots" />}
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<ScrollArea.Autosize
|
<ScrollArea.Autosize
|
||||||
@@ -120,6 +130,10 @@ const EmojiList = ({
|
|||||||
{items.map((item, index: number) => (
|
{items.map((item, index: number) => (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
data-item-index={index}
|
data-item-index={index}
|
||||||
|
id={`emoji-command-option-${index}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === selectedIndex}
|
||||||
|
aria-label={item.id}
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={clsx(classes.menuBtn, {
|
className={clsx(classes.menuBtn, {
|
||||||
|
|||||||
@@ -102,6 +102,14 @@ export const LinkEditorPanel = ({
|
|||||||
leftSection={<IconLink size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />}
|
leftSection={<IconLink size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />}
|
||||||
classNames={{ input: classes.linkInput }}
|
classNames={{ input: classes.linkInput }}
|
||||||
placeholder={t("Paste link or search pages")}
|
placeholder={t("Paste link or search pages")}
|
||||||
|
aria-label={t("Paste link or search pages")}
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={showDropdown}
|
||||||
|
aria-controls="link-editor-results"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-activedescendant={
|
||||||
|
showDropdown ? `link-editor-option-${selectedIndex}` : undefined
|
||||||
|
}
|
||||||
value={state.url}
|
value={state.url}
|
||||||
onChange={state.onChange}
|
onChange={state.onChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -125,10 +133,16 @@ export const LinkEditorPanel = ({
|
|||||||
scrollbarSize={6}
|
scrollbarSize={6}
|
||||||
mt={state.url.length > 0 ? 8 : 0}
|
mt={state.url.length > 0 ? 8 : 0}
|
||||||
styles={{ content: { minWidth: 0 } }}
|
styles={{ content: { minWidth: 0 } }}
|
||||||
|
id="link-editor-results"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={t("Link suggestions")}
|
||||||
>
|
>
|
||||||
{showUrlItem && (
|
{showUrlItem && (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
data-item-index={0}
|
data-item-index={0}
|
||||||
|
id="link-editor-option-0"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedIndex === 0}
|
||||||
onClick={() => onSetLink(state.url, false)}
|
onClick={() => onSetLink(state.url, false)}
|
||||||
className={clsx(classes.searchItem, {
|
className={clsx(classes.searchItem, {
|
||||||
[classes.selectedSearchItem]: selectedIndex === 0,
|
[classes.selectedSearchItem]: selectedIndex === 0,
|
||||||
@@ -156,6 +170,9 @@ export const LinkEditorPanel = ({
|
|||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
data-item-index={itemIndex}
|
data-item-index={itemIndex}
|
||||||
|
id={`link-editor-option-${itemIndex}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={itemIndex === selectedIndex}
|
||||||
key={page.id || index}
|
key={page.id || index}
|
||||||
onClick={() => selectPage(page)}
|
onClick={() => selectPage(page)}
|
||||||
className={clsx(classes.searchItem, {
|
className={clsx(classes.searchItem, {
|
||||||
|
|||||||
@@ -287,7 +287,16 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper id="mention" shadow="md" withBorder radius="md" py={6}>
|
<Paper
|
||||||
|
id="mention"
|
||||||
|
shadow="md"
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
py={6}
|
||||||
|
role="listbox"
|
||||||
|
aria-label={t("Mention suggestions")}
|
||||||
|
aria-activedescendant={`mention-option-${selectedIndex}`}
|
||||||
|
>
|
||||||
<ScrollArea.Autosize
|
<ScrollArea.Autosize
|
||||||
viewportRef={viewportRef}
|
viewportRef={viewportRef}
|
||||||
mah={350}
|
mah={350}
|
||||||
@@ -301,7 +310,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
if (item.entityType === "header") {
|
if (item.entityType === "header") {
|
||||||
const isFirst = index === 0;
|
const isFirst = index === 0;
|
||||||
return (
|
return (
|
||||||
<div key={`${item.label}-${index}`}>
|
<div key={`${item.label}-${index}`} role="presentation">
|
||||||
{!isFirst && <Divider my={6} />}
|
{!isFirst && <Divider my={6} />}
|
||||||
<Text
|
<Text
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
@@ -322,6 +331,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
data-item-index={index}
|
data-item-index={index}
|
||||||
key={index}
|
key={index}
|
||||||
|
id={`mention-option-${index}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === selectedIndex}
|
||||||
onClick={() => selectItem(index)}
|
onClick={() => selectItem(index)}
|
||||||
className={clsx(classes.menuBtn, {
|
className={clsx(classes.menuBtn, {
|
||||||
[classes.selectedItem]: index === selectedIndex,
|
[classes.selectedItem]: index === selectedIndex,
|
||||||
@@ -348,6 +360,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
data-item-index={index}
|
data-item-index={index}
|
||||||
key={index}
|
key={index}
|
||||||
|
id={`mention-option-${index}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === selectedIndex}
|
||||||
onClick={() => selectItem(index)}
|
onClick={() => selectItem(index)}
|
||||||
className={clsx(classes.menuBtn, {
|
className={clsx(classes.menuBtn, {
|
||||||
[classes.selectedItem]: index === selectedIndex,
|
[classes.selectedItem]: index === selectedIndex,
|
||||||
@@ -358,7 +373,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
component="div"
|
component="div"
|
||||||
aria-label={item.label}
|
aria-hidden="true"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
@@ -390,6 +405,11 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
{(hasUsers || hasPages) && <Divider my={6} />}
|
{(hasUsers || hasPages) && <Divider my={6} />}
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
data-item-index={renderItems.indexOf(createPageItemData)}
|
data-item-index={renderItems.indexOf(createPageItemData)}
|
||||||
|
id={`mention-option-${renderItems.indexOf(createPageItemData)}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={
|
||||||
|
renderItems.indexOf(createPageItemData) === selectedIndex
|
||||||
|
}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
selectItem(renderItems.indexOf(createPageItemData))
|
selectItem(renderItems.indexOf(createPageItemData))
|
||||||
}
|
}
|
||||||
@@ -405,6 +425,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
component="div"
|
component="div"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<IconPlus size={16} stroke={1.5} />
|
<IconPlus size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -92,7 +92,20 @@ export default function PdfView(props: NodeViewProps) {
|
|||||||
if (hasError) {
|
if (hasError) {
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper data-drag-handle>
|
<NodeViewWrapper data-drag-handle>
|
||||||
<div data-pdf-error className={clsx(classes.pdfError, { "ProseMirror-selectednode": selected })} onClick={handleSelect}>
|
<div
|
||||||
|
data-pdf-error
|
||||||
|
className={clsx(classes.pdfError, { "ProseMirror-selectednode": selected })}
|
||||||
|
onClick={handleSelect}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={t("Failed to load PDF")}
|
||||||
|
>
|
||||||
<IconFileTypePdf size={32} stroke={1.5} />
|
<IconFileTypePdf size={32} stroke={1.5} />
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("Failed to load PDF")}
|
{t("Failed to load PDF")}
|
||||||
|
|||||||
+25
-3
@@ -187,12 +187,14 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
|||||||
position={{ top: 90, right: 50 }}
|
position={{ top: 90, right: 50 }}
|
||||||
withBorder
|
withBorder
|
||||||
transitionProps={{ transition: "slide-down" }}
|
transitionProps={{ transition: "slide-down" }}
|
||||||
|
aria-label={t("Find and replace")}
|
||||||
>
|
>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Flex align="center" gap="xs">
|
<Flex align="center" gap="xs">
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={t("Find")}
|
placeholder={t("Find")}
|
||||||
|
aria-label={t("Find")}
|
||||||
leftSection={<IconSearch size={16} />}
|
leftSection={<IconSearch size={16} />}
|
||||||
rightSection={
|
rightSection={
|
||||||
<Text size="xs" ta="right">
|
<Text size="xs" ta="right">
|
||||||
@@ -217,7 +219,12 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
|||||||
|
|
||||||
<ActionIcon.Group>
|
<ActionIcon.Group>
|
||||||
<Tooltip label={t("Previous match (Shift+Enter)")}>
|
<Tooltip label={t("Previous match (Shift+Enter)")}>
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={previous}>
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={previous}
|
||||||
|
aria-label={t("Previous match (Shift+Enter)")}
|
||||||
|
>
|
||||||
<IconArrowNarrowUp
|
<IconArrowNarrowUp
|
||||||
style={{ width: "70%", height: "70%" }}
|
style={{ width: "70%", height: "70%" }}
|
||||||
stroke={1.5}
|
stroke={1.5}
|
||||||
@@ -225,7 +232,12 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={t("Next match (Enter)")}>
|
<Tooltip label={t("Next match (Enter)")}>
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={next}>
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={next}
|
||||||
|
aria-label={t("Next match (Enter)")}
|
||||||
|
>
|
||||||
<IconArrowNarrowDown
|
<IconArrowNarrowDown
|
||||||
style={{ width: "70%", height: "70%" }}
|
style={{ width: "70%", height: "70%" }}
|
||||||
stroke={1.5}
|
stroke={1.5}
|
||||||
@@ -237,6 +249,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color={caseSensitive.color}
|
color={caseSensitive.color}
|
||||||
onClick={() => caseSensitiveToggle()}
|
onClick={() => caseSensitiveToggle()}
|
||||||
|
aria-label={t("Match case (Alt+C)")}
|
||||||
|
aria-pressed={caseSensitive.isCaseSensitive}
|
||||||
>
|
>
|
||||||
<IconLetterCase
|
<IconLetterCase
|
||||||
style={{ width: "70%", height: "70%" }}
|
style={{ width: "70%", height: "70%" }}
|
||||||
@@ -250,6 +264,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color={replaceButton.color}
|
color={replaceButton.color}
|
||||||
onClick={() => replaceButtonToggle()}
|
onClick={() => replaceButtonToggle()}
|
||||||
|
aria-label={t("Replace")}
|
||||||
|
aria-pressed={replaceButton.isReplaceShow}
|
||||||
>
|
>
|
||||||
<IconReplace
|
<IconReplace
|
||||||
style={{ width: "70%", height: "70%" }}
|
style={{ width: "70%", height: "70%" }}
|
||||||
@@ -259,7 +275,12 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Tooltip label={t("Close (Escape)")}>
|
<Tooltip label={t("Close (Escape)")}>
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}>
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={closeDialog}
|
||||||
|
aria-label={t("Close (Escape)")}
|
||||||
|
>
|
||||||
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
|
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -269,6 +290,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
|||||||
<Flex align="center" gap="xs">
|
<Flex align="center" gap="xs">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("Replace")}
|
placeholder={t("Replace")}
|
||||||
|
aria-label={t("Replace")}
|
||||||
leftSection={<IconReplace size={16} />}
|
leftSection={<IconReplace size={16} />}
|
||||||
rightSection={<div></div>}
|
rightSection={<div></div>}
|
||||||
rightSectionPointerEvents="all"
|
rightSectionPointerEvents="all"
|
||||||
|
|||||||
@@ -86,7 +86,15 @@ const CommandList = ({
|
|||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
|
|
||||||
return flatItems.length > 0 ? (
|
return flatItems.length > 0 ? (
|
||||||
<Paper id="slash-command" shadow="md" p="xs" withBorder>
|
<Paper
|
||||||
|
id="slash-command"
|
||||||
|
shadow="md"
|
||||||
|
p="xs"
|
||||||
|
withBorder
|
||||||
|
role="listbox"
|
||||||
|
aria-label={t("Slash commands")}
|
||||||
|
aria-activedescendant={`slash-command-option-${selectedIndex}`}
|
||||||
|
>
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
viewportRef={viewportRef}
|
viewportRef={viewportRef}
|
||||||
h={350}
|
h={350}
|
||||||
@@ -94,22 +102,30 @@ const CommandList = ({
|
|||||||
scrollbarSize={8}
|
scrollbarSize={8}
|
||||||
overscrollBehavior="contain"
|
overscrollBehavior="contain"
|
||||||
>
|
>
|
||||||
{Object.entries(items).map(([category, categoryItems]) => (
|
{(() => {
|
||||||
<div key={category}>
|
let flatIndex = -1;
|
||||||
|
return Object.entries(items).map(([category, categoryItems]) => (
|
||||||
|
<div key={category} role="group" aria-label={category}>
|
||||||
<Text c="dimmed" mb={4} fw={500} tt="capitalize">
|
<Text c="dimmed" mb={4} fw={500} tt="capitalize">
|
||||||
{category}
|
{category}
|
||||||
</Text>
|
</Text>
|
||||||
{categoryItems.map((item: SlashMenuItemType, index: number) => (
|
{categoryItems.map((item: SlashMenuItemType) => {
|
||||||
|
flatIndex += 1;
|
||||||
|
const itemIndex = flatIndex;
|
||||||
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
data-item-index={index}
|
data-item-index={itemIndex}
|
||||||
key={index}
|
key={itemIndex}
|
||||||
onClick={() => selectItem(index)}
|
id={`slash-command-option-${itemIndex}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={itemIndex === selectedIndex}
|
||||||
|
onClick={() => selectItem(itemIndex)}
|
||||||
className={clsx(classes.menuBtn, {
|
className={clsx(classes.menuBtn, {
|
||||||
[classes.selectedItem]: index === selectedIndex,
|
[classes.selectedItem]: itemIndex === selectedIndex,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Group>
|
<Group>
|
||||||
<ActionIcon variant="default" component="div">
|
<ActionIcon variant="default" component="div" aria-hidden="true">
|
||||||
<item.icon size={18} />
|
<item.icon size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
@@ -124,9 +140,11 @@ const CommandList = ({
|
|||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
));
|
||||||
|
})()}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Paper>
|
</Paper>
|
||||||
) : null;
|
) : null;
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
IconColumns3,
|
IconColumns3,
|
||||||
IconColumns2,
|
IconColumns2,
|
||||||
IconTag,
|
IconTag,
|
||||||
IconRotate2,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
@@ -478,23 +477,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Sync block",
|
|
||||||
description: "Create a block that stays in sync across pages.",
|
|
||||||
searchTerms: [
|
|
||||||
"sync",
|
|
||||||
"synced",
|
|
||||||
"sync block",
|
|
||||||
"excerpt",
|
|
||||||
"transclusion",
|
|
||||||
"reusable",
|
|
||||||
"snippet",
|
|
||||||
],
|
|
||||||
icon: IconRotate2,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).insertTransclusion().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "2 Columns",
|
title: "2 Columns",
|
||||||
description: "Split content into two columns.",
|
description: "Split content into two columns.",
|
||||||
|
|||||||
@@ -92,8 +92,17 @@ export default function StatusView(props: NodeViewProps) {
|
|||||||
colorClassMap[color],
|
colorClassMap[color],
|
||||||
)}
|
)}
|
||||||
onClick={() => isEditable && setOpened(true)}
|
onClick={() => isEditable && setOpened(true)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isEditable && (e.key === "Enter" || e.key === " ")) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpened(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
aria-label={text || "SET STATUS"}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={opened}
|
||||||
>
|
>
|
||||||
{text || "SET STATUS"}
|
{text || "SET STATUS"}
|
||||||
</span>
|
</span>
|
||||||
@@ -127,6 +136,16 @@ export default function StatusView(props: NodeViewProps) {
|
|||||||
)}
|
)}
|
||||||
style={{ backgroundColor: bg }}
|
style={{ backgroundColor: bg }}
|
||||||
onClick={() => handleColorChange(name)}
|
onClick={() => handleColorChange(name)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleColorChange(name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={name}
|
||||||
|
aria-pressed={color === name}
|
||||||
>
|
>
|
||||||
{color === name && <IconCheck size={14} />}
|
{color === name && <IconCheck size={14} />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import classes from "./transclusion.module.css";
|
|
||||||
|
|
||||||
export default function ErrorPlaceholder() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<div className={classes.placeholder}>
|
|
||||||
<IconAlertTriangle
|
|
||||||
size={20}
|
|
||||||
stroke={1.5}
|
|
||||||
className={classes.placeholderIcon}
|
|
||||||
/>
|
|
||||||
<div className={classes.placeholderTitle}>
|
|
||||||
{t("Failed to load transclusion")}
|
|
||||||
</div>
|
|
||||||
<div className={classes.placeholderSubtext}>
|
|
||||||
{t("An error occurred while rendering this reference")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { IconEyeOff } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import classes from "./transclusion.module.css";
|
|
||||||
|
|
||||||
export default function NoAccessPlaceholder() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<div className={classes.placeholder}>
|
|
||||||
<IconEyeOff size={20} stroke={1.5} className={classes.placeholderIcon} />
|
|
||||||
<div className={classes.placeholderTitle}>{t("No access")}</div>
|
|
||||||
<div className={classes.placeholderSubtext}>
|
|
||||||
{t("You don't have access to this content")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { IconQuestionMark } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import classes from "./transclusion.module.css";
|
|
||||||
|
|
||||||
export default function NotFoundPlaceholder() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<div className={classes.placeholder}>
|
|
||||||
<IconQuestionMark
|
|
||||||
size={20}
|
|
||||||
stroke={1.5}
|
|
||||||
className={classes.placeholderIcon}
|
|
||||||
/>
|
|
||||||
<div className={classes.placeholderTitle}>
|
|
||||||
{t("Synced block unavailable")}
|
|
||||||
</div>
|
|
||||||
<div className={classes.placeholderSubtext}>
|
|
||||||
{t(
|
|
||||||
"The source may have been removed, or embedding it here would create a loop.",
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { EditorProvider } from "@tiptap/react";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
|
||||||
import { UniqueID } from "@docmost/editor-ext";
|
|
||||||
import { TransclusionLookupProvider } from "./transclusion-lookup-context";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
hostPageId: string;
|
|
||||||
content: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TransclusionContent({ hostPageId, content }: Props) {
|
|
||||||
const extensions = useMemo(() => {
|
|
||||||
const filtered = mainExtensions.filter(
|
|
||||||
(e: any) => e.name !== "uniqueID" && e.name !== "globalDragHandle",
|
|
||||||
);
|
|
||||||
return [
|
|
||||||
...filtered,
|
|
||||||
UniqueID.configure({
|
|
||||||
types: ["heading", "paragraph", "transclusion"],
|
|
||||||
updateDocument: false,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Isolate the nested read-only editor's events from the host editor:
|
|
||||||
// - mousedown/click would otherwise make the host node-select the atom
|
|
||||||
// wrapper, blocking native text selection inside.
|
|
||||||
// - dragstart/dragover/drop would otherwise let the host treat events
|
|
||||||
// inside the nested view as drops on the host, duplicating dropped
|
|
||||||
// files at the transclusion's position.
|
|
||||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TransclusionLookupProvider hostPageId={hostPageId}>
|
|
||||||
<div
|
|
||||||
onMouseDown={stop}
|
|
||||||
onClick={stop}
|
|
||||||
onDragStart={stop}
|
|
||||||
onDragOver={stop}
|
|
||||||
onDrop={stop}
|
|
||||||
>
|
|
||||||
<EditorProvider
|
|
||||||
editable={false}
|
|
||||||
immediatelyRender={true}
|
|
||||||
extensions={extensions}
|
|
||||||
content={content as any}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TransclusionLookupProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-198
@@ -1,198 +0,0 @@
|
|||||||
import React, {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { lookupTransclusion } from "@/features/transclusion/services/transclusion-api";
|
|
||||||
import type { TransclusionLookup } from "@/features/transclusion/types/transclusion.types";
|
|
||||||
|
|
||||||
type LookupKey = string; // `${sourcePageId}::${transclusionId}`
|
|
||||||
|
|
||||||
type Subscriber = {
|
|
||||||
key: LookupKey;
|
|
||||||
sourcePageId: string;
|
|
||||||
transclusionId: string;
|
|
||||||
setResult: (r: TransclusionLookup) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ContextValue = {
|
|
||||||
/** Register a subscriber. Returns an unsubscribe function. */
|
|
||||||
subscribe: (s: Subscriber) => () => void;
|
|
||||||
/**
|
|
||||||
* Force a re-fetch of `key` and resolve when the response arrives (or the
|
|
||||||
* request fails). Bypasses the cache and any in-flight de-dup so the user
|
|
||||||
* always sees a fresh server read.
|
|
||||||
*/
|
|
||||||
refresh: (key: LookupKey) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TransclusionLookupContext = createContext<ContextValue | null>(null);
|
|
||||||
|
|
||||||
export function TransclusionLookupProvider({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
/**
|
|
||||||
* Retained for API compatibility with previous callers that passed the
|
|
||||||
* host page id; no longer used internally now that cycle prevention lives
|
|
||||||
* on the server side and lookups are stateless.
|
|
||||||
*/
|
|
||||||
hostPageId?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const subscribersRef = useRef(new Map<LookupKey, Subscriber[]>());
|
|
||||||
const queueRef = useRef(new Set<LookupKey>());
|
|
||||||
const tickRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
// Last looked-up value for each key. Re-subscribers (e.g. when the editor
|
|
||||||
// remounts after switching from static to live) get this immediately
|
|
||||||
// instead of triggering a duplicate fetch.
|
|
||||||
const resultCacheRef = useRef(new Map<LookupKey, TransclusionLookup>());
|
|
||||||
// Keys that are currently in flight in a batch request. A second subscribe
|
|
||||||
// for the same key while the first request is pending is a no-op; the
|
|
||||||
// subscriber is added to subscribersRef and will be notified when the
|
|
||||||
// pending request completes.
|
|
||||||
const inFlightRef = useRef(new Set<LookupKey>());
|
|
||||||
// Resolvers waiting on the next response for a key. Populated by refresh()
|
|
||||||
// so callers can await the fetch round-trip; resolved on success and on
|
|
||||||
// network error so the UI never hangs in a loading state.
|
|
||||||
const pendingRef = useRef(new Map<LookupKey, Array<() => void>>());
|
|
||||||
|
|
||||||
const flush = useCallback(async () => {
|
|
||||||
tickRef.current = null;
|
|
||||||
const keys = Array.from(queueRef.current);
|
|
||||||
queueRef.current.clear();
|
|
||||||
if (keys.length === 0) return;
|
|
||||||
|
|
||||||
for (const k of keys) inFlightRef.current.add(k);
|
|
||||||
|
|
||||||
const references = keys.map((k) => {
|
|
||||||
const [sourcePageId, transclusionId] = k.split("::");
|
|
||||||
return { sourcePageId, transclusionId };
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolveWaiters = (key: LookupKey) => {
|
|
||||||
const waiters = pendingRef.current.get(key);
|
|
||||||
if (!waiters) return;
|
|
||||||
pendingRef.current.delete(key);
|
|
||||||
for (const w of waiters) w();
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { items } = await lookupTransclusion({ references });
|
|
||||||
for (const r of items) {
|
|
||||||
const key = `${r.sourcePageId}::${r.transclusionId}`;
|
|
||||||
resultCacheRef.current.set(key, r);
|
|
||||||
inFlightRef.current.delete(key);
|
|
||||||
const subs = subscribersRef.current.get(key);
|
|
||||||
if (subs) {
|
|
||||||
for (const s of subs) s.setResult(r);
|
|
||||||
}
|
|
||||||
resolveWaiters(key);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Network error — leave subscribers in pending state and clear the
|
|
||||||
// in-flight flag so a future subscribe can retry.
|
|
||||||
for (const k of keys) {
|
|
||||||
inFlightRef.current.delete(k);
|
|
||||||
resolveWaiters(k);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const enqueue = useCallback(
|
|
||||||
(key: LookupKey) => {
|
|
||||||
queueRef.current.add(key);
|
|
||||||
if (tickRef.current === null) {
|
|
||||||
tickRef.current = setTimeout(flush, 10);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[flush],
|
|
||||||
);
|
|
||||||
|
|
||||||
const subscribe = useCallback<ContextValue["subscribe"]>(
|
|
||||||
(s) => {
|
|
||||||
const list = subscribersRef.current.get(s.key) ?? [];
|
|
||||||
list.push(s);
|
|
||||||
subscribersRef.current.set(s.key, list);
|
|
||||||
|
|
||||||
const cached = resultCacheRef.current.get(s.key);
|
|
||||||
if (cached) {
|
|
||||||
s.setResult(cached);
|
|
||||||
} else if (!inFlightRef.current.has(s.key)) {
|
|
||||||
enqueue(s.key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
const cur = subscribersRef.current.get(s.key) ?? [];
|
|
||||||
const next = cur.filter((x) => x !== s);
|
|
||||||
if (next.length === 0) subscribersRef.current.delete(s.key);
|
|
||||||
else subscribersRef.current.set(s.key, next);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[enqueue],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refresh = useCallback<ContextValue["refresh"]>(
|
|
||||||
(key) =>
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
resultCacheRef.current.delete(key);
|
|
||||||
inFlightRef.current.delete(key);
|
|
||||||
const waiters = pendingRef.current.get(key) ?? [];
|
|
||||||
waiters.push(resolve);
|
|
||||||
pendingRef.current.set(key, waiters);
|
|
||||||
enqueue(key);
|
|
||||||
}),
|
|
||||||
[enqueue],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
if (tickRef.current) clearTimeout(tickRef.current);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const value = useMemo<ContextValue>(
|
|
||||||
() => ({ subscribe, refresh }),
|
|
||||||
[subscribe, refresh],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TransclusionLookupContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</TransclusionLookupContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTransclusionLookup(
|
|
||||||
sourcePageId: string | null | undefined,
|
|
||||||
transclusionId: string | null | undefined,
|
|
||||||
): {
|
|
||||||
result: TransclusionLookup | null;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
} {
|
|
||||||
const ctx = useContext(TransclusionLookupContext);
|
|
||||||
const [result, setResult] = useState<TransclusionLookup | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ctx || !sourcePageId || !transclusionId) return;
|
|
||||||
const key = `${sourcePageId}::${transclusionId}`;
|
|
||||||
const unsubscribe = ctx.subscribe({
|
|
||||||
key,
|
|
||||||
sourcePageId,
|
|
||||||
transclusionId,
|
|
||||||
setResult,
|
|
||||||
});
|
|
||||||
return unsubscribe;
|
|
||||||
}, [ctx, sourcePageId, transclusionId]);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
if (!ctx || !sourcePageId || !transclusionId) return;
|
|
||||||
await ctx.refresh(`${sourcePageId}::${transclusionId}`);
|
|
||||||
}, [ctx, sourcePageId, transclusionId]);
|
|
||||||
|
|
||||||
return { result, refresh };
|
|
||||||
}
|
|
||||||
-204
@@ -1,204 +0,0 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
|
||||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconDots,
|
|
||||||
IconExternalLink,
|
|
||||||
IconLinkOff,
|
|
||||||
IconRefresh,
|
|
||||||
IconTrash,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
|
||||||
import { useTransclusionLookup } from "./transclusion-lookup-context";
|
|
||||||
import TransclusionContent from "./transclusion-content";
|
|
||||||
import NoAccessPlaceholder from "./no-access-placeholder";
|
|
||||||
import NotFoundPlaceholder from "./not-found-placeholder";
|
|
||||||
import ErrorPlaceholder from "./error-placeholder";
|
|
||||||
import classes from "./transclusion.module.css";
|
|
||||||
import SyncBlockReferencesDropdown from "@/features/transclusion/components/sync-block-references-dropdown";
|
|
||||||
import {
|
|
||||||
useReferencesQuery,
|
|
||||||
useUnsyncReferenceMutation,
|
|
||||||
} from "@/features/transclusion/queries/transclusion-query";
|
|
||||||
import { buildPageUrl } from "@/features/page/page.utils";
|
|
||||||
|
|
||||||
export default function TransclusionReferenceView(props: NodeViewProps) {
|
|
||||||
const isEditable = props.editor.isEditable;
|
|
||||||
const sourcePageId: string | null = props.node.attrs.sourcePageId ?? null;
|
|
||||||
const transclusionId: string | null = props.node.attrs.transclusionId ?? null;
|
|
||||||
const [openMenus, setOpenMenus] = useState(0);
|
|
||||||
const trackOpen = (open: boolean) =>
|
|
||||||
setOpenMenus((n) => Math.max(0, n + (open ? 1 : -1)));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NodeViewWrapper
|
|
||||||
className={classes.includeWrap}
|
|
||||||
data-focused={isEditable && props.selected ? "true" : "false"}
|
|
||||||
data-menu-open={openMenus > 0 ? "true" : "false"}
|
|
||||||
contentEditable={false}
|
|
||||||
>
|
|
||||||
<ErrorBoundary
|
|
||||||
resetKeys={[sourcePageId, transclusionId]}
|
|
||||||
fallback={<ErrorPlaceholder />}
|
|
||||||
>
|
|
||||||
<TransclusionReferenceBody {...props} trackOpen={trackOpen} />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TransclusionReferenceBody({
|
|
||||||
editor,
|
|
||||||
node,
|
|
||||||
deleteNode,
|
|
||||||
getPos,
|
|
||||||
trackOpen,
|
|
||||||
}: NodeViewProps & { trackOpen: (open: boolean) => void }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const sourcePageId: string | null = node.attrs.sourcePageId ?? null;
|
|
||||||
const transclusionId: string | null = node.attrs.transclusionId ?? null;
|
|
||||||
const isEditable = editor.isEditable;
|
|
||||||
|
|
||||||
const { result, refresh } = useTransclusionLookup(
|
|
||||||
sourcePageId,
|
|
||||||
transclusionId,
|
|
||||||
);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
setRefreshing(true);
|
|
||||||
try {
|
|
||||||
await refresh();
|
|
||||||
} finally {
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// @ts-ignore - editor.storage.pageId is set by the host editor
|
|
||||||
const hostPageId: string | undefined = editor.storage?.pageId;
|
|
||||||
const unsyncMutation = useUnsyncReferenceMutation();
|
|
||||||
// Cached against the dropdown's identical query so the source link target
|
|
||||||
// is ready as soon as the controls fade in on hover, without a second
|
|
||||||
// fetch.
|
|
||||||
const referencesQuery = useReferencesQuery(
|
|
||||||
sourcePageId,
|
|
||||||
transclusionId,
|
|
||||||
isEditable,
|
|
||||||
);
|
|
||||||
const sourcePageHref = (() => {
|
|
||||||
const source = referencesQuery.data?.source;
|
|
||||||
if (source?.spaceSlug) {
|
|
||||||
return buildPageUrl(source.spaceSlug, source.slugId, source.title);
|
|
||||||
}
|
|
||||||
return sourcePageId ? `/p/${sourcePageId}` : null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const handleUnsync = async () => {
|
|
||||||
if (!hostPageId || !sourcePageId || !transclusionId) return;
|
|
||||||
try {
|
|
||||||
const { content } = await unsyncMutation.mutateAsync({
|
|
||||||
referencePageId: hostPageId,
|
|
||||||
sourcePageId,
|
|
||||||
transclusionId,
|
|
||||||
});
|
|
||||||
const pos = getPos();
|
|
||||||
if (typeof pos !== "number") return;
|
|
||||||
const from = pos;
|
|
||||||
const to = pos + node.nodeSize;
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.insertContentAt({ from, to }, content as any)
|
|
||||||
.run();
|
|
||||||
} catch {
|
|
||||||
// mutation surfaces errors via React Query; node stays as-is
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isEditable && (
|
|
||||||
<div className={classes.includeControls} contentEditable={false}>
|
|
||||||
{sourcePageId && transclusionId && hostPageId && (
|
|
||||||
<SyncBlockReferencesDropdown
|
|
||||||
sourcePageId={sourcePageId}
|
|
||||||
transclusionId={transclusionId}
|
|
||||||
currentPageId={hostPageId}
|
|
||||||
mode="reference"
|
|
||||||
onOpenChange={trackOpen}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className={classes.controlsDivider} />
|
|
||||||
<Tooltip label={t("Refresh")}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
loading={refreshing}
|
|
||||||
disabled={!sourcePageId || !transclusionId}
|
|
||||||
>
|
|
||||||
<IconRefresh size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
{sourcePageHref && (
|
|
||||||
<Tooltip label={t("Go to source page")}>
|
|
||||||
<ActionIcon
|
|
||||||
component={Link}
|
|
||||||
to={sourcePageHref}
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<IconExternalLink size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Menu position="bottom-end" withinPortal onChange={trackOpen}>
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
|
||||||
<IconDots size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconLinkOff size={14} />}
|
|
||||||
onClick={handleUnsync}
|
|
||||||
disabled={
|
|
||||||
unsyncMutation.isPending ||
|
|
||||||
!hostPageId ||
|
|
||||||
!sourcePageId ||
|
|
||||||
!transclusionId
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Unsync")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
color="red"
|
|
||||||
leftSection={<IconTrash size={14} />}
|
|
||||||
onClick={() => deleteNode()}
|
|
||||||
>
|
|
||||||
{t("Remove from page")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!sourcePageId || !transclusionId ? (
|
|
||||||
<NotFoundPlaceholder />
|
|
||||||
) : !result ? (
|
|
||||||
<div style={{ minHeight: 24 }} />
|
|
||||||
) : !("status" in result) ? (
|
|
||||||
<TransclusionContent
|
|
||||||
hostPageId={hostPageId ?? sourcePageId}
|
|
||||||
content={result.content}
|
|
||||||
/>
|
|
||||||
) : result.status === "no_access" ? (
|
|
||||||
<NoAccessPlaceholder />
|
|
||||||
) : (
|
|
||||||
<NotFoundPlaceholder />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import {
|
|
||||||
NodeViewContent,
|
|
||||||
NodeViewProps,
|
|
||||||
NodeViewWrapper,
|
|
||||||
} from "@tiptap/react";
|
|
||||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import {
|
|
||||||
IconCheck,
|
|
||||||
IconCopy,
|
|
||||||
IconDots,
|
|
||||||
IconLinkOff,
|
|
||||||
IconTrash,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import classes from "./transclusion.module.css";
|
|
||||||
import SyncBlockReferencesDropdown from "@/features/transclusion/components/sync-block-references-dropdown";
|
|
||||||
|
|
||||||
export default function TransclusionView(props: NodeViewProps) {
|
|
||||||
const { editor, node, deleteNode } = props;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [openMenus, setOpenMenus] = useState(0);
|
|
||||||
const trackOpen = (open: boolean) =>
|
|
||||||
setOpenMenus((n) => Math.max(0, n + (open ? 1 : -1)));
|
|
||||||
|
|
||||||
const isEditable = editor.isEditable;
|
|
||||||
// @ts-ignore - editor.storage.pageId is set by the host editor (page-editor.tsx onCreate)
|
|
||||||
const sourcePageId: string | undefined = editor.storage?.pageId;
|
|
||||||
const transclusionId: string | null = node.attrs.id ?? null;
|
|
||||||
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const handleCopy = async () => {
|
|
||||||
if (!sourcePageId || !transclusionId) return;
|
|
||||||
const html = `<div data-type="transclusionReference" data-source-page-id="${sourcePageId}" data-transclusion-id="${transclusionId}"></div>`;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.write([
|
|
||||||
new ClipboardItem({
|
|
||||||
"text/html": new Blob([html], { type: "text/html" }),
|
|
||||||
"text/plain": new Blob([html], { type: "text/plain" }),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} catch {
|
|
||||||
// Fallback for browsers without ClipboardItem write support
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(html);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setCopied(true);
|
|
||||||
window.setTimeout(() => setCopied(false), 2000);
|
|
||||||
notifications.show({
|
|
||||||
message: t("Copied. Paste on any page to embed this synced block."),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnsync = () => {
|
|
||||||
editor.chain().focus().unsyncTransclusion().run();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NodeViewWrapper
|
|
||||||
className={classes.transclusionWrap}
|
|
||||||
data-drag-handle
|
|
||||||
data-menu-open={openMenus > 0 ? "true" : "false"}
|
|
||||||
>
|
|
||||||
{isEditable && (
|
|
||||||
<div className={classes.transclusionControls} contentEditable={false}>
|
|
||||||
{sourcePageId && transclusionId && (
|
|
||||||
<SyncBlockReferencesDropdown
|
|
||||||
sourcePageId={sourcePageId}
|
|
||||||
transclusionId={transclusionId}
|
|
||||||
currentPageId={sourcePageId}
|
|
||||||
mode="source"
|
|
||||||
onOpenChange={trackOpen}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className={classes.controlsDivider} />
|
|
||||||
|
|
||||||
<Tooltip label={copied ? t("Copied") : t("Copy synced block")}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color={copied ? "teal" : "gray"}
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCopy}
|
|
||||||
disabled={!sourcePageId || !transclusionId}
|
|
||||||
>
|
|
||||||
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Menu position="bottom-end" withinPortal onChange={trackOpen}>
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
|
||||||
<IconDots size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconLinkOff size={14} />}
|
|
||||||
onClick={handleUnsync}
|
|
||||||
>
|
|
||||||
{t("Unsync")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
color="red"
|
|
||||||
leftSection={<IconTrash size={14} />}
|
|
||||||
onClick={() => deleteNode()}
|
|
||||||
>
|
|
||||||
{t("Delete sync block")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<NodeViewContent />
|
|
||||||
</NodeViewWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
.placeholder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: var(--mantine-spacing-md);
|
|
||||||
border-radius: var(--mantine-radius-md);
|
|
||||||
background: light-dark(
|
|
||||||
var(--mantine-color-gray-0),
|
|
||||||
var(--mantine-color-dark-6)
|
|
||||||
);
|
|
||||||
border: 1px dashed
|
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
|
||||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholderIcon {
|
|
||||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholderTitle {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--mantine-font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholderSubtext {
|
|
||||||
font-size: var(--mantine-font-size-xs);
|
|
||||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transclusionBadge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: var(--mantine-font-size-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
|
||||||
background: light-dark(
|
|
||||||
var(--mantine-color-gray-1),
|
|
||||||
var(--mantine-color-dark-5)
|
|
||||||
);
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transclusionWrap {
|
|
||||||
position: relative;
|
|
||||||
margin-left: -3rem;
|
|
||||||
margin-right: -3rem;
|
|
||||||
width: calc(100% + 6rem);
|
|
||||||
padding: 0.5em 3rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: border 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transclusionWrap:hover,
|
|
||||||
.transclusionWrap:focus-within {
|
|
||||||
border: 1px solid
|
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-7));
|
|
||||||
}
|
|
||||||
|
|
||||||
.transclusionControls {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 8px);
|
|
||||||
right: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: var(--mantine-color-body);
|
|
||||||
border: 1px solid var(--mantine-color-default-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 4px 6px;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover bridge: keeps :hover on the wrap while the cursor crosses the
|
|
||||||
8px gap between wrap and floating chrome, so the menu doesn't fade out
|
|
||||||
on the way to it. */
|
|
||||||
.transclusionControls::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transclusionWrap:hover .transclusionControls,
|
|
||||||
.transclusionWrap:focus-within .transclusionControls,
|
|
||||||
.transclusionWrap[data-menu-open="true"] .transclusionControls {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controlsDivider {
|
|
||||||
display: inline-block;
|
|
||||||
width: 1px;
|
|
||||||
height: 16px;
|
|
||||||
background: var(--mantine-color-default-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.includeWrap {
|
|
||||||
position: relative;
|
|
||||||
margin-left: -3rem;
|
|
||||||
margin-right: -3rem;
|
|
||||||
width: calc(100% + 6rem);
|
|
||||||
padding: 0.5em 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: border 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.includeWrap:hover,
|
|
||||||
.includeWrap[data-focused="true"],
|
|
||||||
.includeWrap[data-menu-open="true"] {
|
|
||||||
border: 1px solid
|
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-7));
|
|
||||||
}
|
|
||||||
|
|
||||||
.includeControls {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 8px);
|
|
||||||
right: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: var(--mantine-color-body);
|
|
||||||
border: 1px solid var(--mantine-color-default-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 4px 6px;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover bridge: keeps :hover on the wrap while the cursor crosses the
|
|
||||||
8px gap between wrap and floating chrome, so the menu doesn't fade out
|
|
||||||
on the way to it. */
|
|
||||||
.includeControls::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.includeWrap:hover .includeControls,
|
|
||||||
.includeWrap:focus-within .includeControls,
|
|
||||||
.includeWrap[data-focused="true"] .includeControls,
|
|
||||||
.includeWrap[data-menu-open="true"] .includeControls {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.react-renderer.node-transclusion.ProseMirror-selectednode),
|
|
||||||
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode) {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 48em) {
|
|
||||||
.transclusionWrap,
|
|
||||||
.includeWrap {
|
|
||||||
margin-left: -1rem;
|
|
||||||
margin-right: -1rem;
|
|
||||||
width: calc(100% + 2rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.transclusionWrap {
|
|
||||||
padding-left: 1rem;
|
|
||||||
padding-right: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
.transclusionControls,
|
|
||||||
.includeControls {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.editingOriginalTag {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0 6px;
|
|
||||||
font-size: var(--mantine-font-size-xs);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--mantine-color-blue-7);
|
|
||||||
background: light-dark(
|
|
||||||
var(--mantine-color-blue-0),
|
|
||||||
var(--mantine-color-blue-9)
|
|
||||||
);
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
}
|
|
||||||
@@ -47,6 +47,7 @@ export default function VideoView(props: NodeViewProps) {
|
|||||||
preload="metadata"
|
preload="metadata"
|
||||||
controls
|
controls
|
||||||
src={getFileUrl(src)}
|
src={getFileUrl(src)}
|
||||||
|
aria-label={placeholder?.name || t("Video")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!src && previewSrc && (
|
{!src && previewSrc && (
|
||||||
@@ -56,6 +57,7 @@ export default function VideoView(props: NodeViewProps) {
|
|||||||
preload="metadata"
|
preload="metadata"
|
||||||
controls
|
controls
|
||||||
src={previewSrc}
|
src={previewSrc}
|
||||||
|
aria-label={placeholder?.name || t("Video")}
|
||||||
/>
|
/>
|
||||||
<Loader size={20} pos="absolute" top={6} right={6} />
|
<Loader size={20} pos="absolute" top={6} right={6} />
|
||||||
</Group>
|
</Group>
|
||||||
@@ -71,7 +73,7 @@ export default function VideoView(props: NodeViewProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
{!src && !previewSrc && !placeholder && (
|
{!src && !previewSrc && !placeholder && (
|
||||||
<video className={classes.video} controls />
|
<video className={classes.video} controls aria-label={t("Video")} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
|
|||||||
@@ -1,419 +0,0 @@
|
|||||||
import { Extension } from "@tiptap/core";
|
|
||||||
import {
|
|
||||||
NodeSelection,
|
|
||||||
Plugin,
|
|
||||||
PluginKey,
|
|
||||||
TextSelection,
|
|
||||||
} from "@tiptap/pm/state";
|
|
||||||
import { Fragment, Slice, Node } from "@tiptap/pm/model";
|
|
||||||
import { EditorView } from "@tiptap/pm/view";
|
|
||||||
|
|
||||||
export interface GlobalDragHandleOptions {
|
|
||||||
/**
|
|
||||||
* The width of the drag handle
|
|
||||||
*/
|
|
||||||
dragHandleWidth: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The treshold for scrolling
|
|
||||||
*/
|
|
||||||
scrollThreshold: number;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The css selector to query for the drag handle. (eg: '.custom-handle').
|
|
||||||
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
|
|
||||||
*/
|
|
||||||
dragHandleSelector?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tags to be excluded for drag handle
|
|
||||||
*/
|
|
||||||
excludedTags: string[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom nodes to be included for drag handle
|
|
||||||
*/
|
|
||||||
customNodes: string[];
|
|
||||||
}
|
|
||||||
function absoluteRect(node: Element) {
|
|
||||||
const data = node.getBoundingClientRect();
|
|
||||||
const modal = node.closest('[role="dialog"]');
|
|
||||||
|
|
||||||
if (modal && window.getComputedStyle(modal).transform !== "none") {
|
|
||||||
const modalRect = modal.getBoundingClientRect();
|
|
||||||
|
|
||||||
return {
|
|
||||||
top: data.top - modalRect.top,
|
|
||||||
left: data.left - modalRect.left,
|
|
||||||
width: data.width,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
top: data.top,
|
|
||||||
left: data.left,
|
|
||||||
width: data.width,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeDOMAtCoords(
|
|
||||||
coords: { x: number; y: number },
|
|
||||||
options: GlobalDragHandleOptions,
|
|
||||||
view: EditorView,
|
|
||||||
) {
|
|
||||||
const selectors = [
|
|
||||||
"li",
|
|
||||||
"p:not(:first-child)",
|
|
||||||
"pre",
|
|
||||||
"blockquote",
|
|
||||||
"h1",
|
|
||||||
"h2",
|
|
||||||
"h3",
|
|
||||||
"h4",
|
|
||||||
"h5",
|
|
||||||
"h6",
|
|
||||||
...options.customNodes.map((node) => `[data-type=${node}]`),
|
|
||||||
].join(", ");
|
|
||||||
return document
|
|
||||||
.elementsFromPoint(coords.x, coords.y)
|
|
||||||
.find((elem: Element) => {
|
|
||||||
// Skip elements that belong to a nested editor (e.g. transclusion
|
|
||||||
// references render their own ProseMirror instance). Only consider
|
|
||||||
// elements whose closest editor is this host view.
|
|
||||||
if (elem.closest(".ProseMirror") !== view.dom) return false;
|
|
||||||
return (
|
|
||||||
elem.parentElement?.matches?.(".ProseMirror") ||
|
|
||||||
elem.matches(selectors)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function nodePosAtDOM(
|
|
||||||
node: Element,
|
|
||||||
view: EditorView,
|
|
||||||
options: GlobalDragHandleOptions,
|
|
||||||
) {
|
|
||||||
const boundingRect = node.getBoundingClientRect();
|
|
||||||
|
|
||||||
return view.posAtCoords({
|
|
||||||
left: boundingRect.left + 50 + options.dragHandleWidth,
|
|
||||||
top: boundingRect.top + 1,
|
|
||||||
})?.inside;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calcNodePos(pos: number, view: EditorView) {
|
|
||||||
const $pos = view.state.doc.resolve(pos);
|
|
||||||
if ($pos.depth > 1) return $pos.before($pos.depth);
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DragHandlePlugin(
|
|
||||||
options: GlobalDragHandleOptions & { pluginKey: string },
|
|
||||||
) {
|
|
||||||
let listType = "";
|
|
||||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
|
||||||
view.focus();
|
|
||||||
|
|
||||||
if (!event.dataTransfer) return;
|
|
||||||
|
|
||||||
const node = nodeDOMAtCoords(
|
|
||||||
{
|
|
||||||
x: event.clientX + 50 + options.dragHandleWidth,
|
|
||||||
y: event.clientY,
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
view,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!(node instanceof Element)) return;
|
|
||||||
|
|
||||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
|
||||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
|
||||||
draggedNodePos = calcNodePos(draggedNodePos, view);
|
|
||||||
|
|
||||||
const { from, to } = view.state.selection;
|
|
||||||
const diff = from - to;
|
|
||||||
|
|
||||||
const fromSelectionPos = calcNodePos(from, view);
|
|
||||||
let differentNodeSelected = false;
|
|
||||||
|
|
||||||
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
|
||||||
|
|
||||||
// Check if nodePos points to the top level node
|
|
||||||
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
|
|
||||||
else {
|
|
||||||
const nodeSelection = NodeSelection.create(
|
|
||||||
view.state.doc,
|
|
||||||
nodePos.before(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if the node where the drag event started is part of the current selection
|
|
||||||
differentNodeSelected = !(
|
|
||||||
draggedNodePos + 1 >= nodeSelection.$from.pos &&
|
|
||||||
draggedNodePos <= nodeSelection.$to.pos
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let selection = view.state.selection;
|
|
||||||
if (
|
|
||||||
!differentNodeSelected &&
|
|
||||||
diff !== 0 &&
|
|
||||||
!(view.state.selection instanceof NodeSelection)
|
|
||||||
) {
|
|
||||||
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
|
||||||
selection = TextSelection.create(
|
|
||||||
view.state.doc,
|
|
||||||
draggedNodePos,
|
|
||||||
endSelection.$to.pos,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
selection = NodeSelection.create(view.state.doc, draggedNodePos);
|
|
||||||
|
|
||||||
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
|
|
||||||
// if table row is selected, go to the parent node to select the whole node
|
|
||||||
if (
|
|
||||||
(selection as NodeSelection).node.type.isInline ||
|
|
||||||
(selection as NodeSelection).node.type.name === "tableRow"
|
|
||||||
) {
|
|
||||||
let $pos = view.state.doc.resolve(selection.from);
|
|
||||||
selection = NodeSelection.create(view.state.doc, $pos.before());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
view.dispatch(view.state.tr.setSelection(selection));
|
|
||||||
|
|
||||||
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
|
||||||
if (
|
|
||||||
view.state.selection instanceof NodeSelection &&
|
|
||||||
view.state.selection.node.type.name === "listItem"
|
|
||||||
) {
|
|
||||||
listType = node.parentElement!.tagName;
|
|
||||||
}
|
|
||||||
|
|
||||||
const slice = view.state.selection.content();
|
|
||||||
const { dom, text } = view.serializeForClipboard(slice);
|
|
||||||
|
|
||||||
event.dataTransfer.clearData();
|
|
||||||
event.dataTransfer.setData("text/html", dom.innerHTML);
|
|
||||||
event.dataTransfer.setData("text/plain", text);
|
|
||||||
event.dataTransfer.effectAllowed = "move";
|
|
||||||
|
|
||||||
event.dataTransfer.setDragImage(node, 0, 0);
|
|
||||||
|
|
||||||
view.dragging = { slice, move: event.ctrlKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
let dragHandleElement: HTMLElement | null = null;
|
|
||||||
|
|
||||||
function hideDragHandle() {
|
|
||||||
if (dragHandleElement) {
|
|
||||||
dragHandleElement.classList.add("hide");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showDragHandle() {
|
|
||||||
if (dragHandleElement) {
|
|
||||||
dragHandleElement.classList.remove("hide");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideHandleOnEditorOut(event: MouseEvent) {
|
|
||||||
if (event.target instanceof Element) {
|
|
||||||
// Check if the relatedTarget class is still inside the editor
|
|
||||||
const relatedTarget = event.relatedTarget as HTMLElement;
|
|
||||||
const isInsideEditor =
|
|
||||||
relatedTarget?.classList.contains("tiptap") ||
|
|
||||||
relatedTarget?.classList.contains("drag-handle");
|
|
||||||
|
|
||||||
if (isInsideEditor) return;
|
|
||||||
}
|
|
||||||
hideDragHandle();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Plugin({
|
|
||||||
key: new PluginKey(options.pluginKey),
|
|
||||||
view: (view) => {
|
|
||||||
const handleBySelector = options.dragHandleSelector
|
|
||||||
? document.querySelector<HTMLElement>(options.dragHandleSelector)
|
|
||||||
: null;
|
|
||||||
dragHandleElement = handleBySelector ?? document.createElement("div");
|
|
||||||
dragHandleElement.draggable = true;
|
|
||||||
dragHandleElement.dataset.dragHandle = "";
|
|
||||||
dragHandleElement.classList.add("drag-handle");
|
|
||||||
|
|
||||||
function onDragHandleDragStart(e: DragEvent) {
|
|
||||||
handleDragStart(e, view);
|
|
||||||
}
|
|
||||||
|
|
||||||
dragHandleElement.addEventListener("dragstart", onDragHandleDragStart);
|
|
||||||
|
|
||||||
function onDragHandleDrag(e: DragEvent) {
|
|
||||||
hideDragHandle();
|
|
||||||
let scrollY = window.scrollY;
|
|
||||||
if (e.clientY < options.scrollThreshold) {
|
|
||||||
window.scrollTo({ top: scrollY - 30, behavior: "smooth" });
|
|
||||||
} else if (window.innerHeight - e.clientY < options.scrollThreshold) {
|
|
||||||
window.scrollTo({ top: scrollY + 30, behavior: "smooth" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dragHandleElement.addEventListener("drag", onDragHandleDrag);
|
|
||||||
|
|
||||||
hideDragHandle();
|
|
||||||
|
|
||||||
if (!handleBySelector) {
|
|
||||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
|
||||||
}
|
|
||||||
view?.dom?.parentElement?.addEventListener(
|
|
||||||
"mouseout",
|
|
||||||
hideHandleOnEditorOut,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
destroy: () => {
|
|
||||||
if (!handleBySelector) {
|
|
||||||
dragHandleElement?.remove?.();
|
|
||||||
}
|
|
||||||
dragHandleElement?.removeEventListener("drag", onDragHandleDrag);
|
|
||||||
dragHandleElement?.removeEventListener(
|
|
||||||
"dragstart",
|
|
||||||
onDragHandleDragStart,
|
|
||||||
);
|
|
||||||
dragHandleElement = null;
|
|
||||||
view?.dom?.parentElement?.removeEventListener(
|
|
||||||
"mouseout",
|
|
||||||
hideHandleOnEditorOut,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
handleDOMEvents: {
|
|
||||||
mousemove: (view, event) => {
|
|
||||||
if (!view.editable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = nodeDOMAtCoords(
|
|
||||||
{
|
|
||||||
x: event.clientX + 50 + options.dragHandleWidth,
|
|
||||||
y: event.clientY,
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
view,
|
|
||||||
);
|
|
||||||
|
|
||||||
const notDragging = node?.closest(".not-draggable");
|
|
||||||
const excludedTagList = options.excludedTags
|
|
||||||
.concat(["ol", "ul"])
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
if (
|
|
||||||
!(node instanceof Element) ||
|
|
||||||
node.matches(excludedTagList) ||
|
|
||||||
notDragging
|
|
||||||
) {
|
|
||||||
hideDragHandle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const compStyle = window.getComputedStyle(node);
|
|
||||||
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
|
||||||
const lineHeight = isNaN(parsedLineHeight)
|
|
||||||
? parseInt(compStyle.fontSize) * 1.2
|
|
||||||
: parsedLineHeight;
|
|
||||||
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
|
||||||
|
|
||||||
const rect = absoluteRect(node);
|
|
||||||
|
|
||||||
rect.top += (lineHeight - 24) / 2;
|
|
||||||
rect.top += paddingTop;
|
|
||||||
// Li markers
|
|
||||||
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
|
|
||||||
rect.left -= options.dragHandleWidth;
|
|
||||||
}
|
|
||||||
rect.width = options.dragHandleWidth;
|
|
||||||
|
|
||||||
if (!dragHandleElement) return;
|
|
||||||
|
|
||||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
|
||||||
dragHandleElement.style.top = `${rect.top}px`;
|
|
||||||
showDragHandle();
|
|
||||||
},
|
|
||||||
keydown: () => {
|
|
||||||
hideDragHandle();
|
|
||||||
},
|
|
||||||
mousewheel: () => {
|
|
||||||
hideDragHandle();
|
|
||||||
},
|
|
||||||
// dragging class is used for CSS
|
|
||||||
dragstart: (view) => {
|
|
||||||
view.dom.classList.add("dragging");
|
|
||||||
},
|
|
||||||
drop: (view, event) => {
|
|
||||||
view.dom.classList.remove("dragging");
|
|
||||||
hideDragHandle();
|
|
||||||
let droppedNode: Node | null = null;
|
|
||||||
const dropPos = view.posAtCoords({
|
|
||||||
left: event.clientX,
|
|
||||||
top: event.clientY,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dropPos) return;
|
|
||||||
|
|
||||||
if (view.state.selection instanceof NodeSelection) {
|
|
||||||
droppedNode = view.state.selection.node;
|
|
||||||
}
|
|
||||||
if (!droppedNode) return;
|
|
||||||
|
|
||||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
|
||||||
|
|
||||||
const isDroppedInsideList =
|
|
||||||
resolvedPos.parent.type.name === "listItem";
|
|
||||||
|
|
||||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
|
||||||
if (
|
|
||||||
view.state.selection instanceof NodeSelection &&
|
|
||||||
view.state.selection.node.type.name === "listItem" &&
|
|
||||||
!isDroppedInsideList &&
|
|
||||||
listType == "OL"
|
|
||||||
) {
|
|
||||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(
|
|
||||||
null,
|
|
||||||
droppedNode,
|
|
||||||
);
|
|
||||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
|
||||||
view.dragging = { slice, move: event.ctrlKey };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dragend: (view) => {
|
|
||||||
view.dom.classList.remove("dragging");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const GlobalDragHandle = Extension.create({
|
|
||||||
name: "globalDragHandle",
|
|
||||||
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
dragHandleWidth: 20,
|
|
||||||
scrollTreshold: 100,
|
|
||||||
excludedTags: [],
|
|
||||||
customNodes: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [
|
|
||||||
DragHandlePlugin({
|
|
||||||
pluginKey: "globalDragHandle",
|
|
||||||
dragHandleWidth: this.options.dragHandleWidth,
|
|
||||||
scrollThreshold: this.options.scrollThreshold,
|
|
||||||
dragHandleSelector: this.options.dragHandleSelector,
|
|
||||||
excludedTags: this.options.excludedTags,
|
|
||||||
customNodes: this.options.customNodes,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default GlobalDragHandle;
|
|
||||||
@@ -9,6 +9,7 @@ import SubScript from "@tiptap/extension-subscript";
|
|||||||
import { Typography } from "@tiptap/extension-typography";
|
import { Typography } from "@tiptap/extension-typography";
|
||||||
import { TextStyle } from "@tiptap/extension-text-style";
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
import { Color } from "@tiptap/extension-color";
|
import { Color } from "@tiptap/extension-color";
|
||||||
|
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
||||||
import { Youtube } from "@tiptap/extension-youtube";
|
import { Youtube } from "@tiptap/extension-youtube";
|
||||||
import SlashCommand, { SlashCommandExtension as Command } from "@/features/editor/extensions/slash-command";
|
import SlashCommand, { SlashCommandExtension as Command } from "@/features/editor/extensions/slash-command";
|
||||||
import renderItems from "@/features/editor/components/slash-menu/render-items";
|
import renderItems from "@/features/editor/components/slash-menu/render-items";
|
||||||
@@ -51,8 +52,6 @@ import {
|
|||||||
Columns,
|
Columns,
|
||||||
Column,
|
Column,
|
||||||
Status,
|
Status,
|
||||||
Transclusion,
|
|
||||||
TransclusionReference,
|
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -81,8 +80,6 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
|
|||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
||||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||||
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
|
|
||||||
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
|
|
||||||
import { common, createLowlight } from "lowlight";
|
import { common, createLowlight } from "lowlight";
|
||||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||||
import powershell from "highlight.js/lib/languages/powershell";
|
import powershell from "highlight.js/lib/languages/powershell";
|
||||||
@@ -103,7 +100,6 @@ import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboa
|
|||||||
import EmojiCommand from "./emoji-command";
|
import EmojiCommand from "./emoji-command";
|
||||||
import { countWords } from "alfaaz";
|
import { countWords } from "alfaaz";
|
||||||
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
|
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
|
||||||
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
|
|
||||||
|
|
||||||
const lowlight = createLowlight(common);
|
const lowlight = createLowlight(common);
|
||||||
lowlight.register("mermaid", plaintext);
|
lowlight.register("mermaid", plaintext);
|
||||||
@@ -171,7 +167,7 @@ export const mainExtensions = [
|
|||||||
SharedStorage,
|
SharedStorage,
|
||||||
Heading,
|
Heading,
|
||||||
UniqueID.configure({
|
UniqueID.configure({
|
||||||
types: ["heading", "paragraph", "transclusion"],
|
types: ["heading", "paragraph"],
|
||||||
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
@@ -219,9 +215,7 @@ export const mainExtensions = [
|
|||||||
}),
|
}),
|
||||||
Typography,
|
Typography,
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
GlobalDragHandle.configure({
|
GlobalDragHandle,
|
||||||
customNodes: ["transclusion", "transclusionReference"],
|
|
||||||
}),
|
|
||||||
TextStyle,
|
TextStyle,
|
||||||
Color,
|
Color,
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
@@ -357,12 +351,6 @@ export const mainExtensions = [
|
|||||||
Status.configure({
|
Status.configure({
|
||||||
view: StatusView,
|
view: StatusView,
|
||||||
}),
|
}),
|
||||||
Transclusion.configure({
|
|
||||||
view: TransclusionView,
|
|
||||||
}),
|
|
||||||
TransclusionReference.configure({
|
|
||||||
view: TransclusionReferenceView,
|
|
||||||
}),
|
|
||||||
MarkdownClipboard.configure({
|
MarkdownClipboard.configure({
|
||||||
transformPastedText: true,
|
transformPastedText: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ 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 { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -400,60 +399,55 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
}, [yjsConnectionStatus, isSynced]);
|
}, [yjsConnectionStatus, isSynced]);
|
||||||
|
|
||||||
|
if (showStatic) {
|
||||||
|
return (
|
||||||
|
<EditorProvider
|
||||||
|
editable={false}
|
||||||
|
immediatelyRender={true}
|
||||||
|
extensions={mainExtensions}
|
||||||
|
content={content}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TransclusionLookupProvider hostPageId={pageId}>
|
<div className="editor-container" style={{ position: "relative" }}>
|
||||||
{showStatic ? (
|
<div ref={menuContainerRef}>
|
||||||
<EditorProvider
|
<EditorContent editor={editor} />
|
||||||
editable={false}
|
|
||||||
immediatelyRender={true}
|
|
||||||
extensions={mainExtensions}
|
|
||||||
content={content}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="editor-container" style={{ position: "relative" }}>
|
|
||||||
<div ref={menuContainerRef}>
|
|
||||||
<EditorContent editor={editor} />
|
|
||||||
|
|
||||||
{editor && (
|
{editor && (
|
||||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editor && editorIsEditable && (
|
{editor && editorIsEditable && (
|
||||||
<div>
|
<div>
|
||||||
<EditorAiMenu editor={editor} />
|
<EditorAiMenu editor={editor} />
|
||||||
<EditorLinkMenu editor={editor} />
|
<EditorLinkMenu editor={editor} />
|
||||||
<EditorBubbleMenu editor={editor} />
|
<EditorBubbleMenu editor={editor} />
|
||||||
<TableMenu editor={editor} />
|
<TableMenu editor={editor} />
|
||||||
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
<ImageMenu editor={editor} />
|
<ImageMenu editor={editor} />
|
||||||
<VideoMenu editor={editor} />
|
<VideoMenu editor={editor} />
|
||||||
<PdfMenu editor={editor} />
|
<PdfMenu editor={editor} />
|
||||||
<CalloutMenu editor={editor} />
|
<CalloutMenu editor={editor} />
|
||||||
<SubpagesMenu editor={editor} />
|
<SubpagesMenu editor={editor} />
|
||||||
<ExcalidrawMenu editor={editor} />
|
<ExcalidrawMenu editor={editor} />
|
||||||
<DrawioMenu editor={editor} />
|
<DrawioMenu editor={editor} />
|
||||||
<ColumnsMenu editor={editor} />
|
<ColumnsMenu editor={editor} />
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{editor &&
|
|
||||||
!editorIsEditable &&
|
|
||||||
(editable || canComment) &&
|
|
||||||
providersRef.current && (
|
|
||||||
<ReadonlyBubbleMenu editor={editor} />
|
|
||||||
)}
|
|
||||||
{showCommentPopup && (
|
|
||||||
<CommentDialog editor={editor} pageId={pageId} />
|
|
||||||
)}
|
|
||||||
{showReadOnlyCommentPopup && (
|
|
||||||
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
)}
|
||||||
onClick={() => editor.commands.focus("end")}
|
{editor && !editorIsEditable && (editable || canComment) && providersRef.current && (
|
||||||
style={{ paddingBottom: "20vh" }}
|
<ReadonlyBubbleMenu editor={editor} />
|
||||||
></div>
|
)}
|
||||||
</div>
|
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||||
)}
|
{showReadOnlyCommentPopup && (
|
||||||
</TransclusionLookupProvider>
|
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => editor.commands.focus("end")}
|
||||||
|
style={{ paddingBottom: "20vh" }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { Placeholder } from "@tiptap/extension-placeholder";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -66,7 +65,7 @@ export default function ReadonlyPageEditor({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TransclusionLookupProvider hostPageId={pageId ?? "anonymous"}>
|
<>
|
||||||
<div className="page-title">
|
<div className="page-title">
|
||||||
<EditorProvider
|
<EditorProvider
|
||||||
editable={false}
|
editable={false}
|
||||||
@@ -96,6 +95,6 @@ export default function ReadonlyPageEditor({
|
|||||||
}}
|
}}
|
||||||
></EditorProvider>
|
></EditorProvider>
|
||||||
<div style={{ paddingBottom: "20vh" }}></div>
|
<div style={{ paddingBottom: "20vh" }}></div>
|
||||||
</TransclusionLookupProvider>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,15 +53,17 @@ export default function StarButton(props: StarButtonProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const label = isFavorited
|
||||||
|
? t("Remove from favorites")
|
||||||
|
: t("Add to favorites");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip label={label} openDelay={250} withArrow>
|
||||||
label={isFavorited ? t("Remove from favorites") : t("Add to favorites")}
|
|
||||||
openDelay={250}
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color={isFavorited ? "yellow" : "gray"}
|
color={isFavorited ? "yellow" : "gray"}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={isFavorited}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default function GroupActionMenu() {
|
|||||||
arrowPosition="center"
|
arrowPosition="center"
|
||||||
>
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="light">
|
<ActionIcon variant="light" aria-label={t("Group menu")}>
|
||||||
<IconDots size={20} stroke={2} />
|
<IconDots size={20} stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function GroupMembersList() {
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>{t("User")}</Table.Th>
|
<Table.Th>{t("User")}</Table.Th>
|
||||||
<Table.Th>{t("Status")}</Table.Th>
|
<Table.Th>{t("Status")}</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
<Table.Th aria-label={t("Action")} />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
Badge,
|
Badge,
|
||||||
Table,
|
Table,
|
||||||
ActionIcon,
|
ThemeIcon,
|
||||||
Button,
|
Button,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -61,13 +61,13 @@ export default function CreatedByMe({ spaceId }: Props) {
|
|||||||
>
|
>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{page.icon || (
|
{page.icon || (
|
||||||
<ActionIcon
|
<ThemeIcon
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
color="gray"
|
color="gray"
|
||||||
size={18}
|
size={18}
|
||||||
>
|
>
|
||||||
<IconFileDescription size={18} />
|
<IconFileDescription size={18} />
|
||||||
</ActionIcon>
|
</ThemeIcon>
|
||||||
)}
|
)}
|
||||||
<Text fw={500} size="md" lineClamp={1}>
|
<Text fw={500} size="md" lineClamp={1}>
|
||||||
{page.title || t("Untitled")}
|
{page.title || t("Untitled")}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
Badge,
|
Badge,
|
||||||
Table,
|
Table,
|
||||||
ActionIcon,
|
ThemeIcon,
|
||||||
Button,
|
Button,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -62,13 +62,13 @@ export default function FavoritesPages({ spaceId }: Props) {
|
|||||||
>
|
>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{fav.page.icon || (
|
{fav.page.icon || (
|
||||||
<ActionIcon
|
<ThemeIcon
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
color="gray"
|
color="gray"
|
||||||
size={18}
|
size={18}
|
||||||
>
|
>
|
||||||
<IconFileDescription size={18} />
|
<IconFileDescription size={18} />
|
||||||
</ActionIcon>
|
</ThemeIcon>
|
||||||
)}
|
)}
|
||||||
<Text fw={500} size="md" lineClamp={1}>
|
<Text fw={500} size="md" lineClamp={1}>
|
||||||
{fav.page.title || t("Untitled")}
|
{fav.page.title || t("Untitled")}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export function NotificationPopover() {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="dark"
|
color="dark"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
aria-label={t("Notifications")}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={opened}
|
||||||
onClick={() => setOpened((o) => !o)}
|
onClick={() => setOpened((o) => !o)}
|
||||||
>
|
>
|
||||||
<Indicator
|
<Indicator
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
|
|||||||
opened={isModalOpen}
|
opened={isModalOpen}
|
||||||
onClose={() => setModalOpen(false)}
|
onClose={() => setModalOpen(false)}
|
||||||
fullScreen
|
fullScreen
|
||||||
|
aria-label={t("Page history")}
|
||||||
>
|
>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
@@ -49,6 +50,7 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
|
|||||||
size={1400}
|
size={1400}
|
||||||
opened={isModalOpen}
|
opened={isModalOpen}
|
||||||
onClose={() => setModalOpen(false)}
|
onClose={() => setModalOpen(false)}
|
||||||
|
aria-label={t("Page history")}
|
||||||
>
|
>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useMediaQuery } from "@mantine/hooks";
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
function getTitle(name: string, icon: string) {
|
function getTitle(name: string, icon: string) {
|
||||||
if (icon) {
|
if (icon) {
|
||||||
@@ -28,6 +29,7 @@ function getTitle(name: string, icon: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Breadcrumb() {
|
export default function Breadcrumb() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const treeData = useAtomValue(treeDataAtom);
|
const treeData = useAtomValue(treeDataAtom);
|
||||||
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
|
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
|
||||||
SpaceTreeNode[] | null
|
SpaceTreeNode[] | null
|
||||||
@@ -80,7 +82,7 @@ export default function Breadcrumb() {
|
|||||||
));
|
));
|
||||||
|
|
||||||
const renderAnchor = useCallback(
|
const renderAnchor = useCallback(
|
||||||
(node: SpaceTreeNode) => (
|
(node: SpaceTreeNode, isCurrent = false) => (
|
||||||
<Tooltip label={node.name} key={node.id}>
|
<Tooltip label={node.name} key={node.id}>
|
||||||
<Anchor
|
<Anchor
|
||||||
component={Link}
|
component={Link}
|
||||||
@@ -89,6 +91,7 @@ export default function Breadcrumb() {
|
|||||||
fz="sm"
|
fz="sm"
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className={classes.truncatedText}
|
className={classes.truncatedText}
|
||||||
|
aria-current={isCurrent ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{getTitle(node.name, node.icon)}
|
{getTitle(node.name, node.icon)}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
@@ -115,7 +118,11 @@ export default function Breadcrumb() {
|
|||||||
key="hidden-nodes"
|
key="hidden-nodes"
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<ActionIcon color="gray" variant="transparent">
|
<ActionIcon
|
||||||
|
color="gray"
|
||||||
|
variant="transparent"
|
||||||
|
aria-label={t("Show hidden breadcrumbs")}
|
||||||
|
>
|
||||||
<IconDots size={20} stroke={2} />
|
<IconDots size={20} stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
@@ -124,11 +131,13 @@ export default function Breadcrumb() {
|
|||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>,
|
</Popover>,
|
||||||
//renderAnchor(secondLastNode),
|
//renderAnchor(secondLastNode),
|
||||||
renderAnchor(lastNode),
|
renderAnchor(lastNode, true),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return breadcrumbNodes.map(renderAnchor);
|
return breadcrumbNodes.map((node, i) =>
|
||||||
|
renderAnchor(node, i === breadcrumbNodes.length - 1),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMobileBreadcrumbItems = () => {
|
const getMobileBreadcrumbItems = () => {
|
||||||
@@ -144,8 +153,12 @@ export default function Breadcrumb() {
|
|||||||
key="mobile-hidden-nodes"
|
key="mobile-hidden-nodes"
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label="Breadcrumbs">
|
<Tooltip label={t("Breadcrumbs")}>
|
||||||
<ActionIcon color="gray" variant="transparent">
|
<ActionIcon
|
||||||
|
color="gray"
|
||||||
|
variant="transparent"
|
||||||
|
aria-label={t("Breadcrumbs")}
|
||||||
|
>
|
||||||
<IconCornerDownRightDouble size={20} stroke={2} />
|
<IconCornerDownRightDouble size={20} stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -157,16 +170,18 @@ export default function Breadcrumb() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return breadcrumbNodes.map(renderAnchor);
|
return breadcrumbNodes.map((node, i) =>
|
||||||
|
renderAnchor(node, i === breadcrumbNodes.length - 1),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.breadcrumbDiv}>
|
<nav aria-label={t("Breadcrumb")} className={classes.breadcrumbDiv}>
|
||||||
{breadcrumbNodes && (
|
{breadcrumbNodes && (
|
||||||
<Breadcrumbs className={classes.breadcrumbs}>
|
<Breadcrumbs className={classes.breadcrumbs}>
|
||||||
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
|
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
)}
|
)}
|
||||||
</div>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ActionIcon, Group, Menu, Text, Tooltip } from "@mantine/core";
|
import { ActionIcon, Group, Menu, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconArrowsHorizontal,
|
IconArrowsHorizontal,
|
||||||
@@ -99,6 +99,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="dark"
|
color="dark"
|
||||||
|
aria-label={t("Comments")}
|
||||||
onClick={() => toggleAside("comments")}
|
onClick={() => toggleAside("comments")}
|
||||||
>
|
>
|
||||||
<IconMessage size={20} stroke={2} />
|
<IconMessage size={20} stroke={2} />
|
||||||
@@ -109,6 +110,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="dark"
|
color="dark"
|
||||||
|
aria-label={t("Table of contents")}
|
||||||
onClick={() => toggleAside("toc")}
|
onClick={() => toggleAside("toc")}
|
||||||
>
|
>
|
||||||
<IconList size={20} stroke={2} />
|
<IconList size={20} stroke={2} />
|
||||||
@@ -205,7 +207,11 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
arrowPosition="center"
|
arrowPosition="center"
|
||||||
>
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="subtle" color="dark">
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="dark"
|
||||||
|
aria-label={t("Page actions")}
|
||||||
|
>
|
||||||
<IconDots size={20} />
|
<IconDots size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
@@ -416,9 +422,15 @@ function ConnectionWarning() {
|
|||||||
openDelay={250}
|
openDelay={250}
|
||||||
withArrow
|
withArrow
|
||||||
>
|
>
|
||||||
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
|
<ThemeIcon
|
||||||
|
variant="default"
|
||||||
|
c="red"
|
||||||
|
role="status"
|
||||||
|
aria-label={t("Real-time editor connection lost. Retrying...")}
|
||||||
|
style={{ border: "none" }}
|
||||||
|
>
|
||||||
<IconWifiOff size={20} stroke={2} />
|
<IconWifiOff size={20} stroke={2} />
|
||||||
</ActionIcon>
|
</ThemeIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function PageImportModal({
|
|||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
<Modal.Header py={0}>
|
<Modal.Header py={0}>
|
||||||
<Modal.Title fw={500}>{t("Import pages")}</Modal.Title>
|
<Modal.Title fw={500}>{t("Import pages")}</Modal.Title>
|
||||||
<Modal.CloseButton />
|
<Modal.CloseButton aria-label={t("Close")} />
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<ImportFormatSelection spaceId={spaceId} onClose={onClose} />
|
<ImportFormatSelection spaceId={spaceId} onClose={onClose} />
|
||||||
@@ -332,7 +332,15 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SimpleGrid cols={2}>
|
<SimpleGrid cols={2}>
|
||||||
<FileButton onChange={handleFileUpload} accept=".md" multiple resetRef={markdownFileRef}>
|
<FileButton
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
accept=".md"
|
||||||
|
multiple
|
||||||
|
resetRef={markdownFileRef}
|
||||||
|
inputProps={{
|
||||||
|
"aria-label": t("Choose {{format}} file", { format: "Markdown" }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Button
|
<Button
|
||||||
justify="start"
|
justify="start"
|
||||||
@@ -345,7 +353,15 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
)}
|
)}
|
||||||
</FileButton>
|
</FileButton>
|
||||||
|
|
||||||
<FileButton onChange={handleFileUpload} accept="text/html" multiple resetRef={htmlFileRef}>
|
<FileButton
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
accept="text/html"
|
||||||
|
multiple
|
||||||
|
resetRef={htmlFileRef}
|
||||||
|
inputProps={{
|
||||||
|
"aria-label": t("Choose {{format}} file", { format: "HTML" }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Button
|
<Button
|
||||||
justify="start"
|
justify="start"
|
||||||
@@ -363,6 +379,9 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
accept=".docx"
|
accept=".docx"
|
||||||
multiple
|
multiple
|
||||||
resetRef={docxFileRef}
|
resetRef={docxFileRef}
|
||||||
|
inputProps={{
|
||||||
|
"aria-label": t("Choose {{format}} file", { format: "Word (DOCX)" }),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -387,6 +406,9 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
accept=".pdf"
|
accept=".pdf"
|
||||||
multiple
|
multiple
|
||||||
resetRef={pdfFileRef}
|
resetRef={pdfFileRef}
|
||||||
|
inputProps={{
|
||||||
|
"aria-label": t("Choose {{format}} file", { format: "PDF" }),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -410,6 +432,9 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
onChange={(file) => handleZipUpload(file, "notion")}
|
onChange={(file) => handleZipUpload(file, "notion")}
|
||||||
accept="application/zip"
|
accept="application/zip"
|
||||||
resetRef={notionFileRef}
|
resetRef={notionFileRef}
|
||||||
|
inputProps={{
|
||||||
|
"aria-label": t("Choose {{format}} file", { format: "Notion" }),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -426,6 +451,9 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
onChange={(file) => handleZipUpload(file, "confluence")}
|
onChange={(file) => handleZipUpload(file, "confluence")}
|
||||||
accept="application/zip"
|
accept="application/zip"
|
||||||
resetRef={confluenceFileRef}
|
resetRef={confluenceFileRef}
|
||||||
|
inputProps={{
|
||||||
|
"aria-label": t("Choose {{format}} file", { format: "Confluence" }),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -463,6 +491,9 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
onChange={(file) => handleZipUpload(file, "generic")}
|
onChange={(file) => handleZipUpload(file, "generic")}
|
||||||
accept="application/zip"
|
accept="application/zip"
|
||||||
resetRef={zipFileRef}
|
resetRef={zipFileRef}
|
||||||
|
inputProps={{
|
||||||
|
"aria-label": t("Choose {{format}} file", { format: "ZIP" }),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Group justify="center">
|
<Group justify="center">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function TrashPageContentModal({
|
|||||||
const title = pageTitle || t("Untitled");
|
const title = pageTitle || t("Untitled");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal.Root size={1200} opened={opened} onClose={onClose}>
|
<Modal.Root size={1200} opened={opened} onClose={onClose} aria-label={t("Preview")}>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export default function Trash() {
|
|||||||
<Table.Th style={{ whiteSpace: "nowrap" }}>
|
<Table.Th style={{ whiteSpace: "nowrap" }}>
|
||||||
{t("Deleted at")}
|
{t("Deleted at")}
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
<Table.Th aria-label={t("Action")} />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
|
|||||||
@@ -458,6 +458,8 @@ interface CreateNodeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
|
function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
if (node.data.hasChildren && node.children.length === 0) {
|
if (node.data.hasChildren && node.children.length === 0) {
|
||||||
node.toggle();
|
node.toggle();
|
||||||
@@ -475,6 +477,7 @@ function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
c="gray"
|
c="gray"
|
||||||
|
aria-label={t("Create page")}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -591,6 +594,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
c="gray"
|
c="gray"
|
||||||
|
aria-label={t("Page menu")}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -725,6 +729,8 @@ interface PageArrowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PageArrow({ node, onExpandTree }: PageArrowProps) {
|
function PageArrow({ node, onExpandTree }: PageArrowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (node.isOpen) {
|
if (node.isOpen) {
|
||||||
onExpandTree();
|
onExpandTree();
|
||||||
@@ -736,6 +742,8 @@ function PageArrow({ node, onExpandTree }: PageArrowProps) {
|
|||||||
size={20}
|
size={20}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
c="gray"
|
c="gray"
|
||||||
|
aria-label={node.isOpen ? t("Collapse") : t("Expand")}
|
||||||
|
aria-expanded={node.isInternal ? node.isOpen : undefined}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function SearchMobileControl({ onSearch }: SearchMobileControlProps) {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="dark"
|
color="dark"
|
||||||
|
aria-label={t("Search")}
|
||||||
onClick={onSearch}
|
onClick={onSearch}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function SessionList() {
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>{t("Device Name")}</Table.Th>
|
<Table.Th>{t("Device Name")}</Table.Th>
|
||||||
<Table.Th>{t("Last Active")}</Table.Th>
|
<Table.Th>{t("Last Active")}</Table.Th>
|
||||||
<Table.Th />
|
<Table.Th aria-label={t("Action")} />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
@@ -94,7 +94,7 @@ export default function SessionList() {
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>{t("Device Name")}</Table.Th>
|
<Table.Th>{t("Device Name")}</Table.Th>
|
||||||
<Table.Th>{t("Last Active")}</Table.Th>
|
<Table.Th>{t("Last Active")}</Table.Th>
|
||||||
{otherSessions.length > 0 && <Table.Th />}
|
{otherSessions.length > 0 && <Table.Th aria-label={t("Action")} />}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export default function ShareActionMenu({ share }: Props) {
|
|||||||
arrowPosition="center"
|
arrowPosition="center"
|
||||||
>
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="subtle" c="gray">
|
<ActionIcon variant="subtle" c="gray" aria-label={t("More options")}>
|
||||||
<IconDots size={20} stroke={2} />
|
<IconDots size={20} stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export default function ShareShell({
|
|||||||
onClick={toggleTocMobile}
|
onClick={toggleTocMobile}
|
||||||
hiddenFrom="sm"
|
hiddenFrom="sm"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
aria-label={t("Table of contents")}
|
||||||
>
|
>
|
||||||
<IconList size={20} stroke={2} />
|
<IconList size={20} stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -157,6 +158,7 @@ export default function ShareShell({
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
|
aria-label={t("Table of contents")}
|
||||||
onClick={toggleToc}
|
onClick={toggleToc}
|
||||||
visibleFrom="sm"
|
visibleFrom="sm"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export default function SpaceMembersList({
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>{t("Member")}</Table.Th>
|
<Table.Th>{t("Member")}</Table.Th>
|
||||||
<Table.Th>{t("Role")}</Table.Th>
|
<Table.Th>{t("Role")}</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
<Table.Th aria-label={t("Action")} />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
|
|||||||
@@ -49,15 +49,15 @@ function WatchButton({ spaceId, watchedIds, size = 16 }: { spaceId: string; watc
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const label = isWatching ? t("Stop watching space") : t("Watch space");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip label={label} openDelay={250} withArrow>
|
||||||
label={isWatching ? t("Stop watching space") : t("Watch space")}
|
|
||||||
openDelay={250}
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color={isWatching ? "blue" : "gray"}
|
color={isWatching ? "blue" : "gray"}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={isWatching}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
>
|
>
|
||||||
@@ -111,7 +111,7 @@ export default function AllSpacesList({
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>{t("Space")}</Table.Th>
|
<Table.Th>{t("Space")}</Table.Th>
|
||||||
<Table.Th>{t("Members")}</Table.Th>
|
<Table.Th>{t("Members")}</Table.Th>
|
||||||
<Table.Th w={130}></Table.Th>
|
<Table.Th w={130} aria-label={t("Action")} />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
@@ -168,7 +168,11 @@ export default function AllSpacesList({
|
|||||||
<WatchButton spaceId={space.id} watchedIds={watchedIds} size={16} />
|
<WatchButton spaceId={space.id} watchedIds={watchedIds} size={16} />
|
||||||
<Menu position="bottom-end">
|
<Menu position="bottom-end">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="subtle" color="gray">
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={t("Space menu")}
|
||||||
|
>
|
||||||
<IconDots size={16} />
|
<IconDots size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|||||||
-205
@@ -1,205 +0,0 @@
|
|||||||
.trigger {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 3px 6px 3px 6px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
font-size: var(--mantine-font-size-xs);
|
|
||||||
color: light-dark(
|
|
||||||
var(--mantine-color-gray-7),
|
|
||||||
var(--mantine-color-dark-1)
|
|
||||||
);
|
|
||||||
transition: background 120ms ease;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger:hover {
|
|
||||||
background: light-dark(
|
|
||||||
var(--mantine-color-gray-1),
|
|
||||||
var(--mantine-color-dark-5)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.triggerIcon {
|
|
||||||
color: light-dark(
|
|
||||||
var(--mantine-color-gray-5),
|
|
||||||
var(--mantine-color-dark-2)
|
|
||||||
);
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.triggerChev {
|
|
||||||
color: light-dark(
|
|
||||||
var(--mantine-color-gray-5),
|
|
||||||
var(--mantine-color-dark-2)
|
|
||||||
);
|
|
||||||
display: inline-flex;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: light-dark(
|
|
||||||
var(--mantine-color-gray-0),
|
|
||||||
var(--mantine-color-dark-6)
|
|
||||||
);
|
|
||||||
border-bottom: 1px solid
|
|
||||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
|
||||||
color: light-dark(
|
|
||||||
var(--mantine-color-gray-7),
|
|
||||||
var(--mantine-color-dark-1)
|
|
||||||
);
|
|
||||||
font-size: var(--mantine-font-size-xs);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bannerIcon {
|
|
||||||
color: light-dark(
|
|
||||||
var(--mantine-color-gray-6),
|
|
||||||
var(--mantine-color-dark-2)
|
|
||||||
);
|
|
||||||
flex: none;
|
|
||||||
display: inline-flex;
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bannerLink {
|
|
||||||
color: light-dark(
|
|
||||||
var(--mantine-color-gray-9),
|
|
||||||
var(--mantine-color-dark-0)
|
|
||||||
);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
text-decoration-thickness: 1px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bannerLink:hover {
|
|
||||||
text-decoration-thickness: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionLabel {
|
|
||||||
font-size: var(--mantine-font-size-xs);
|
|
||||||
font-weight: 500;
|
|
||||||
color: light-dark(
|
|
||||||
var(--mantine-color-gray-6),
|
|
||||||
var(--mantine-color-dark-2)
|
|
||||||
);
|
|
||||||
margin: 0 0 6px;
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: light-dark(
|
|
||||||
var(--mantine-color-gray-9),
|
|
||||||
var(--mantine-color-dark-0)
|
|
||||||
);
|
|
||||||
font-size: var(--mantine-font-size-sm);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background 100ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row:hover {
|
|
||||||
background: light-dark(
|
|
||||||
var(--mantine-color-gray-1),
|
|
||||||
var(--mantine-color-dark-5)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowIcon {
|
|
||||||
color: light-dark(
|
|
||||||
var(--mantine-color-gray-5),
|
|
||||||
var(--mantine-color-dark-2)
|
|
||||||
);
|
|
||||||
flex: none;
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowEmoji {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
flex: none;
|
|
||||||
display: inline-flex;
|
|
||||||
width: 18px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowTitle {
|
|
||||||
font-weight: 500;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
padding: 2px 7px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: light-dark(
|
|
||||||
var(--mantine-color-gray-1),
|
|
||||||
var(--mantine-color-dark-5)
|
|
||||||
);
|
|
||||||
color: light-dark(
|
|
||||||
var(--mantine-color-gray-7),
|
|
||||||
var(--mantine-color-dark-1)
|
|
||||||
);
|
|
||||||
text-transform: uppercase;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badgeAccent {
|
|
||||||
background: light-dark(
|
|
||||||
var(--mantine-color-blue-0),
|
|
||||||
var(--mantine-color-blue-9)
|
|
||||||
);
|
|
||||||
color: light-dark(
|
|
||||||
var(--mantine-color-blue-7),
|
|
||||||
var(--mantine-color-blue-2)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
padding: 18px 14px;
|
|
||||||
text-align: center;
|
|
||||||
color: light-dark(
|
|
||||||
var(--mantine-color-gray-6),
|
|
||||||
var(--mantine-color-dark-2)
|
|
||||||
);
|
|
||||||
font-size: var(--mantine-font-size-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Loader, Popover } from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconChevronDown,
|
|
||||||
IconCornerDownLeft,
|
|
||||||
IconFile,
|
|
||||||
IconInfoCircle,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
|
||||||
import { useReferencesQuery } from "@/features/transclusion/queries/transclusion-query";
|
|
||||||
import type { ReferencingPage } from "@/features/transclusion/types/transclusion.types";
|
|
||||||
import { buildPageUrl } from "@/features/page/page.utils";
|
|
||||||
import classes from "./sync-block-references-dropdown.module.css";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
sourcePageId: string | null;
|
|
||||||
transclusionId: string | null;
|
|
||||||
/** The page currently being viewed - used to mark the "THIS PAGE" badge. */
|
|
||||||
currentPageId: string;
|
|
||||||
/**
|
|
||||||
* Source: trigger reads "Editing original".
|
|
||||||
* Reference: trigger reads "Synced to N other pages".
|
|
||||||
*/
|
|
||||||
mode: "source" | "reference";
|
|
||||||
/** Notified whenever the dropdown opens/closes (for keep-chrome-visible). */
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SyncBlockReferencesDropdown({
|
|
||||||
sourcePageId,
|
|
||||||
transclusionId,
|
|
||||||
currentPageId,
|
|
||||||
mode,
|
|
||||||
onOpenChange,
|
|
||||||
}: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [opened, setOpened] = useState(false);
|
|
||||||
|
|
||||||
const handleOpenChange = (next: boolean) => {
|
|
||||||
setOpened(next);
|
|
||||||
onOpenChange?.(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch eagerly so the "Synced to N other pages" count is correct even
|
|
||||||
// before the dropdown is opened. The cache is keyed on (sourcePageId,
|
|
||||||
// transclusionId), so two views (source + reference) share one fetch.
|
|
||||||
const enabled = !!sourcePageId && !!transclusionId;
|
|
||||||
const { data, isLoading } = useReferencesQuery(
|
|
||||||
sourcePageId,
|
|
||||||
transclusionId,
|
|
||||||
enabled,
|
|
||||||
);
|
|
||||||
|
|
||||||
const allPages: Array<{ page: ReferencingPage; isOriginal: boolean }> = [];
|
|
||||||
if (data?.source) {
|
|
||||||
allPages.push({ page: data.source, isOriginal: true });
|
|
||||||
}
|
|
||||||
for (const ref of data?.references ?? []) {
|
|
||||||
allPages.push({ page: ref, isOriginal: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherCount = allPages.filter((p) => p.page.id !== currentPageId).length;
|
|
||||||
const label =
|
|
||||||
mode === "source"
|
|
||||||
? t("Editing original")
|
|
||||||
: t("Synced to {{count}} other page", {
|
|
||||||
count: otherCount,
|
|
||||||
defaultValue_one: "Synced to {{count}} other page",
|
|
||||||
defaultValue_other: "Synced to {{count}} other pages",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
position="bottom-start"
|
|
||||||
shadow="lg"
|
|
||||||
opened={opened}
|
|
||||||
onChange={handleOpenChange}
|
|
||||||
width={340}
|
|
||||||
withinPortal
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={classes.trigger}
|
|
||||||
onClick={() => handleOpenChange(!opened)}
|
|
||||||
aria-expanded={opened}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
>
|
|
||||||
<span className={classes.triggerIcon}>
|
|
||||||
<IconCornerDownLeft size={14} stroke={1.8} />
|
|
||||||
</span>
|
|
||||||
<span>{label}</span>
|
|
||||||
<span className={classes.triggerChev}>
|
|
||||||
<IconChevronDown size={12} stroke={2} />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</Popover.Target>
|
|
||||||
|
|
||||||
<Popover.Dropdown className={classes.dropdown}>
|
|
||||||
{mode === "reference" && data?.source && (
|
|
||||||
<div className={classes.banner}>
|
|
||||||
<span className={classes.bannerIcon}>
|
|
||||||
<IconInfoCircle size={16} stroke={1.6} />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<Trans
|
|
||||||
i18nKey="sourceReadOnlyHint"
|
|
||||||
defaults="This section is read-only here. Edit it on the <link>original source page</link>."
|
|
||||||
components={{
|
|
||||||
link: (
|
|
||||||
<Link
|
|
||||||
to={
|
|
||||||
data.source.spaceSlug
|
|
||||||
? buildPageUrl(
|
|
||||||
data.source.spaceSlug,
|
|
||||||
data.source.slugId,
|
|
||||||
data.source.title,
|
|
||||||
)
|
|
||||||
: `/p/${data.source.id}`
|
|
||||||
}
|
|
||||||
className={classes.bannerLink}
|
|
||||||
onClick={() => handleOpenChange(false)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className={classes.loading}>
|
|
||||||
<Loader size="xs" />
|
|
||||||
</div>
|
|
||||||
) : allPages.length === 0 ? (
|
|
||||||
<div className={classes.empty}>{t("No pages")}</div>
|
|
||||||
) : (
|
|
||||||
<div className={classes.section}>
|
|
||||||
<div className={classes.sectionLabel}>{t("Synced to")}</div>
|
|
||||||
<ul className={classes.list}>
|
|
||||||
{allPages.map(({ page, isOriginal }) => {
|
|
||||||
const isCurrent = page.id === currentPageId;
|
|
||||||
const href = page.spaceSlug
|
|
||||||
? buildPageUrl(page.spaceSlug, page.slugId, page.title)
|
|
||||||
: `/p/${page.id}`;
|
|
||||||
const title = page.title?.length ? page.title : t("Untitled");
|
|
||||||
return (
|
|
||||||
<li key={page.id}>
|
|
||||||
<Link
|
|
||||||
to={href}
|
|
||||||
className={classes.row}
|
|
||||||
onClick={() => handleOpenChange(false)}
|
|
||||||
>
|
|
||||||
{page.icon ? (
|
|
||||||
<span className={classes.rowEmoji}>{page.icon}</span>
|
|
||||||
) : (
|
|
||||||
<span className={classes.rowIcon}>
|
|
||||||
<IconFile size={16} stroke={1.6} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className={classes.rowTitle} title={title}>
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
{isCurrent ? (
|
|
||||||
<span
|
|
||||||
className={`${classes.badge} ${classes.badgeAccent}`}
|
|
||||||
>
|
|
||||||
{t("THIS PAGE")}
|
|
||||||
</span>
|
|
||||||
) : isOriginal ? (
|
|
||||||
<span className={classes.badge}>{t("ORIGINAL")}</span>
|
|
||||||
) : null}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
listReferences,
|
|
||||||
unsyncReference,
|
|
||||||
} from "../services/transclusion-api";
|
|
||||||
|
|
||||||
export function useReferencesQuery(
|
|
||||||
sourcePageId: string | null,
|
|
||||||
transclusionId: string | null,
|
|
||||||
enabled: boolean,
|
|
||||||
) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["transclusion-references", sourcePageId, transclusionId],
|
|
||||||
queryFn: () =>
|
|
||||||
listReferences({
|
|
||||||
sourcePageId: sourcePageId!,
|
|
||||||
transclusionId: transclusionId!,
|
|
||||||
}),
|
|
||||||
enabled: enabled && !!sourcePageId && !!transclusionId,
|
|
||||||
staleTime: 10 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUnsyncReferenceMutation() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (params: {
|
|
||||||
referencePageId: string;
|
|
||||||
sourcePageId: string;
|
|
||||||
transclusionId: string;
|
|
||||||
}) => unsyncReference(params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import type {
|
|
||||||
ReferencingPagesResponse,
|
|
||||||
TransclusionLookup,
|
|
||||||
} from "../types/transclusion.types";
|
|
||||||
|
|
||||||
export async function lookupTransclusion(params: {
|
|
||||||
references: Array<{ sourcePageId: string; transclusionId: string }>;
|
|
||||||
}): Promise<{ items: TransclusionLookup[] }> {
|
|
||||||
const r = await api.post("/pages/transclusion/lookup", params);
|
|
||||||
return r.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listReferences(params: {
|
|
||||||
sourcePageId: string;
|
|
||||||
transclusionId: string;
|
|
||||||
}): Promise<ReferencingPagesResponse> {
|
|
||||||
const r = await api.post("/pages/transclusion/references", params);
|
|
||||||
return r.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unsyncReference(params: {
|
|
||||||
referencePageId: string;
|
|
||||||
sourcePageId: string;
|
|
||||||
transclusionId: string;
|
|
||||||
}): Promise<{ content: unknown }> {
|
|
||||||
const r = await api.post("/pages/transclusion/unsync-reference", params);
|
|
||||||
return r.data;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
export type TransclusionLookup =
|
|
||||||
| {
|
|
||||||
sourcePageId: string;
|
|
||||||
transclusionId: string;
|
|
||||||
content: unknown;
|
|
||||||
sourceUpdatedAt: string;
|
|
||||||
}
|
|
||||||
| { sourcePageId: string; transclusionId: string; status: "not_found" }
|
|
||||||
| { sourcePageId: string; transclusionId: string; status: "no_access" };
|
|
||||||
|
|
||||||
export type ReferencingPage = {
|
|
||||||
id: string;
|
|
||||||
slugId: string;
|
|
||||||
title: string | null;
|
|
||||||
icon: string | null;
|
|
||||||
spaceId: string;
|
|
||||||
spaceSlug: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ReferencingPagesResponse = {
|
|
||||||
source: ReferencingPage | null;
|
|
||||||
references: ReferencingPage[];
|
|
||||||
};
|
|
||||||
+5
-1
@@ -83,7 +83,11 @@ export default function MemberActionMenu({ userId, deactivatedAt }: Props) {
|
|||||||
arrowPosition="center"
|
arrowPosition="center"
|
||||||
>
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="subtle" c="gray">
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
c="gray"
|
||||||
|
aria-label={t("Member actions")}
|
||||||
|
>
|
||||||
<IconDots size={20} stroke={2} />
|
<IconDots size={20} stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|||||||
+1
@@ -34,6 +34,7 @@ export default function WorkspaceInvitesTable() {
|
|||||||
<Table.Th>{t("Email")}</Table.Th>
|
<Table.Th>{t("Email")}</Table.Th>
|
||||||
<Table.Th>{t("Role")}</Table.Th>
|
<Table.Th>{t("Role")}</Table.Th>
|
||||||
<Table.Th>{t("Date")}</Table.Th>
|
<Table.Th>{t("Date")}</Table.Th>
|
||||||
|
<Table.Th aria-label={t("Action")} />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
|
|||||||
+1
@@ -61,6 +61,7 @@ export default function WorkspaceMembersTable() {
|
|||||||
<Table.Th>{t("User")}</Table.Th>
|
<Table.Th>{t("User")}</Table.Th>
|
||||||
<Table.Th>{t("Status")}</Table.Th>
|
<Table.Th>{t("Status")}</Table.Th>
|
||||||
<Table.Th>{t("Role")}</Table.Th>
|
<Table.Th>{t("Role")}</Table.Th>
|
||||||
|
<Table.Th aria-label={t("Action")} />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
ActionIcon,
|
ThemeIcon,
|
||||||
Button,
|
Button,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -71,13 +71,13 @@ export default function FavoritesPage() {
|
|||||||
>
|
>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{fav.page.icon || (
|
{fav.page.icon || (
|
||||||
<ActionIcon
|
<ThemeIcon
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
color="gray"
|
color="gray"
|
||||||
size={18}
|
size={18}
|
||||||
>
|
>
|
||||||
<IconFileDescription size={18} />
|
<IconFileDescription size={18} />
|
||||||
</ActionIcon>
|
</ThemeIcon>
|
||||||
)}
|
)}
|
||||||
<Text fw={500} size="md" lineClamp={1}>
|
<Text fw={500} size="md" lineClamp={1}>
|
||||||
{fav.page.title || t("Untitled")}
|
{fav.page.title || t("Untitled")}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
|
|||||||
"--input-error-size": theme.fontSizes.sm,
|
"--input-error-size": theme.fontSizes.sm,
|
||||||
},
|
},
|
||||||
light: {
|
light: {
|
||||||
"--mantine-color-dimmed": "#6b7280",
|
"--mantine-color-dimmed": "#4b5563",
|
||||||
"--mantine-color-dark-light-color": "#4e5359",
|
"--mantine-color-dark-light-color": "#4e5359",
|
||||||
"--mantine-color-dark-light-hover": "var(--mantine-color-gray-light-hover)",
|
"--mantine-color-dark-light-hover": "var(--mantine-color-gray-light-hover)",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { LoggerExtension } from './extensions/logger.extension';
|
|||||||
import { CollaborationHandler } from './collaboration.handler';
|
import { CollaborationHandler } from './collaboration.handler';
|
||||||
import { CollabHistoryService } from './services/collab-history.service';
|
import { CollabHistoryService } from './services/collab-history.service';
|
||||||
import { WatcherModule } from '../core/watcher/watcher.module';
|
import { WatcherModule } from '../core/watcher/watcher.module';
|
||||||
import { TransclusionService } from '../core/page/transclusion/transclusion.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -29,7 +28,6 @@ import { TransclusionService } from '../core/page/transclusion/transclusion.serv
|
|||||||
HistoryProcessor,
|
HistoryProcessor,
|
||||||
CollabHistoryService,
|
CollabHistoryService,
|
||||||
CollaborationHandler,
|
CollaborationHandler,
|
||||||
TransclusionService,
|
|
||||||
],
|
],
|
||||||
exports: [CollaborationGateway],
|
exports: [CollaborationGateway],
|
||||||
imports: [TokenModule, WatcherModule],
|
imports: [TokenModule, WatcherModule],
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ import {
|
|||||||
Status,
|
Status,
|
||||||
addUniqueIdsToDoc,
|
addUniqueIdsToDoc,
|
||||||
htmlToMarkdown,
|
htmlToMarkdown,
|
||||||
Transclusion,
|
|
||||||
TransclusionReference,
|
|
||||||
} 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';
|
||||||
@@ -103,8 +101,6 @@ export const tiptapExtensions = [
|
|||||||
Columns,
|
Columns,
|
||||||
Column,
|
Column,
|
||||||
Status,
|
Status,
|
||||||
Transclusion,
|
|
||||||
TransclusionReference,
|
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
export function jsonToHtml(tiptapJson: any) {
|
export function jsonToHtml(tiptapJson: any) {
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
HISTORY_FAST_THRESHOLD,
|
HISTORY_FAST_THRESHOLD,
|
||||||
HISTORY_INTERVAL,
|
HISTORY_INTERVAL,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PersistenceExtension implements Extension {
|
export class PersistenceExtension implements Extension {
|
||||||
@@ -46,7 +45,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
|
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
|
||||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||||
private readonly collabHistory: CollabHistoryService,
|
private readonly collabHistory: CollabHistoryService,
|
||||||
private readonly transclusionService: TransclusionService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||||
@@ -136,11 +134,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
try {
|
try {
|
||||||
const existingContributors = page.contributorIds || [];
|
const existingContributors = page.contributorIds || [];
|
||||||
contributorIds = Array.from(
|
contributorIds = Array.from(
|
||||||
new Set([
|
new Set([...existingContributors, ...editingUserIds, page.creatorId]),
|
||||||
...existingContributors,
|
|
||||||
...editingUserIds,
|
|
||||||
page.creatorId,
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||||
@@ -164,10 +158,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
this.logger.error(`Failed to update page ${pageId}`, err);
|
this.logger.error(`Failed to update page ${pageId}`, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page) {
|
|
||||||
await this.syncTransclusion(pageId, tiptapJson);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page) {
|
if (page) {
|
||||||
await this.collabHistory.addContributors(pageId, editingUserIds);
|
await this.collabHistory.addContributors(pageId, editingUserIds);
|
||||||
|
|
||||||
@@ -175,9 +165,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
|
|
||||||
const userMentions = extractUserMentions(mentions);
|
const userMentions = extractUserMentions(mentions);
|
||||||
const oldMentions = page.content ? extractMentions(page.content) : [];
|
const oldMentions = page.content ? extractMentions(page.content) : [];
|
||||||
const oldMentionedUserIds = extractUserMentions(oldMentions).map(
|
const oldMentionedUserIds = extractUserMentions(oldMentions).map((m) => m.entityId);
|
||||||
(m) => m.entityId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (userMentions.length > 0) {
|
if (userMentions.length > 0) {
|
||||||
await this.notificationQueue.add(QueueJob.PAGE_MENTION_NOTIFICATION, {
|
await this.notificationQueue.add(QueueJob.PAGE_MENTION_NOTIFICATION, {
|
||||||
@@ -241,29 +229,4 @@ export class PersistenceExtension implements Extension {
|
|||||||
{ jobId: page.id, delay },
|
{ jobId: page.id, delay },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh `page_transclusions` and `page_transclusion_references` to match
|
|
||||||
* the page's current content. Runs outside the page-write transaction and
|
|
||||||
* isolates each call so a failure here cannot affect the page save itself.
|
|
||||||
* The diff is idempotent — the next save converges if a round drops anything.
|
|
||||||
*/
|
|
||||||
private async syncTransclusion(
|
|
||||||
pageId: string,
|
|
||||||
tiptapJson: unknown,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.transclusionService.syncPageTransclusions(pageId, tiptapJson);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(`Failed to sync transclusions for page ${pageId}`, err);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await this.transclusionService.syncPageReferences(pageId, tiptapJson);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to sync transclusion references for page ${pageId}`,
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import { TrashCleanupService } from './services/trash-cleanup.service';
|
|||||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||||
import { WatcherModule } from '../watcher/watcher.module';
|
import { WatcherModule } from '../watcher/watcher.module';
|
||||||
import { TransclusionModule } from './transclusion/transclusion.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [PageController],
|
controllers: [PageController],
|
||||||
providers: [PageService, PageHistoryService, TrashCleanupService],
|
providers: [PageService, PageHistoryService, TrashCleanupService],
|
||||||
exports: [PageService, PageHistoryService],
|
exports: [PageService, PageHistoryService],
|
||||||
imports: [StorageModule, CollaborationModule, WatcherModule, TransclusionModule],
|
imports: [StorageModule, CollaborationModule, WatcherModule],
|
||||||
})
|
})
|
||||||
export class PageModule {}
|
export class PageModule {}
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import {
|
|||||||
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';
|
import { sql } from 'kysely';
|
||||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageService {
|
export class PageService {
|
||||||
@@ -72,7 +71,6 @@ export class PageService {
|
|||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
private collaborationGateway: CollaborationGateway,
|
private collaborationGateway: CollaborationGateway,
|
||||||
private readonly watcherService: WatcherService,
|
private readonly watcherService: WatcherService,
|
||||||
private readonly transclusionService: TransclusionService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
@@ -602,17 +600,6 @@ export class PageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remap transclusion-reference source pages to their copies when
|
|
||||||
// the source page is also being duplicated in the same operation.
|
|
||||||
if (node.type.name === 'transclusionReference') {
|
|
||||||
const sourcePageId = node.attrs.sourcePageId;
|
|
||||||
if (sourcePageId && pageMap.has(sourcePageId)) {
|
|
||||||
const mappedPage = pageMap.get(sourcePageId);
|
|
||||||
//@ts-ignore
|
|
||||||
node.attrs.sourcePageId = mappedPage.newPageId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update internal page links in link marks
|
// Update internal page links in link marks
|
||||||
for (const mark of node.marks) {
|
for (const mark of node.marks) {
|
||||||
if (
|
if (
|
||||||
@@ -672,31 +659,6 @@ export class PageService {
|
|||||||
|
|
||||||
await this.db.insertInto('pages').values(insertablePages).execute();
|
await this.db.insertInto('pages').values(insertablePages).execute();
|
||||||
|
|
||||||
// Extract transclusions from every duplicated page and persist them in
|
|
||||||
// one statement. Duplication bypasses Yjs onStoreDocument; brand-new
|
|
||||||
// pages never have prior rows so we can skip the diff and just bulk-insert.
|
|
||||||
try {
|
|
||||||
await this.transclusionService.insertTransclusionsForPages(
|
|
||||||
insertablePages.map((p) => ({ id: p.id, content: p.content })),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
'Failed to insert transclusions for duplicated pages',
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.transclusionService.insertReferencesForPages(
|
|
||||||
insertablePages.map((p) => ({ id: p.id, content: p.content })),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
'Failed to insert transclusion references for duplicated pages',
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertedPageIds = insertablePages.map((page) => page.id);
|
const insertedPageIds = insertablePages.map((page) => page.id);
|
||||||
this.eventEmitter.emit(EventName.PAGE_CREATED, {
|
this.eventEmitter.emit(EventName.PAGE_CREATED, {
|
||||||
pageIds: insertedPageIds,
|
pageIds: insertedPageIds,
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Type } from 'class-transformer';
|
|
||||||
import {
|
|
||||||
ArrayMaxSize,
|
|
||||||
IsArray,
|
|
||||||
IsString,
|
|
||||||
IsUUID,
|
|
||||||
ValidateNested,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export class LookupReferenceDto {
|
|
||||||
@IsUUID()
|
|
||||||
sourcePageId!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
transclusionId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LookupDto {
|
|
||||||
@IsArray()
|
|
||||||
@ArrayMaxSize(50)
|
|
||||||
@ValidateNested({ each: true })
|
|
||||||
@Type(() => LookupReferenceDto)
|
|
||||||
references!: LookupReferenceDto[];
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { IsString, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
export class ReferencesDto {
|
|
||||||
@IsUUID()
|
|
||||||
sourcePageId!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
transclusionId!: string;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { IsString, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
export class UnsyncReferenceDto {
|
|
||||||
@IsUUID()
|
|
||||||
referencePageId!: string;
|
|
||||||
|
|
||||||
@IsUUID()
|
|
||||||
sourcePageId!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
transclusionId!: string;
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
import {
|
|
||||||
collectReferencesFromPmJson,
|
|
||||||
collectTransclusionsFromPmJson,
|
|
||||||
} from '../utils/transclusion-prosemirror.util';
|
|
||||||
|
|
||||||
describe('collectTransclusionsFromPmJson', () => {
|
|
||||||
it('returns [] for null/undefined doc', () => {
|
|
||||||
expect(collectTransclusionsFromPmJson(null)).toEqual([]);
|
|
||||||
expect(collectTransclusionsFromPmJson(undefined)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns [] for a doc with no transclusion nodes', () => {
|
|
||||||
const doc = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
|
|
||||||
};
|
|
||||||
expect(collectTransclusionsFromPmJson(doc)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts a top-level transclusion with id, name and content', () => {
|
|
||||||
const doc = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusion',
|
|
||||||
attrs: { id: 'abc123', name: 'Pricing' },
|
|
||||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const got = collectTransclusionsFromPmJson(doc);
|
|
||||||
expect(got).toHaveLength(1);
|
|
||||||
expect(got[0].transclusionId).toBe('abc123');
|
|
||||||
expect(got[0].name).toBe('Pricing');
|
|
||||||
expect(got[0].content).toEqual({
|
|
||||||
type: 'doc',
|
|
||||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips transclusion nodes with no id (transient before UniqueID assigns one)', () => {
|
|
||||||
const doc = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{ type: 'transclusion', attrs: {}, content: [{ type: 'paragraph' }] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(collectTransclusionsFromPmJson(doc)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns multiple top-level transclusions', () => {
|
|
||||||
const doc = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{ type: 'transclusion', attrs: { id: 'a' }, content: [{ type: 'paragraph' }] },
|
|
||||||
{ type: 'transclusion', attrs: { id: 'b', name: 'Two' }, content: [{ type: 'paragraph' }] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const got = collectTransclusionsFromPmJson(doc);
|
|
||||||
expect(got.map((e) => e.transclusionId)).toEqual(['a', 'b']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not recurse into a nested transclusion (transclusion cannot contain transclusion per schema, but be defensive)', () => {
|
|
||||||
const doc = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusion',
|
|
||||||
attrs: { id: 'outer' },
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusion',
|
|
||||||
attrs: { id: 'inner' },
|
|
||||||
content: [{ type: 'paragraph' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const got = collectTransclusionsFromPmJson(doc);
|
|
||||||
expect(got.map((e) => e.transclusionId)).toEqual(['outer']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finds transclusions nested inside other block containers (e.g. column)', () => {
|
|
||||||
const doc = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'column',
|
|
||||||
content: [
|
|
||||||
{ type: 'transclusion', attrs: { id: 'inCol' }, content: [{ type: 'paragraph' }] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(collectTransclusionsFromPmJson(doc).map((e) => e.transclusionId)).toEqual([
|
|
||||||
'inCol',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the last id when duplicate ids appear (later wins, deterministic)', () => {
|
|
||||||
const doc = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{ type: 'transclusion', attrs: { id: 'dup', name: 'first' }, content: [{ type: 'paragraph' }] },
|
|
||||||
{ type: 'transclusion', attrs: { id: 'dup', name: 'second' }, content: [{ type: 'paragraph' }] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const got = collectTransclusionsFromPmJson(doc);
|
|
||||||
expect(got).toHaveLength(1);
|
|
||||||
expect(got[0].name).toBe('second');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('collectReferencesFromPmJson', () => {
|
|
||||||
it('returns [] for null/undefined doc', () => {
|
|
||||||
expect(collectReferencesFromPmJson(null)).toEqual([]);
|
|
||||||
expect(collectReferencesFromPmJson(undefined)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns [] for a doc with no transclusionReference nodes', () => {
|
|
||||||
const doc = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(collectReferencesFromPmJson(doc)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts a top-level reference', () => {
|
|
||||||
const doc = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusionReference',
|
|
||||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(collectReferencesFromPmJson(doc)).toEqual([
|
|
||||||
{ containingTransclusionId: null, sourcePageId: 'p1', transclusionId: 'e1' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips references missing sourcePageId or transclusionId', () => {
|
|
||||||
const doc = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{ type: 'transclusionReference', attrs: { transclusionId: 'e1' } },
|
|
||||||
{ type: 'transclusionReference', attrs: { sourcePageId: 'p1' } },
|
|
||||||
{ type: 'transclusionReference', attrs: {} },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(collectReferencesFromPmJson(doc)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finds references nested in other block containers (column, callout, etc.)', () => {
|
|
||||||
const doc = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'column',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusionReference',
|
|
||||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'callout',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusionReference',
|
|
||||||
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(collectReferencesFromPmJson(doc)).toEqual([
|
|
||||||
{ containingTransclusionId: null, sourcePageId: 'p1', transclusionId: 'e1' },
|
|
||||||
{ containingTransclusionId: null, sourcePageId: 'p2', transclusionId: 'e2' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('also finds references nested inside a transclusion (source) node', () => {
|
|
||||||
const doc = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusion',
|
|
||||||
attrs: { id: 'src1' },
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusionReference',
|
|
||||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(collectReferencesFromPmJson(doc)).toEqual([
|
|
||||||
{ containingTransclusionId: 'src1', sourcePageId: 'p1', transclusionId: 'e1' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('dedupes identical (containingTransclusionId, sourcePageId, transclusionId) triples', () => {
|
|
||||||
const doc = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusionReference',
|
|
||||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'transclusionReference',
|
|
||||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'transclusionReference',
|
|
||||||
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(collectReferencesFromPmJson(doc)).toEqual([
|
|
||||||
{ containingTransclusionId: null, sourcePageId: 'p1', transclusionId: 'e1' },
|
|
||||||
{ containingTransclusionId: null, sourcePageId: 'p2', transclusionId: 'e2' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import {
|
|
||||||
rewriteAttachmentsForUnsync,
|
|
||||||
type AttachmentRewritePlan,
|
|
||||||
} from '../utils/transclusion-unsync.util';
|
|
||||||
|
|
||||||
describe('rewriteAttachmentsForUnsync', () => {
|
|
||||||
const fixedIds = (() => {
|
|
||||||
let i = 0;
|
|
||||||
return () => `new-${++i}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns content unchanged when no attachment nodes are present', () => {
|
|
||||||
const content = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const r = rewriteAttachmentsForUnsync(content, fixedIds());
|
|
||||||
expect(r.content).toEqual(content);
|
|
||||||
expect(r.copies).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rewrites attachmentId and src on a single image node', () => {
|
|
||||||
const oldId = '11111111-1111-1111-1111-111111111111';
|
|
||||||
const content = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'image',
|
|
||||||
attrs: {
|
|
||||||
attachmentId: oldId,
|
|
||||||
src: `/api/files/${oldId}/cat.png`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const gen = fixedIds();
|
|
||||||
const r = rewriteAttachmentsForUnsync(content, gen);
|
|
||||||
|
|
||||||
expect(r.copies).toHaveLength(1);
|
|
||||||
const plan: AttachmentRewritePlan = r.copies[0];
|
|
||||||
expect(plan.oldAttachmentId).toBe(oldId);
|
|
||||||
expect(plan.newAttachmentId).toBe('new-1');
|
|
||||||
|
|
||||||
const img = (r.content as any).content[0];
|
|
||||||
expect(img.attrs.attachmentId).toBe('new-1');
|
|
||||||
expect(img.attrs.src).toBe('/api/files/new-1/cat.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rewrites every attachment node type (image, video, audio, attachment, drawio, excalidraw, pdf)', () => {
|
|
||||||
const types = [
|
|
||||||
'image',
|
|
||||||
'video',
|
|
||||||
'audio',
|
|
||||||
'attachment',
|
|
||||||
'drawio',
|
|
||||||
'excalidraw',
|
|
||||||
'pdf',
|
|
||||||
] as const;
|
|
||||||
const content = {
|
|
||||||
type: 'doc',
|
|
||||||
content: types.map((t, i) => ({
|
|
||||||
type: t,
|
|
||||||
attrs: {
|
|
||||||
attachmentId: `old-${i}`,
|
|
||||||
src: `/api/files/old-${i}/file`,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
const r = rewriteAttachmentsForUnsync(content, fixedIds());
|
|
||||||
expect(r.copies).toHaveLength(types.length);
|
|
||||||
expect((r.content as any).content.map((n: any) => n.attrs.attachmentId)).toEqual(
|
|
||||||
Array.from({ length: types.length }, (_, i) => `new-${i + 1}`),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reuses one new id per old attachmentId across nodes (dedupe)', () => {
|
|
||||||
const shared = 'shared-old';
|
|
||||||
const content = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'image',
|
|
||||||
attrs: {
|
|
||||||
attachmentId: shared,
|
|
||||||
src: `/api/files/${shared}/a.png`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'image',
|
|
||||||
attrs: {
|
|
||||||
attachmentId: shared,
|
|
||||||
src: `/api/files/${shared}/a.png`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const r = rewriteAttachmentsForUnsync(content, fixedIds());
|
|
||||||
expect(r.copies).toHaveLength(1);
|
|
||||||
expect(r.copies[0].oldAttachmentId).toBe(shared);
|
|
||||||
const newId = r.copies[0].newAttachmentId;
|
|
||||||
expect((r.content as any).content[0].attrs.attachmentId).toBe(newId);
|
|
||||||
expect((r.content as any).content[1].attrs.attachmentId).toBe(newId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not mutate the input content object', () => {
|
|
||||||
const content = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'image',
|
|
||||||
attrs: { attachmentId: 'old-x', src: '/api/files/old-x/x.png' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const snapshot = JSON.parse(JSON.stringify(content));
|
|
||||||
rewriteAttachmentsForUnsync(content, fixedIds());
|
|
||||||
expect(content).toEqual(snapshot);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips nodes whose attachmentId is missing or not a uuid-shaped string', () => {
|
|
||||||
const content = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{ type: 'image', attrs: {} },
|
|
||||||
{ type: 'image', attrs: { attachmentId: '' } },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const r = rewriteAttachmentsForUnsync(content, fixedIds());
|
|
||||||
expect(r.copies).toEqual([]);
|
|
||||||
expect(r.content).toEqual(content);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('recurses into nested containers (column, callout)', () => {
|
|
||||||
const oldId = 'old-nested';
|
|
||||||
const content = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'callout',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'image',
|
|
||||||
attrs: {
|
|
||||||
attachmentId: oldId,
|
|
||||||
src: `/api/files/${oldId}/x.png`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const r = rewriteAttachmentsForUnsync(content, fixedIds());
|
|
||||||
expect(r.copies).toHaveLength(1);
|
|
||||||
const newId = r.copies[0].newAttachmentId;
|
|
||||||
const inner = (r.content as any).content[0].content[0];
|
|
||||||
expect(inner.attrs.attachmentId).toBe(newId);
|
|
||||||
expect(inner.attrs.src).toBe(`/api/files/${newId}/x.png`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { TransclusionController } from '../transclusion.controller';
|
|
||||||
import { TransclusionService } from '../transclusion.service';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
|
||||||
|
|
||||||
describe('TransclusionController.lookup', () => {
|
|
||||||
let controller: TransclusionController;
|
|
||||||
let service: jest.Mocked<TransclusionService>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
service = {
|
|
||||||
lookup: jest.fn(),
|
|
||||||
listReferences: jest.fn(),
|
|
||||||
unsyncReference: jest.fn(),
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const module = await Test.createTestingModule({
|
|
||||||
controllers: [TransclusionController],
|
|
||||||
providers: [{ provide: TransclusionService, useValue: service }],
|
|
||||||
})
|
|
||||||
.overrideGuard(JwtAuthGuard)
|
|
||||||
.useValue({ canActivate: () => true })
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
controller = module.get(TransclusionController);
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = { id: 'u1' } as any;
|
|
||||||
const ref = { sourcePageId: 'p1', transclusionId: 'e1' };
|
|
||||||
|
|
||||||
it('returns content when lookup succeeds', async () => {
|
|
||||||
service.lookup.mockResolvedValue({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
sourcePageId: 'p1',
|
|
||||||
transclusionId: 'e1',
|
|
||||||
content: { type: 'doc' },
|
|
||||||
sourceUpdatedAt: new Date(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const out = await controller.lookup({ references: [ref] } as any, user);
|
|
||||||
expect(out.items[0]).not.toHaveProperty('status');
|
|
||||||
expect((out.items[0] as any).content).toEqual({ type: 'doc' });
|
|
||||||
expect(service.lookup).toHaveBeenCalledWith([ref], 'u1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns no_access when service says no_access', async () => {
|
|
||||||
service.lookup.mockResolvedValue({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
sourcePageId: 'p1',
|
|
||||||
transclusionId: 'e1',
|
|
||||||
status: 'no_access',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const out = await controller.lookup({ references: [ref] } as any, user);
|
|
||||||
expect((out.items[0] as { status?: string }).status).toBe('no_access');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns not_found when service says not_found', async () => {
|
|
||||||
service.lookup.mockResolvedValue({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
sourcePageId: 'p1',
|
|
||||||
transclusionId: 'e1',
|
|
||||||
status: 'not_found',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const out = await controller.lookup({ references: [ref] } as any, user);
|
|
||||||
expect((out.items[0] as { status?: string }).status).toBe('not_found');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { TransclusionService } from '../transclusion.service';
|
|
||||||
import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo';
|
|
||||||
import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusion-references/page-transclusion-references.repo';
|
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|
||||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
|
||||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
|
||||||
import { StorageService } from '../../../../integrations/storage/storage.service';
|
|
||||||
|
|
||||||
describe('TransclusionService.syncPageTransclusions', () => {
|
|
||||||
let service: TransclusionService;
|
|
||||||
let repo: jest.Mocked<PageTransclusionsRepo>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const mockRepo: jest.Mocked<Partial<PageTransclusionsRepo>> = {
|
|
||||||
findByPageId: jest.fn(),
|
|
||||||
insert: jest.fn(),
|
|
||||||
update: jest.fn(),
|
|
||||||
deleteByPageAndTransclusionIds: jest.fn(),
|
|
||||||
};
|
|
||||||
const module = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
TransclusionService,
|
|
||||||
{ provide: PageTransclusionsRepo, useValue: mockRepo },
|
|
||||||
{ provide: PageTransclusionReferencesRepo, useValue: {} },
|
|
||||||
{ provide: PageRepo, useValue: {} },
|
|
||||||
{ provide: PagePermissionRepo, useValue: {} },
|
|
||||||
{ provide: AttachmentRepo, useValue: {} },
|
|
||||||
{ provide: StorageService, useValue: {} },
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
service = module.get(TransclusionService);
|
|
||||||
repo = module.get(PageTransclusionsRepo);
|
|
||||||
});
|
|
||||||
|
|
||||||
const pageId = '00000000-0000-0000-0000-000000000001';
|
|
||||||
|
|
||||||
it('inserts new transclusions that did not exist before', async () => {
|
|
||||||
repo.findByPageId.mockResolvedValue([]);
|
|
||||||
const pm = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusion',
|
|
||||||
attrs: { id: 'a', name: 'Hello' },
|
|
||||||
content: [{ type: 'paragraph' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await service.syncPageTransclusions(pageId, pm);
|
|
||||||
|
|
||||||
expect(result).toEqual({ inserted: 1, updated: 0, deleted: 0 });
|
|
||||||
expect(repo.insert).toHaveBeenCalledTimes(1);
|
|
||||||
expect(repo.insert).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
pageId,
|
|
||||||
transclusionId: 'a',
|
|
||||||
name: 'Hello',
|
|
||||||
}),
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(repo.update).not.toHaveBeenCalled();
|
|
||||||
expect(repo.deleteByPageAndTransclusionIds).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates transclusions whose name or content changed', async () => {
|
|
||||||
repo.findByPageId.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'row1',
|
|
||||||
pageId,
|
|
||||||
transclusionId: 'a',
|
|
||||||
name: 'Old',
|
|
||||||
content: { type: 'doc', content: [{ type: 'paragraph' }] },
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
} as any,
|
|
||||||
]);
|
|
||||||
const pm = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusion',
|
|
||||||
attrs: { id: 'a', name: 'New' },
|
|
||||||
content: [
|
|
||||||
{ type: 'paragraph', content: [{ type: 'text', text: 'X' }] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await service.syncPageTransclusions(pageId, pm);
|
|
||||||
|
|
||||||
expect(result).toEqual({ inserted: 0, updated: 1, deleted: 0 });
|
|
||||||
expect(repo.update).toHaveBeenCalledWith(
|
|
||||||
pageId,
|
|
||||||
'a',
|
|
||||||
expect.objectContaining({ name: 'New' }),
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips update when name and content are unchanged', async () => {
|
|
||||||
const sameContent = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [{ type: 'paragraph' }],
|
|
||||||
};
|
|
||||||
repo.findByPageId.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'row1',
|
|
||||||
pageId,
|
|
||||||
transclusionId: 'a',
|
|
||||||
name: 'Same',
|
|
||||||
content: sameContent,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
} as any,
|
|
||||||
]);
|
|
||||||
const pm = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusion',
|
|
||||||
attrs: { id: 'a', name: 'Same' },
|
|
||||||
content: sameContent.content,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await service.syncPageTransclusions(pageId, pm);
|
|
||||||
|
|
||||||
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 });
|
|
||||||
expect(repo.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes transclusions that no longer appear in the doc', async () => {
|
|
||||||
repo.findByPageId.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'r',
|
|
||||||
pageId,
|
|
||||||
transclusionId: 'gone',
|
|
||||||
name: null,
|
|
||||||
content: { type: 'doc', content: [] },
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
} as any,
|
|
||||||
]);
|
|
||||||
const pm = { type: 'doc', content: [{ type: 'paragraph' }] };
|
|
||||||
|
|
||||||
const result = await service.syncPageTransclusions(pageId, pm);
|
|
||||||
|
|
||||||
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 1 });
|
|
||||||
expect(repo.deleteByPageAndTransclusionIds).toHaveBeenCalledWith(
|
|
||||||
pageId,
|
|
||||||
['gone'],
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty doc → noop', async () => {
|
|
||||||
repo.findByPageId.mockResolvedValue([]);
|
|
||||||
const result = await service.syncPageTransclusions(pageId, null);
|
|
||||||
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 });
|
|
||||||
expect(repo.insert).not.toHaveBeenCalled();
|
|
||||||
expect(repo.update).not.toHaveBeenCalled();
|
|
||||||
expect(repo.deleteByPageAndTransclusionIds).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes through the trx parameter to repo calls', async () => {
|
|
||||||
repo.findByPageId.mockResolvedValue([]);
|
|
||||||
const trx = { mock: 'trx' } as any;
|
|
||||||
const pm = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{ type: 'transclusion', attrs: { id: 'a' }, content: [{ type: 'paragraph' }] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
await service.syncPageTransclusions(pageId, pm, trx);
|
|
||||||
|
|
||||||
expect(repo.findByPageId).toHaveBeenCalledWith(pageId, trx);
|
|
||||||
expect(repo.insert).toHaveBeenCalledWith(expect.anything(), trx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TransclusionService.syncPageReferences', () => {
|
|
||||||
let service: TransclusionService;
|
|
||||||
let refRepo: jest.Mocked<PageTransclusionReferencesRepo>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const mockTransclusionsRepo: Partial<PageTransclusionsRepo> = {};
|
|
||||||
const mockRefRepo: jest.Mocked<Partial<PageTransclusionReferencesRepo>> = {
|
|
||||||
findByReferencePageId: jest.fn(),
|
|
||||||
insertMany: jest.fn(),
|
|
||||||
deleteByReferenceAndKeys: jest.fn(),
|
|
||||||
findCyclicEdgesForSource: jest.fn().mockResolvedValue([]),
|
|
||||||
deleteByIds: jest.fn(),
|
|
||||||
};
|
|
||||||
const module = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
TransclusionService,
|
|
||||||
{ provide: PageTransclusionsRepo, useValue: mockTransclusionsRepo },
|
|
||||||
{ provide: PageTransclusionReferencesRepo, useValue: mockRefRepo },
|
|
||||||
{ provide: PageRepo, useValue: {} },
|
|
||||||
{ provide: PagePermissionRepo, useValue: {} },
|
|
||||||
{ provide: AttachmentRepo, useValue: {} },
|
|
||||||
{ provide: StorageService, useValue: {} },
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
service = module.get(TransclusionService);
|
|
||||||
refRepo = module.get(PageTransclusionReferencesRepo);
|
|
||||||
});
|
|
||||||
|
|
||||||
const referencePageId = '00000000-0000-0000-0000-000000000001';
|
|
||||||
|
|
||||||
it('inserts new loose references, no deletes when none existed', async () => {
|
|
||||||
refRepo.findByReferencePageId.mockResolvedValue([]);
|
|
||||||
const pm = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusionReference',
|
|
||||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'transclusionReference',
|
|
||||||
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await service.syncPageReferences(referencePageId, pm);
|
|
||||||
|
|
||||||
expect(result).toEqual({ inserted: 2, deleted: 0 });
|
|
||||||
expect(refRepo.insertMany).toHaveBeenCalledWith(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
referencePageId,
|
|
||||||
containingTransclusionId: null,
|
|
||||||
sourcePageId: 'p1',
|
|
||||||
transclusionId: 'e1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
referencePageId,
|
|
||||||
containingTransclusionId: null,
|
|
||||||
sourcePageId: 'p2',
|
|
||||||
transclusionId: 'e2',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(refRepo.deleteByReferenceAndKeys).not.toHaveBeenCalled();
|
|
||||||
// Loose references never seed cycle detection.
|
|
||||||
expect(refRepo.findCyclicEdgesForSource).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('records the containing transclusion when references nest in a source', async () => {
|
|
||||||
refRepo.findByReferencePageId.mockResolvedValue([]);
|
|
||||||
const pm = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusion',
|
|
||||||
attrs: { id: 's1' },
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusionReference',
|
|
||||||
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await service.syncPageReferences(referencePageId, pm);
|
|
||||||
|
|
||||||
expect(result).toEqual({ inserted: 1, deleted: 0 });
|
|
||||||
expect(refRepo.insertMany).toHaveBeenCalledWith(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
referencePageId,
|
|
||||||
containingTransclusionId: 's1',
|
|
||||||
sourcePageId: 'p2',
|
|
||||||
transclusionId: 'e2',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(refRepo.findCyclicEdgesForSource).toHaveBeenCalledWith(
|
|
||||||
'p2',
|
|
||||||
'e2',
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes edges that close a cycle and excludes them from the inserted count', async () => {
|
|
||||||
refRepo.findByReferencePageId.mockResolvedValue([]);
|
|
||||||
refRepo.findCyclicEdgesForSource.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'closing-edge-id',
|
|
||||||
referencePageId,
|
|
||||||
containingTransclusionId: 's1',
|
|
||||||
sourcePageId: 'p2',
|
|
||||||
transclusionId: 'e2',
|
|
||||||
createdAt: new Date(),
|
|
||||||
} as any,
|
|
||||||
]);
|
|
||||||
const pm = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusion',
|
|
||||||
attrs: { id: 's1' },
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusionReference',
|
|
||||||
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await service.syncPageReferences(referencePageId, pm);
|
|
||||||
|
|
||||||
expect(result).toEqual({ inserted: 0, deleted: 0 });
|
|
||||||
expect(refRepo.deleteByIds).toHaveBeenCalledWith(
|
|
||||||
['closing-edge-id'],
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes references that no longer appear', async () => {
|
|
||||||
refRepo.findByReferencePageId.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'r1',
|
|
||||||
referencePageId,
|
|
||||||
containingTransclusionId: null,
|
|
||||||
sourcePageId: 'p1',
|
|
||||||
transclusionId: 'e1',
|
|
||||||
createdAt: new Date(),
|
|
||||||
} as any,
|
|
||||||
]);
|
|
||||||
const pm = { type: 'doc', content: [{ type: 'paragraph' }] };
|
|
||||||
|
|
||||||
const result = await service.syncPageReferences(referencePageId, pm);
|
|
||||||
|
|
||||||
expect(result).toEqual({ inserted: 0, deleted: 1 });
|
|
||||||
expect(refRepo.deleteByReferenceAndKeys).toHaveBeenCalledWith(
|
|
||||||
referencePageId,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
containingTransclusionId: null,
|
|
||||||
sourcePageId: 'p1',
|
|
||||||
transclusionId: 'e1',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(refRepo.insertMany).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is a no-op when desired matches existing exactly', async () => {
|
|
||||||
refRepo.findByReferencePageId.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'r',
|
|
||||||
referencePageId,
|
|
||||||
containingTransclusionId: null,
|
|
||||||
sourcePageId: 'p1',
|
|
||||||
transclusionId: 'e1',
|
|
||||||
createdAt: new Date(),
|
|
||||||
} as any,
|
|
||||||
]);
|
|
||||||
const pm = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusionReference',
|
|
||||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await service.syncPageReferences(referencePageId, pm);
|
|
||||||
|
|
||||||
expect(result).toEqual({ inserted: 0, deleted: 0 });
|
|
||||||
expect(refRepo.insertMany).not.toHaveBeenCalled();
|
|
||||||
expect(refRepo.deleteByReferenceAndKeys).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes through trx parameter to repo calls', async () => {
|
|
||||||
refRepo.findByReferencePageId.mockResolvedValue([]);
|
|
||||||
const trx = { mock: 'trx' } as any;
|
|
||||||
const pm = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'transclusionReference',
|
|
||||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
await service.syncPageReferences(referencePageId, pm, trx);
|
|
||||||
|
|
||||||
expect(refRepo.findByReferencePageId).toHaveBeenCalledWith(
|
|
||||||
referencePageId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
expect(refRepo.insertMany).toHaveBeenCalledWith(expect.anything(), trx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
|
||||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
|
||||||
import { User } from '@docmost/db/types/entity.types';
|
|
||||||
import { TransclusionService } from './transclusion.service';
|
|
||||||
import { LookupDto } from './dto/lookup.dto';
|
|
||||||
import { ReferencesDto } from './dto/references.dto';
|
|
||||||
import { UnsyncReferenceDto } from './dto/unsync-reference.dto';
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Controller('pages/transclusion')
|
|
||||||
export class TransclusionController {
|
|
||||||
constructor(private readonly transclusionService: TransclusionService) {}
|
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('lookup')
|
|
||||||
async lookup(@Body() dto: LookupDto, @AuthUser() user: User) {
|
|
||||||
return this.transclusionService.lookup(
|
|
||||||
dto.references,
|
|
||||||
user?.id ?? null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('references')
|
|
||||||
async references(
|
|
||||||
@Body() dto: ReferencesDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
) {
|
|
||||||
return this.transclusionService.listReferences({
|
|
||||||
sourcePageId: dto.sourcePageId,
|
|
||||||
transclusionId: dto.transclusionId,
|
|
||||||
viewerUserId: user.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('unsync-reference')
|
|
||||||
async unsyncReference(
|
|
||||||
@Body() dto: UnsyncReferenceDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
) {
|
|
||||||
return this.transclusionService.unsyncReference(
|
|
||||||
dto.referencePageId,
|
|
||||||
dto.sourcePageId,
|
|
||||||
dto.transclusionId,
|
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TransclusionController } from './transclusion.controller';
|
|
||||||
import { TransclusionService } from './transclusion.service';
|
|
||||||
import { StorageModule } from '../../../integrations/storage/storage.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [StorageModule],
|
|
||||||
controllers: [TransclusionController],
|
|
||||||
providers: [TransclusionService],
|
|
||||||
exports: [TransclusionService],
|
|
||||||
})
|
|
||||||
export class TransclusionModule {}
|
|
||||||
@@ -1,526 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
ForbiddenException,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
|
||||||
import { v7 as uuid7 } from 'uuid';
|
|
||||||
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
|
|
||||||
import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo';
|
|
||||||
import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusion-references/page-transclusion-references.repo';
|
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|
||||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
|
||||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
|
||||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
|
||||||
import {
|
|
||||||
collectReferencesFromPmJson,
|
|
||||||
collectTransclusionsFromPmJson,
|
|
||||||
} from './utils/transclusion-prosemirror.util';
|
|
||||||
import { rewriteAttachmentsForUnsync } from './utils/transclusion-unsync.util';
|
|
||||||
import { TransclusionLookup } from './transclusion.types';
|
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
|
||||||
|
|
||||||
type ReferencingPageInfo = {
|
|
||||||
id: string;
|
|
||||||
slugId: string;
|
|
||||||
title: string | null;
|
|
||||||
icon: string | null;
|
|
||||||
spaceId: string;
|
|
||||||
spaceSlug: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TransclusionService {
|
|
||||||
private readonly logger = new Logger(TransclusionService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly pageTransclusionsRepo: PageTransclusionsRepo,
|
|
||||||
private readonly pageTransclusionReferencesRepo: PageTransclusionReferencesRepo,
|
|
||||||
private readonly pageRepo: PageRepo,
|
|
||||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
|
||||||
private readonly attachmentRepo: AttachmentRepo,
|
|
||||||
private readonly storageService: StorageService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async syncPageTransclusions(
|
|
||||||
pageId: string,
|
|
||||||
pmJson: unknown,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<{ inserted: number; updated: number; deleted: number }> {
|
|
||||||
const desired = collectTransclusionsFromPmJson(pmJson);
|
|
||||||
const desiredById = new Map(desired.map((d) => [d.transclusionId, d]));
|
|
||||||
|
|
||||||
const existing = await this.pageTransclusionsRepo.findByPageId(pageId, trx);
|
|
||||||
const existingById = new Map(existing.map((e) => [e.transclusionId, e]));
|
|
||||||
|
|
||||||
let inserted = 0;
|
|
||||||
let updated = 0;
|
|
||||||
let deleted = 0;
|
|
||||||
|
|
||||||
for (const d of desired) {
|
|
||||||
const prev = existingById.get(d.transclusionId);
|
|
||||||
if (!prev) {
|
|
||||||
await this.pageTransclusionsRepo.insert(
|
|
||||||
{
|
|
||||||
pageId,
|
|
||||||
transclusionId: d.transclusionId,
|
|
||||||
name: d.name,
|
|
||||||
content: d.content as any,
|
|
||||||
},
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
inserted += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nameChanged = prev.name !== d.name;
|
|
||||||
const contentChanged = !isDeepStrictEqual(prev.content, d.content);
|
|
||||||
if (nameChanged || contentChanged) {
|
|
||||||
await this.pageTransclusionsRepo.update(
|
|
||||||
pageId,
|
|
||||||
d.transclusionId,
|
|
||||||
{ name: d.name, content: d.content as any },
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
updated += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removedIds = existing
|
|
||||||
.filter((e) => !desiredById.has(e.transclusionId))
|
|
||||||
.map((e) => e.transclusionId);
|
|
||||||
if (removedIds.length > 0) {
|
|
||||||
await this.pageTransclusionsRepo.deleteByPageAndTransclusionIds(
|
|
||||||
pageId,
|
|
||||||
removedIds,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
deleted = removedIds.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { inserted, updated, deleted };
|
|
||||||
}
|
|
||||||
|
|
||||||
async syncPageReferences(
|
|
||||||
referencePageId: string,
|
|
||||||
pmJson: unknown,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<{ inserted: number; deleted: number }> {
|
|
||||||
const desired = collectReferencesFromPmJson(pmJson);
|
|
||||||
const keyOf = (s: {
|
|
||||||
containingTransclusionId: string | null;
|
|
||||||
sourcePageId: string;
|
|
||||||
transclusionId: string;
|
|
||||||
}) =>
|
|
||||||
`${s.containingTransclusionId ?? ''}::${s.sourcePageId}::${s.transclusionId}`;
|
|
||||||
const desiredKeys = new Set(desired.map(keyOf));
|
|
||||||
|
|
||||||
const existing = await this.pageTransclusionReferencesRepo.findByReferencePageId(
|
|
||||||
referencePageId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
const existingKeys = new Set(existing.map(keyOf));
|
|
||||||
|
|
||||||
const toInsert = desired
|
|
||||||
.filter((d) => !existingKeys.has(keyOf(d)))
|
|
||||||
.map((d) => ({
|
|
||||||
referencePageId,
|
|
||||||
containingTransclusionId: d.containingTransclusionId,
|
|
||||||
sourcePageId: d.sourcePageId,
|
|
||||||
transclusionId: d.transclusionId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const toDelete = existing
|
|
||||||
.filter((e) => !desiredKeys.has(keyOf(e)))
|
|
||||||
.map((e) => ({
|
|
||||||
containingTransclusionId: e.containingTransclusionId,
|
|
||||||
sourcePageId: e.sourcePageId,
|
|
||||||
transclusionId: e.transclusionId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (toInsert.length > 0) {
|
|
||||||
await this.pageTransclusionReferencesRepo.insertMany(toInsert, trx);
|
|
||||||
}
|
|
||||||
if (toDelete.length > 0) {
|
|
||||||
await this.pageTransclusionReferencesRepo.deleteByReferenceAndKeys(
|
|
||||||
referencePageId,
|
|
||||||
toDelete,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const removedCount = await this.removeCyclicEdgesIntroducedBy(
|
|
||||||
toInsert,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
inserted: toInsert.length - removedCount,
|
|
||||||
deleted: toDelete.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run cycle detection rooted at each newly-introduced edge's target and
|
|
||||||
* delete any closing edge that belongs to a cycle. Lookups for those rows
|
|
||||||
* then return `not_found`, which the editor renders as the cycle-aware
|
|
||||||
* placeholder. Returns the count of rows removed.
|
|
||||||
*/
|
|
||||||
private async removeCyclicEdgesIntroducedBy(
|
|
||||||
candidates: ReadonlyArray<{
|
|
||||||
referencePageId: string;
|
|
||||||
containingTransclusionId: string | null;
|
|
||||||
sourcePageId: string;
|
|
||||||
transclusionId: string;
|
|
||||||
}>,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<number> {
|
|
||||||
const seedKeys = new Set<string>();
|
|
||||||
const seeds: Array<{ sourcePageId: string; transclusionId: string }> = [];
|
|
||||||
for (const c of candidates) {
|
|
||||||
if (c.containingTransclusionId === null) continue;
|
|
||||||
const key = `${c.sourcePageId}::${c.transclusionId}`;
|
|
||||||
if (seedKeys.has(key)) continue;
|
|
||||||
seedKeys.add(key);
|
|
||||||
seeds.push({
|
|
||||||
sourcePageId: c.sourcePageId,
|
|
||||||
transclusionId: c.transclusionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (seeds.length === 0) return 0;
|
|
||||||
|
|
||||||
const offendingIds = new Set<string>();
|
|
||||||
for (const seed of seeds) {
|
|
||||||
const cyclicEdges =
|
|
||||||
await this.pageTransclusionReferencesRepo.findCyclicEdgesForSource(
|
|
||||||
seed.sourcePageId,
|
|
||||||
seed.transclusionId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
for (const edge of cyclicEdges) offendingIds.add(edge.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offendingIds.size === 0) return 0;
|
|
||||||
|
|
||||||
await this.pageTransclusionReferencesRepo.deleteByIds(
|
|
||||||
Array.from(offendingIds),
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
return offendingIds.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract transclusions from each page's PM JSON and bulk-insert into
|
|
||||||
* `page_transclusions` in a single statement. Intended for brand-new pages
|
|
||||||
* (e.g. duplication, import) where there is nothing to diff against.
|
|
||||||
*/
|
|
||||||
async insertTransclusionsForPages(
|
|
||||||
pages: Array<{ id: string; content: unknown }>,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<{ inserted: number }> {
|
|
||||||
const rows: Parameters<PageTransclusionsRepo['insertMany']>[0] = [];
|
|
||||||
for (const page of pages) {
|
|
||||||
const snapshots = collectTransclusionsFromPmJson(page.content);
|
|
||||||
for (const s of snapshots) {
|
|
||||||
rows.push({
|
|
||||||
pageId: page.id,
|
|
||||||
transclusionId: s.transclusionId,
|
|
||||||
name: s.name,
|
|
||||||
content: s.content as any,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rows.length === 0) return { inserted: 0 };
|
|
||||||
await this.pageTransclusionsRepo.insertMany(rows, trx);
|
|
||||||
return { inserted: rows.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walk each page's PM JSON for `transclusionReference` nodes and bulk-insert
|
|
||||||
* one row per `(containing, source, target)` triple. For brand-new pages
|
|
||||||
* (duplication, import) where there is nothing to diff against.
|
|
||||||
*
|
|
||||||
* Cycle detection runs once per distinct seed source after the bulk insert;
|
|
||||||
* any closing edges are removed so lookups return `not_found` and the
|
|
||||||
* editor renders the cycle-aware placeholder.
|
|
||||||
*/
|
|
||||||
async insertReferencesForPages(
|
|
||||||
pages: Array<{ id: string; content: unknown }>,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<{ inserted: number }> {
|
|
||||||
const rows: Array<{
|
|
||||||
referencePageId: string;
|
|
||||||
containingTransclusionId: string | null;
|
|
||||||
sourcePageId: string;
|
|
||||||
transclusionId: string;
|
|
||||||
}> = [];
|
|
||||||
for (const page of pages) {
|
|
||||||
const refs = collectReferencesFromPmJson(page.content);
|
|
||||||
for (const r of refs) {
|
|
||||||
rows.push({
|
|
||||||
referencePageId: page.id,
|
|
||||||
containingTransclusionId: r.containingTransclusionId,
|
|
||||||
sourcePageId: r.sourcePageId,
|
|
||||||
transclusionId: r.transclusionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rows.length === 0) return { inserted: 0 };
|
|
||||||
await this.pageTransclusionReferencesRepo.insertMany(rows, trx);
|
|
||||||
|
|
||||||
const removedCount = await this.removeCyclicEdgesIntroducedBy(rows, trx);
|
|
||||||
return { inserted: rows.length - removedCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
async lookup(
|
|
||||||
references: Array<{ sourcePageId: string; transclusionId: string }>,
|
|
||||||
viewerUserId: string | null,
|
|
||||||
): Promise<{ items: TransclusionLookup[] }> {
|
|
||||||
if (references.length === 0) return { items: [] };
|
|
||||||
|
|
||||||
const items: TransclusionLookup[] = new Array(references.length).fill(null);
|
|
||||||
const pendingIdx = references.map((_, i) => i);
|
|
||||||
|
|
||||||
// 1) permission filter on the candidate pageIds (auth users only;
|
|
||||||
// unauthenticated share viewers get no_access for any private page).
|
|
||||||
const candidatePageIds = Array.from(
|
|
||||||
new Set(pendingIdx.map((i) => references[i].sourcePageId)),
|
|
||||||
);
|
|
||||||
const accessibleSet = viewerUserId
|
|
||||||
? new Set(
|
|
||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
|
||||||
pageIds: candidatePageIds,
|
|
||||||
userId: viewerUserId,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: new Set<string>();
|
|
||||||
|
|
||||||
// 2) one DB hit for all (page_id, transclusion_id) keys still pending and accessible
|
|
||||||
const accessiblePending = pendingIdx.filter((i) =>
|
|
||||||
accessibleSet.has(references[i].sourcePageId),
|
|
||||||
);
|
|
||||||
const rows = await this.pageTransclusionsRepo.findManyByPageAndTransclusion(
|
|
||||||
accessiblePending.map((i) => ({
|
|
||||||
pageId: references[i].sourcePageId,
|
|
||||||
transclusionId: references[i].transclusionId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const rowKey = (r: { pageId: string; transclusionId: string }) =>
|
|
||||||
`${r.pageId}::${r.transclusionId}`;
|
|
||||||
const rowMap = new Map(rows.map((r) => [rowKey(r), r]));
|
|
||||||
|
|
||||||
// 3) pull updatedAt from each accessible page so we can return
|
|
||||||
// sourceUpdatedAt on each successful result.
|
|
||||||
const accessiblePageIds = Array.from(
|
|
||||||
new Set(accessiblePending.map((i) => references[i].sourcePageId)),
|
|
||||||
);
|
|
||||||
const pageMeta = new Map<string, Date>();
|
|
||||||
for (const pid of accessiblePageIds) {
|
|
||||||
const p = await this.pageRepo.findById(pid);
|
|
||||||
if (p && !p.deletedAt) pageMeta.set(p.id, p.updatedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) stitch the results
|
|
||||||
for (const i of pendingIdx) {
|
|
||||||
const ref = references[i];
|
|
||||||
if (!accessibleSet.has(ref.sourcePageId)) {
|
|
||||||
items[i] = {
|
|
||||||
sourcePageId: ref.sourcePageId,
|
|
||||||
transclusionId: ref.transclusionId,
|
|
||||||
status: 'no_access',
|
|
||||||
};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const updatedAt = pageMeta.get(ref.sourcePageId);
|
|
||||||
if (!updatedAt) {
|
|
||||||
items[i] = {
|
|
||||||
sourcePageId: ref.sourcePageId,
|
|
||||||
transclusionId: ref.transclusionId,
|
|
||||||
status: 'not_found',
|
|
||||||
};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = rowMap.get(`${ref.sourcePageId}::${ref.transclusionId}`);
|
|
||||||
if (!row) {
|
|
||||||
items[i] = {
|
|
||||||
sourcePageId: ref.sourcePageId,
|
|
||||||
transclusionId: ref.transclusionId,
|
|
||||||
status: 'not_found',
|
|
||||||
};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
items[i] = {
|
|
||||||
sourcePageId: ref.sourcePageId,
|
|
||||||
transclusionId: ref.transclusionId,
|
|
||||||
content: row.content,
|
|
||||||
sourceUpdatedAt: updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { items };
|
|
||||||
}
|
|
||||||
|
|
||||||
async listReferences(opts: {
|
|
||||||
sourcePageId: string;
|
|
||||||
transclusionId: string;
|
|
||||||
viewerUserId: string;
|
|
||||||
}): Promise<{
|
|
||||||
source: ReferencingPageInfo | null;
|
|
||||||
references: ReferencingPageInfo[];
|
|
||||||
}> {
|
|
||||||
const { sourcePageId, transclusionId, viewerUserId } = opts;
|
|
||||||
|
|
||||||
const referencePageIds =
|
|
||||||
await this.pageTransclusionReferencesRepo.findReferencePageIdsByTransclusion(
|
|
||||||
sourcePageId,
|
|
||||||
transclusionId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const candidatePageIds = Array.from(
|
|
||||||
new Set([sourcePageId, ...referencePageIds]),
|
|
||||||
);
|
|
||||||
const accessibleSet = new Set(
|
|
||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
|
||||||
pageIds: candidatePageIds,
|
|
||||||
userId: viewerUserId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const accessibleIds = candidatePageIds.filter((id) =>
|
|
||||||
accessibleSet.has(id),
|
|
||||||
);
|
|
||||||
if (accessibleIds.length === 0) {
|
|
||||||
return { source: null, references: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await Promise.all(
|
|
||||||
accessibleIds.map((id) =>
|
|
||||||
this.pageRepo.findById(id, { includeSpace: true }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const byId = new Map<string, ReferencingPageInfo>();
|
|
||||||
for (const p of rows) {
|
|
||||||
if (!p || p.deletedAt) continue;
|
|
||||||
const space = (p as Page & { space?: { slug?: string } }).space;
|
|
||||||
byId.set(p.id, {
|
|
||||||
id: p.id,
|
|
||||||
slugId: p.slugId,
|
|
||||||
title: p.title ?? null,
|
|
||||||
icon: p.icon ?? null,
|
|
||||||
spaceId: p.spaceId,
|
|
||||||
spaceSlug: space?.slug ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = byId.get(sourcePageId) ?? null;
|
|
||||||
const references = referencePageIds
|
|
||||||
.map((id) => byId.get(id))
|
|
||||||
.filter((p): p is ReferencingPageInfo => Boolean(p));
|
|
||||||
|
|
||||||
return { source, references };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a `transclusionReference` into a self-contained copy on the
|
|
||||||
* reference page: load source content, generate fresh attachment ids, copy storage
|
|
||||||
* files, insert new attachment rows, return rewritten content. The caller
|
|
||||||
* (controller) returns the content blob to the client which then performs
|
|
||||||
* `editor.commands.insertContentAt(range, content)` to replace the
|
|
||||||
* reference node. The next Yjs save naturally cleans up the
|
|
||||||
* page_transclusion_references row, but we also delete it eagerly here so a
|
|
||||||
* crash between server response and client save doesn't leave a stale row.
|
|
||||||
*/
|
|
||||||
async unsyncReference(
|
|
||||||
referencePageId: string,
|
|
||||||
sourcePageId: string,
|
|
||||||
transclusionId: string,
|
|
||||||
viewerUserId: string,
|
|
||||||
): Promise<{ content: unknown }> {
|
|
||||||
const referencePage = await this.pageRepo.findById(referencePageId);
|
|
||||||
if (!referencePage || referencePage.deletedAt) {
|
|
||||||
throw new NotFoundException('Reference page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourcePage = await this.pageRepo.findById(sourcePageId);
|
|
||||||
if (!sourcePage || sourcePage.deletedAt) {
|
|
||||||
throw new NotFoundException('Source page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessible = new Set(
|
|
||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
|
||||||
pageIds: [referencePageId, sourcePageId],
|
|
||||||
userId: viewerUserId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (!accessible.has(referencePageId) || !accessible.has(sourcePageId)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
const transclusion =
|
|
||||||
await this.pageTransclusionsRepo.findByPageAndTransclusion(
|
|
||||||
sourcePageId,
|
|
||||||
transclusionId,
|
|
||||||
);
|
|
||||||
if (!transclusion) {
|
|
||||||
throw new NotFoundException('Sync block not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { content, copies } = rewriteAttachmentsForUnsync(
|
|
||||||
transclusion.content,
|
|
||||||
() => uuid7(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (copies.length > 0) {
|
|
||||||
const oldIds = copies.map((c) => c.oldAttachmentId);
|
|
||||||
const oldRows = await this.attachmentRepo.findBySpaceId(sourcePage.spaceId);
|
|
||||||
const byOldId = new Map(
|
|
||||||
oldRows
|
|
||||||
.filter(
|
|
||||||
(a) => oldIds.includes(a.id) && a.pageId === sourcePageId,
|
|
||||||
)
|
|
||||||
.map((a) => [a.id, a]),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const plan of copies) {
|
|
||||||
const old = byOldId.get(plan.oldAttachmentId);
|
|
||||||
if (!old) continue;
|
|
||||||
|
|
||||||
const newFilePath = old.filePath
|
|
||||||
.split(plan.oldAttachmentId)
|
|
||||||
.join(plan.newAttachmentId);
|
|
||||||
try {
|
|
||||||
await this.storageService.copy(old.filePath, newFilePath);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
`unsync: failed to copy attachment ${old.id}`,
|
|
||||||
err as Error,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await this.attachmentRepo.insertAttachment({
|
|
||||||
id: plan.newAttachmentId,
|
|
||||||
type: old.type,
|
|
||||||
filePath: newFilePath,
|
|
||||||
fileName: old.fileName,
|
|
||||||
fileSize: old.fileSize,
|
|
||||||
mimeType: old.mimeType,
|
|
||||||
fileExt: old.fileExt,
|
|
||||||
creatorId: viewerUserId,
|
|
||||||
workspaceId: referencePage.workspaceId,
|
|
||||||
pageId: referencePageId,
|
|
||||||
spaceId: referencePage.spaceId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.pageTransclusionReferencesRepo.deleteOne(
|
|
||||||
referencePageId,
|
|
||||||
sourcePageId,
|
|
||||||
transclusionId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { content };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export type TransclusionLookup =
|
|
||||||
| {
|
|
||||||
sourcePageId: string;
|
|
||||||
transclusionId: string;
|
|
||||||
content: unknown;
|
|
||||||
sourceUpdatedAt: Date;
|
|
||||||
}
|
|
||||||
| { sourcePageId: string; transclusionId: string; status: 'not_found' }
|
|
||||||
| { sourcePageId: string; transclusionId: string; status: 'no_access' };
|
|
||||||
|
|
||||||
export type TransclusionNodeSnapshot = {
|
|
||||||
transclusionId: string;
|
|
||||||
name: string | null;
|
|
||||||
content: unknown;
|
|
||||||
};
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { TransclusionNodeSnapshot } from '../transclusion.types';
|
|
||||||
|
|
||||||
const TRANSCLUSION_TYPE = 'transclusion';
|
|
||||||
const REFERENCE_TYPE = 'transclusionReference';
|
|
||||||
|
|
||||||
export type TransclusionReferenceSnapshot = {
|
|
||||||
/**
|
|
||||||
* Id of the `transclusion` (source) node whose content holds this reference,
|
|
||||||
* or `null` if the reference is loose on the page (not nested inside a source).
|
|
||||||
* Used by the cycle-detection CTE to walk source-to-source edges.
|
|
||||||
*/
|
|
||||||
containingTransclusionId: string | null;
|
|
||||||
sourcePageId: string;
|
|
||||||
transclusionId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walks a ProseMirror JSON document and returns one snapshot per top-level
|
|
||||||
* `transclusion` node. Does not recurse into transclusions (schema disallows
|
|
||||||
* nesting). Skips transclusion nodes without an id (transient state). When
|
|
||||||
* duplicate ids are encountered, the later occurrence wins so the result is
|
|
||||||
* deterministic.
|
|
||||||
*/
|
|
||||||
export function collectTransclusionsFromPmJson(
|
|
||||||
doc: unknown,
|
|
||||||
): TransclusionNodeSnapshot[] {
|
|
||||||
if (!doc || typeof doc !== 'object') return [];
|
|
||||||
|
|
||||||
const byId = new Map<string, TransclusionNodeSnapshot>();
|
|
||||||
|
|
||||||
const visit = (node: any): void => {
|
|
||||||
if (!node || typeof node !== 'object') return;
|
|
||||||
|
|
||||||
if (node.type === TRANSCLUSION_TYPE) {
|
|
||||||
const id = node.attrs?.id;
|
|
||||||
if (typeof id === 'string' && id.length > 0) {
|
|
||||||
const name =
|
|
||||||
typeof node.attrs?.name === 'string' && node.attrs.name.length > 0
|
|
||||||
? node.attrs.name
|
|
||||||
: null;
|
|
||||||
byId.set(id, {
|
|
||||||
transclusionId: id,
|
|
||||||
name,
|
|
||||||
content: { type: 'doc', content: node.content ?? [] },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return; // do not recurse into transclusion children
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (const child of node.content) visit(child);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
visit(doc);
|
|
||||||
return Array.from(byId.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walks a ProseMirror JSON document and returns one snapshot per unique
|
|
||||||
* `(containingTransclusionId, sourcePageId, transclusionId)` triple found on
|
|
||||||
* `transclusionReference` nodes. Recurses into every container, including
|
|
||||||
* `transclusion` (a source node may contain a reference to another source).
|
|
||||||
* Order preserved by first-seen.
|
|
||||||
*/
|
|
||||||
export function collectReferencesFromPmJson(
|
|
||||||
doc: unknown,
|
|
||||||
): TransclusionReferenceSnapshot[] {
|
|
||||||
if (!doc || typeof doc !== 'object') return [];
|
|
||||||
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: TransclusionReferenceSnapshot[] = [];
|
|
||||||
|
|
||||||
const visit = (node: any, containingTransclusionId: string | null): void => {
|
|
||||||
if (!node || typeof node !== 'object') return;
|
|
||||||
|
|
||||||
if (node.type === REFERENCE_TYPE) {
|
|
||||||
const sourcePageId = node.attrs?.sourcePageId;
|
|
||||||
const transclusionId = node.attrs?.transclusionId;
|
|
||||||
if (
|
|
||||||
typeof sourcePageId === 'string' &&
|
|
||||||
sourcePageId.length > 0 &&
|
|
||||||
typeof transclusionId === 'string' &&
|
|
||||||
transclusionId.length > 0
|
|
||||||
) {
|
|
||||||
const key = `${containingTransclusionId ?? ''}::${sourcePageId}::${transclusionId}`;
|
|
||||||
if (!seen.has(key)) {
|
|
||||||
seen.add(key);
|
|
||||||
out.push({
|
|
||||||
containingTransclusionId,
|
|
||||||
sourcePageId,
|
|
||||||
transclusionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return; // atom node - no children
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextContainer =
|
|
||||||
node.type === TRANSCLUSION_TYPE && typeof node.attrs?.id === 'string'
|
|
||||||
? node.attrs.id
|
|
||||||
: containingTransclusionId;
|
|
||||||
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (const child of node.content) visit(child, nextContainer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
visit(doc, null);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { isAttachmentNode } from '../../../../common/helpers/prosemirror/utils';
|
|
||||||
|
|
||||||
export type AttachmentRewritePlan = {
|
|
||||||
oldAttachmentId: string;
|
|
||||||
newAttachmentId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RewriteResult = {
|
|
||||||
content: unknown;
|
|
||||||
copies: AttachmentRewritePlan[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walk a ProseMirror JSON tree, rewrite every attachment-like node so its
|
|
||||||
* `attachmentId` (and any `src` substring matching that id) point at a fresh
|
|
||||||
* id. Each unique old id maps to exactly one new id; the caller is responsible
|
|
||||||
* for actually copying the underlying storage file.
|
|
||||||
*
|
|
||||||
* Pure: does not mutate the input. Returns a deep clone.
|
|
||||||
*/
|
|
||||||
export function rewriteAttachmentsForUnsync(
|
|
||||||
content: unknown,
|
|
||||||
generateId: () => string,
|
|
||||||
): RewriteResult {
|
|
||||||
const cloned = content ? JSON.parse(JSON.stringify(content)) : content;
|
|
||||||
const idMap = new Map<string, string>();
|
|
||||||
|
|
||||||
const visit = (node: any): void => {
|
|
||||||
if (!node || typeof node !== 'object') return;
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof node.type === 'string' &&
|
|
||||||
isAttachmentNode(node.type) &&
|
|
||||||
node.attrs
|
|
||||||
) {
|
|
||||||
const oldId = node.attrs.attachmentId;
|
|
||||||
if (typeof oldId === 'string' && oldId.length > 0) {
|
|
||||||
let newId = idMap.get(oldId);
|
|
||||||
if (!newId) {
|
|
||||||
newId = generateId();
|
|
||||||
idMap.set(oldId, newId);
|
|
||||||
}
|
|
||||||
node.attrs.attachmentId = newId;
|
|
||||||
if (typeof node.attrs.src === 'string' && node.attrs.src.includes(oldId)) {
|
|
||||||
node.attrs.src = node.attrs.src.split(oldId).join(newId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (const child of node.content) visit(child);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
visit(cloned);
|
|
||||||
|
|
||||||
const copies: AttachmentRewritePlan[] = Array.from(idMap.entries()).map(
|
|
||||||
([oldAttachmentId, newAttachmentId]) => ({
|
|
||||||
oldAttachmentId,
|
|
||||||
newAttachmentId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { content: cloned, copies };
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,6 @@ 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 { PagePermissionRepo } from './repos/page/page-permission.repo';
|
||||||
import { CommentRepo } from './repos/comment/comment.repo';
|
import { CommentRepo } from './repos/comment/comment.repo';
|
||||||
import { PageTransclusionsRepo } from './repos/page-transclusions/page-transclusions.repo';
|
|
||||||
import { PageTransclusionReferencesRepo } from './repos/page-transclusion-references/page-transclusion-references.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';
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
@@ -77,8 +75,6 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
SpaceMemberRepo,
|
SpaceMemberRepo,
|
||||||
PageRepo,
|
PageRepo,
|
||||||
PagePermissionRepo,
|
PagePermissionRepo,
|
||||||
PageTransclusionsRepo,
|
|
||||||
PageTransclusionReferencesRepo,
|
|
||||||
PageHistoryRepo,
|
PageHistoryRepo,
|
||||||
CommentRepo,
|
CommentRepo,
|
||||||
FavoriteRepo,
|
FavoriteRepo,
|
||||||
@@ -101,8 +97,6 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
SpaceMemberRepo,
|
SpaceMemberRepo,
|
||||||
PageRepo,
|
PageRepo,
|
||||||
PagePermissionRepo,
|
PagePermissionRepo,
|
||||||
PageTransclusionsRepo,
|
|
||||||
PageTransclusionReferencesRepo,
|
|
||||||
PageHistoryRepo,
|
PageHistoryRepo,
|
||||||
CommentRepo,
|
CommentRepo,
|
||||||
FavoriteRepo,
|
FavoriteRepo,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user