Compare commits

..

1 Commits

Author SHA1 Message Date
Philipinho 8b16ac4151 chore: add undici for oidc proxy support 2026-04-24 14:29:57 +01:00
200 changed files with 1878 additions and 7517 deletions
+7 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.80.1", "version": "0.80.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -31,8 +31,8 @@
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0", "highlightjs-sap-abap": "^0.3.0",
"i18next": "25.10.1", "i18next": "^25.10.1",
"i18next-http-backend": "3.0.6", "i18next-http-backend": "^3.0.2",
"jotai": "^2.18.1", "jotai": "^2.18.1",
"jotai-optics": "^0.4.0", "jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@@ -42,7 +42,7 @@
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.13.0", "mermaid": "^11.13.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"posthog-js": "1.372.2", "posthog-js": "1.370.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "3.4.0", "react-arborist": "3.4.0",
"react-clear-modal": "^2.0.18", "react-clear-modal": "^2.0.18",
@@ -50,10 +50,11 @@
"react-drawio": "^1.0.7", "react-drawio": "^1.0.7",
"react-error-boundary": "^6.1.1", "react-error-boundary": "^6.1.1",
"react-helmet-async": "^3.0.0", "react-helmet-async": "^3.0.0",
"react-i18next": "16.5.8", "react-i18next": "^16.5.8",
"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": {
@@ -73,7 +74,7 @@
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^15.13.0", "globals": "^15.13.0",
"optics-ts": "^2.4.1", "optics-ts": "^2.4.1",
"postcss": "^8.5.12", "postcss": "^8.5.8",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1", "prettier": "^3.8.1",
@@ -391,7 +391,7 @@
"Write anything. Enter \"/\" for commands": "Schreiben Sie etwas. Geben Sie \"/\" für Befehle ein", "Write anything. Enter \"/\" for commands": "Schreiben Sie etwas. Geben Sie \"/\" für Befehle ein",
"Write...": "\"Schreiben...\"", "Write...": "\"Schreiben...\"",
"Column count": "Spaltenanzahl", "Column count": "Spaltenanzahl",
"{{count}} Columns": "{{count}} Spalten", "{{count}} Columns": "{count, plural, one {# Spalte} other {# Spalten}}",
"Equal columns": "Gleich breite Spalten", "Equal columns": "Gleich breite Spalten",
"Left sidebar": "Linke Seitenleiste", "Left sidebar": "Linke Seitenleiste",
"Right sidebar": "Rechte Seitenleiste", "Right sidebar": "Rechte Seitenleiste",
@@ -416,7 +416,6 @@
"{{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",
@@ -609,21 +608,25 @@
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.", "Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully", "Image removed successfully": "Image removed successfully",
"API key": "API key", "API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys", "API keys": "API keys",
"API management": "API management", "API management": "API management",
"Are you sure you want to revoke this API key": "Are you sure you want to revoke this API key",
"Create API Key": "Create API Key",
"Custom expiration date": "Custom expiration date", "Custom expiration date": "Custom expiration date",
"Enter a descriptive token name": "Enter a descriptive token name", "Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration", "Expiration": "Expiration",
"Expired": "Expired", "Expired": "Expired",
"Expires": "Expires", "Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used", "Last use": "Last Used",
"No API keys found": "No API keys found", "No API keys found": "No API keys found",
"No expiration": "No expiration", "No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully", "Revoked successfully": "Revoked successfully",
"Select expiration date": "Select expiration date", "Select expiration date": "Select expiration date",
"This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.", "This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
"Update": "Update", "Update API key": "Update API key",
"Update {{credential}}": "Update {{credential}}",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace", "Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace",
"Restrict API key creation to admins": "Restrict API key creation to admins", "Restrict API key creation to admins": "Restrict API key creation to admins",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Only admins and owners can create new API keys. Existing member keys will continue to work.", "Only admins and owners can create new API keys. Existing member keys will continue to work.": "Only admins and owners can create new API keys. Existing member keys will continue to work.",
@@ -870,12 +873,6 @@
"Previous 7 days": "Previous 7 days", "Previous 7 days": "Previous 7 days",
"Previous 30 days": "Previous 30 days", "Previous 30 days": "Previous 30 days",
"Search chats...": "Search chats...", "Search chats...": "Search chats...",
"Search chats": "Search chats",
"Ask anything... Use @ to mention pages": "Ask anything... Use @ to mention pages",
"Ask anything or search your workspace": "Ask anything or search your workspace",
"Welcome to {{name}}": "Welcome to {{name}}",
"Add files": "Add files",
"Mention a page": "Mention a page",
"Start a new chat to see it here.": "Start a new chat to see it here.", "Start a new chat to see it here.": "Start a new chat to see it here.",
"Summarize this page": "Summarize this page", "Summarize this page": "Summarize this page",
"Toggle AI Chat": "Toggle AI Chat", "Toggle AI Chat": "Toggle AI Chat",
@@ -883,68 +880,5 @@
"Try a different search term.": "Try a different search term.", "Try a different search term.": "Try a different search term.",
"Try again": "Try again", "Try again": "Try again",
"Untitled chat": "Untitled chat", "Untitled chat": "Untitled chat",
"What can I help you with?": "What can I help you with?", "What can I help you with?": "What can I help you with?"
"Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Automatically provision users and groups from your identity provider via SCIM.",
"Configure your identity provider with this URL to provision users and groups.": "Configure your identity provider with this URL to provision users and groups.",
"Create {{credential}}": "Create {{credential}}",
"{{credential}} created": "{{credential}} created",
"{{credential}} created successfully": "{{credential}} created successfully",
"Created by": "Created by",
"Custom": "Custom",
"Enable SCIM": "Enable SCIM",
"Enter a descriptive name": "Enter a descriptive name",
"I've saved my {{credential}}": "I've saved my {{credential}}",
"Important": "Important",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Make sure to copy your {{credential}} now. You won't be able to see it again!",
"Never": "Never",
"Revoke {{credential}}": "Revoke {{credential}}",
"SCIM endpoint URL": "SCIM endpoint URL",
"SCIM provisioning": "SCIM provisioning",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM takes precedence over SSO group sync while enabled.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.",
"SCIM token": "SCIM token",
"SCIM tokens": "SCIM tokens",
"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",
"Token": "Token",
"Page menu": "Page menu",
"Expand": "Expand",
"Collapse": "Collapse",
"Comment menu": "Comment menu",
"Group menu": "Group menu",
"Show hidden breadcrumbs": "Show hidden breadcrumbs",
"Breadcrumbs": "Breadcrumbs",
"Page actions": "Page actions",
"Pick emoji": "Pick emoji",
"Template menu": "Template menu",
"Chat menu": "Chat menu",
"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"
"Synced block": "Synced block",
"Create a block that stays in sync across pages.": "Create a block that stays in sync across pages.",
"Editing original": "Editing original",
"Copy synced block": "Copy synced block",
"Unsync": "Unsync",
"Delete synced block": "Delete synced block",
"Synced to {{count}} other page_one": "Synced to {{count}} other page",
"Synced to {{count}} other page_other": "Synced to {{count}} other pages",
"ORIGINAL": "ORIGINAL",
"THIS PAGE": "THIS PAGE",
"No pages": "No pages",
"The original synced block no longer exists": "The original synced block no longer exists",
"You don't have access to this synced block": "You don't have access to this synced block",
"Failed to load this synced block": "Failed to load this synced block"
} }
@@ -80,12 +80,6 @@ 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;
@@ -110,8 +104,6 @@ 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" }}
/> />
@@ -123,8 +115,6 @@ 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,7 +25,6 @@ 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,
ThemeIcon, ActionIcon,
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 || (
<ThemeIcon variant="transparent" color="gray" size={18}> <ActionIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} /> <IconFileDescription size={18} />
</ThemeIcon> </ActionIcon>
)} )}
<Text fw={500} size="md" lineClamp={1}> <Text fw={500} size="md" lineClamp={1}>
@@ -6,14 +6,12 @@ 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) {
@@ -30,7 +28,6 @@ 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 { ThemeIcon } from "@mantine/core"; import { ActionIcon, rem } 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 (
<ThemeIcon variant="light" size="lg" color="gray" radius="xl"> <ActionIcon variant="light" size="lg" color="gray" radius="xl">
<IconUsersGroup stroke={1.5} /> <IconUsersGroup stroke={1.5} />
</ThemeIcon> </ActionIcon>
); );
} }
@@ -28,22 +28,4 @@
} }
} }
.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,7 +1,6 @@
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 {
@@ -24,12 +23,11 @@ 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, tab: asideTab }] = useAtom(asideStateAtom); const [{ isAsideOpen }] = 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);
@@ -81,11 +79,7 @@ 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,
@@ -111,15 +105,6 @@ 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} />
@@ -129,35 +114,19 @@ export default function GlobalAppShell({
{isAiRoute && <AiChatSidebar />} {isAiRoute && <AiChatSidebar />}
{showGlobalSidebar && <GlobalSidebar />} {showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main id="main-content"> <AppShell.Main>
{isSettingsRoute ? ( {isSettingsRoute ? (
<Container size={900} pb={80}> <Container size={900}>{children}</Container>
{children}
</Container>
) : ( ) : (
children children
)} )}
</AppShell.Main> </AppShell.Main>
{isPageRoute && ( {isPageRoute && (
<AppShell.Aside <AppShell.Aside className={classes.aside} p="md" withBorder={false}>
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: var(--mantine-color-dimmed); color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
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, UnstyledButton } from "@mantine/core"; import { ScrollArea, Text, Divider, Modal } from "@mantine/core";
import { import {
IconHome, IconHome,
IconClock, IconClock,
@@ -119,13 +119,17 @@ export default function GlobalSidebar() {
</ScrollArea> </ScrollArea>
<div className={classes.bottomSection}> <div className={classes.bottomSection}>
<UnstyledButton <a
className={classes.link} className={classes.link}
onClick={openInvite} onClick={(e) => {
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>
</UnstyledButton> </a>
<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="dark" color="gray"
inline inline
size={16} size={16}
position="middle-end" position="middle-end"
@@ -13,7 +13,6 @@ import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key"; import { getApiKeys } from "@/ee/api-key";
import { getAuditLogs } from "@/ee/audit/services/audit-service"; import { getAuditLogs } from "@/ee/audit/services/audit-service";
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service"; import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
export const prefetchWorkspaceMembers = () => { export const prefetchWorkspaceMembers = () => {
const params: QueryParams = { limit: 100, query: "" }; const params: QueryParams = { limit: 100, query: "" };
@@ -99,10 +98,3 @@ export const prefetchVerifiedPages = () => {
queryFn: () => getVerificationList(params), queryFn: () => getVerificationList(params),
}); });
}; };
export const prefetchScimTokens = () => {
queryClient.prefetchQuery({
queryKey: ["scim-token-list", { cursor: undefined }],
queryFn: () => getScimTokens({}),
});
};
@@ -31,7 +31,6 @@ import {
prefetchBilling, prefetchBilling,
prefetchGroups, prefetchGroups,
prefetchLicense, prefetchLicense,
prefetchScimTokens,
prefetchShares, prefetchShares,
prefetchSpaces, prefetchSpaces,
prefetchSsoProviders, prefetchSsoProviders,
@@ -205,10 +204,7 @@ export default function SettingsSidebar() {
} }
break; break;
case "Security & SSO": case "Security & SSO":
prefetchHandler = () => { prefetchHandler = prefetchSsoProviders;
prefetchSsoProviders();
prefetchScimTokens();
};
break; break;
case "Public sharing": case "Public sharing":
prefetchHandler = prefetchShares; prefetchHandler = prefetchShares;
@@ -230,6 +226,32 @@ 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 (
@@ -239,41 +261,12 @@ export default function SettingsSidebar() {
position="right" position="right"
withArrow withArrow
> >
<span {linkElement}
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 ( return linkElement;
<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>
); );
@@ -291,7 +284,7 @@ export default function SettingsSidebar() {
}} }}
variant="transparent" variant="transparent"
c="gray" c="gray"
aria-label={t("Back")} aria-label="Back"
> >
<IconArrowLeft stroke={2} /> <IconArrowLeft stroke={2} />
</ActionIcon> </ActionIcon>
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Avatar, MantineColor } from "@mantine/core"; import { Avatar } 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,39 +16,11 @@ 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, color, ...props }: CustomAvatarProps, ref) => { >(({ avatarUrl, name, type, ...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
@@ -56,7 +28,7 @@ export const CustomAvatar = React.forwardRef<
src={avatarLink} src={avatarLink}
name={name} name={name}
alt={name} alt={name}
color={resolvedColor} color="initials"
{...props} {...props}
/> />
); );
@@ -74,18 +74,7 @@ export function PageChildren({
/> />
))} ))}
{hasNextPage && ( {hasNextPage && (
<div <div className={classes.loadMore} onClick={() => fetchNextPage()}>
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,14 +70,11 @@ 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,7 +132,6 @@ 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>
@@ -137,8 +137,7 @@ export default function AiChatSidebar() {
<TextInput <TextInput
className={classes.searchInput} className={classes.searchInput}
placeholder={t("Search chats...")} placeholder="Search chats..."
aria-label={t("Search chats")}
leftSection={<IconSearch size={14} />} leftSection={<IconSearch size={14} />}
size="xs" size="xs"
value={search} value={search}
@@ -178,7 +178,6 @@ export default function AsideChatPanel() {
href="/ai" href="/ai"
variant="subtle" variant="subtle"
color="dark" color="dark"
aria-label={t("New chat")}
onClick={handleNewChat} onClick={handleNewChat}
> >
<IconPlus size={20} stroke={1.75} /> <IconPlus size={20} stroke={1.75} />
@@ -186,23 +185,13 @@ export default function AsideChatPanel() {
</Tooltip> </Tooltip>
<Tooltip label={t("Open full page")} openDelay={250}> <Tooltip label={t("Open full page")} openDelay={250}>
<ActionIcon <ActionIcon variant="subtle" color="dark" onClick={handleExpand}>
variant="subtle"
color="dark"
aria-label={t("Open full page")}
onClick={handleExpand}
>
<IconArrowsDiagonal size={18} stroke={1.5} /> <IconArrowsDiagonal size={18} stroke={1.5} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={t("Close")} openDelay={250}> <Tooltip label={t("Close")} openDelay={250}>
<ActionIcon <ActionIcon variant="subtle" color="dark" onClick={handleClose}>
variant="subtle"
color="dark"
aria-label={t("Close")}
onClick={handleClose}
>
<IconX size={20} stroke={1.75} /> <IconX size={20} stroke={1.75} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -65,7 +65,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
isStreaming={isStreaming} isStreaming={isStreaming}
onSend={onSend} onSend={onSend}
onStop={onStop} onStop={onStop}
placeholder={t("Ask anything... Use @ to mention pages")} placeholder="Ask anything... Use @ to mention pages"
autofocus autofocus
/> />
</div> </div>
@@ -200,7 +200,7 @@ export default function ChatInput({
link: false, link: false,
}), }),
Placeholder.configure({ Placeholder.configure({
placeholder: placeholder || t("Ask anything... Use @ to mention pages"), placeholder: placeholder || "Ask anything... Use @ to mention pages",
}), }),
CharacterCount.configure({ CharacterCount.configure({
limit: 50000, limit: 50000,
@@ -225,10 +225,6 @@ export default function ChatInput({
}), }),
], ],
editorProps: { editorProps: {
attributes: {
"aria-label": placeholder || t("Ask anything... Use @ to mention pages"),
"aria-multiline": "true",
},
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => { keydown: (_view, event) => {
if ( if (
@@ -279,8 +275,6 @@ export default function ChatInput({
type="file" type="file"
accept={ACCEPTED_FILE_TYPES} accept={ACCEPTED_FILE_TYPES}
multiple multiple
aria-label={t("Add files")}
tabIndex={-1}
style={{ display: "none" }} style={{ display: "none" }}
onChange={(e) => handleFileSelect(e.target.files)} onChange={(e) => handleFileSelect(e.target.files)}
/> />
@@ -31,16 +31,7 @@ export default function ChatToolGroup({ toolCalls, isStreaming }: Props) {
<div className={classes.toolGroup}> <div className={classes.toolGroup}>
<div <div
className={classes.toolGroupHeader} className={classes.toolGroupHeader}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={() => setExpanded((prev) => !prev)} onClick={() => setExpanded((prev) => !prev)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((prev) => !prev);
}
}}
> >
{activeLabel ? ( {activeLabel ? (
<IconLoader2 size={12} className={classes.processingSpinner} /> <IconLoader2 size={12} className={classes.processingSpinner} />
@@ -98,7 +98,7 @@
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: var(--mantine-color-dimmed); color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
margin-bottom: var(--mantine-spacing-xs); margin-bottom: var(--mantine-spacing-xs);
} }
@@ -125,7 +125,7 @@
.suggestionsLabel { .suggestionsLabel {
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
font-weight: 500; font-weight: 500;
color: var(--mantine-color-dimmed); color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-bottom: var(--mantine-spacing-sm); margin-bottom: var(--mantine-spacing-sm);
@@ -43,7 +43,7 @@
margin-top: 6px; margin-top: 6px;
text-align: center; text-align: center;
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
color: var(--mantine-color-dimmed); color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
} }
.attachmentChips { .attachmentChips {
@@ -36,7 +36,7 @@
padding: 4px var(--mantine-spacing-xs); padding: 4px var(--mantine-spacing-xs);
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
font-weight: 600; font-weight: 600;
color: var(--mantine-color-dimmed); color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
user-select: none; user-select: none;
} }
@@ -104,7 +104,7 @@
.chatItemDate { .chatItemDate {
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
color: var(--mantine-color-dimmed); color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
white-space: nowrap; white-space: nowrap;
transition: opacity 150ms; transition: opacity 150ms;
} }
@@ -31,7 +31,7 @@ export function ApiKeyCreatedModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("{{credential}} created", { credential: t("API key") })} title={t("API key created")}
size="lg" size="lg"
> >
<Stack gap="md"> <Stack gap="md">
@@ -41,8 +41,7 @@ export function ApiKeyCreatedModal({
color="red" color="red"
> >
{t( {t(
"Make sure to copy your {{credential}} now. You won't be able to see it again!", "Make sure to copy your API key now. You won't be able to see it again!",
{ credential: t("API key") },
)} )}
</Alert> </Alert>
@@ -65,7 +64,7 @@ export function ApiKeyCreatedModal({
</div> </div>
<Button fullWidth onClick={onClose} mt="md"> <Button fullWidth onClick={onClose} mt="md">
{t("I've saved my {{credential}}", { credential: t("API key") })} {t("I've saved my API key")}
</Button> </Button>
</Stack> </Stack>
</Modal> </Modal>
@@ -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 aria-label={t("Action")} /> <Table.Th></Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@@ -106,11 +106,7 @@ export function ApiKeyTable({
<Table.Td> <Table.Td>
<Menu position="bottom-end" withinPortal> <Menu position="bottom-end" withinPortal>
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon variant="subtle" color="gray">
variant="subtle"
color="gray"
aria-label={t("API key menu")}
>
<IconDots size={16} /> <IconDots size={16} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -105,7 +105,7 @@ export function CreateApiKeyModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={handleClose} onClose={handleClose}
title={t("Create {{credential}}", { credential: t("API key") })} title={t("Create API Key")}
size="md" size="md"
> >
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
@@ -30,14 +30,12 @@ export function RevokeApiKeyModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("API key") })} title={t("Revoke API key")}
size="md" size="md"
> >
<Stack gap="md"> <Stack gap="md">
<Text> <Text>
{t("Are you sure you want to revoke this {{credential}}", { {t("Are you sure you want to revoke this API key")}{" "}
credential: t("API key"),
})}{" "}
<strong>{apiKey?.name}</strong>? <strong>{apiKey?.name}</strong>?
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@@ -53,7 +53,7 @@ export function UpdateApiKeyModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("Update {{credential}}", { credential: t("API key") })} title={t("Update API key")}
size="md" size="md"
> >
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
@@ -63,11 +63,7 @@ export function useCreateApiKeyMutation() {
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({ return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
mutationFn: (data) => createApiKey(data), mutationFn: (data) => createApiKey(data),
onSuccess: () => { onSuccess: () => {
notifications.show({ notifications.show({ message: t("API key created successfully") });
message: t("{{credential}} created successfully", {
credential: t("API key"),
}),
});
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: (item) => predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string), ["api-key-list"].includes(item.queryKey[0] as string),
@@ -33,10 +33,6 @@ export const auditEventLabels: Record<string, string> = {
"api_key.updated": "Updated API key", "api_key.updated": "Updated API key",
"api_key.deleted": "Deleted API key", "api_key.deleted": "Deleted API key",
"scim_token.created": "Created SCIM token",
"scim_token.updated": "Updated SCIM token",
"scim_token.deleted": "Deleted SCIM token",
"space.created": "Created space", "space.created": "Created space",
"space.updated": "Updated space", "space.updated": "Updated space",
"space.deleted": "Deleted space", "space.deleted": "Deleted space",
@@ -178,14 +174,6 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "api_key.deleted", label: "Deleted API key" }, { value: "api_key.deleted", label: "Deleted API key" },
], ],
}, },
{
group: "SCIM token",
items: [
{ value: "scim_token.created", label: "Created SCIM token" },
{ value: "scim_token.updated", label: "Updated SCIM token" },
{ value: "scim_token.deleted", label: "Deleted SCIM token" },
],
},
{ {
group: "License", group: "License",
items: [ items: [
-1
View File
@@ -8,7 +8,6 @@ export const Feature = {
AI: 'ai', AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence', CONFLUENCE_IMPORT: 'import:confluence',
DOCX_IMPORT: 'import:docx', DOCX_IMPORT: 'import:docx',
PDF_IMPORT: 'import:pdf',
ATTACHMENT_INDEXING: 'attachment:indexing', ATTACHMENT_INDEXING: 'attachment:indexing',
SECURITY_SETTINGS: 'security:settings', SECURITY_SETTINGS: 'security:settings',
MCP: 'mcp', MCP: 'mcp',
@@ -140,7 +140,7 @@ export function PagePermissionList({
)} )}
</Group> </Group>
<ScrollArea.Autosize mah={400} viewportRef={viewportRef}> <ScrollArea mah={250} viewportRef={viewportRef}>
{sortedMembers.map((member) => ( {sortedMembers.map((member) => (
<PagePermissionItem <PagePermissionItem
key={`${member.type}-${member.id}`} key={`${member.type}-${member.id}`}
@@ -158,7 +158,7 @@ export function PagePermissionList({
<Loader size="xs" /> <Loader size="xs" />
</Center> </Center>
)} )}
</ScrollArea.Autosize> </ScrollArea>
</> </>
); );
} }
@@ -1,12 +1,4 @@
import { import { ActionIcon, Group, Menu, Modal, Text, Tooltip } from "@mantine/core";
ActionIcon,
Group,
Menu,
Modal,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { import {
IconRosetteDiscountCheckFilled, IconRosetteDiscountCheckFilled,
@@ -46,7 +38,6 @@ 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
@@ -106,9 +97,9 @@ export function PageVerificationBadge({
withArrow withArrow
openDelay={250} openDelay={250}
> >
<ThemeIcon variant="subtle" color="gray"> <ActionIcon variant="subtle" color="gray">
<IconShieldCheck size={20} stroke={1.5} /> <IconShieldCheck size={20} stroke={1.5} />
</ThemeIcon> </ActionIcon>
</Tooltip> </Tooltip>
); );
} }
@@ -139,12 +130,7 @@ 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 <ActionIcon variant="subtle" color="gray" onClick={open}>
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>
@@ -1,78 +0,0 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useCreateScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface CreateScimTokenModalProps {
opened: boolean;
onClose: () => void;
onSuccess: (response: IScimToken) => void;
}
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateScimTokenModal({
opened,
onClose,
onSuccess,
}: CreateScimTokenModalProps) {
const { t } = useTranslation();
const createMutation = useCreateScimTokenMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: { name: "" },
});
const handleSubmit = async (data: FormValues) => {
try {
const created = await createMutation.mutateAsync({ name: data.name });
onSuccess(created);
form.reset();
onClose();
} catch (err) {
//
}
};
const handleClose = () => {
form.reset();
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
data-autofocus
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={createMutation.isPending}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -1,55 +0,0 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function EnableScim() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.isScimEnabled ?? false);
const hasAccess = useHasFeature(Feature.SCIM);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ isScimEnabled: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Enable SCIM")}</Text>
<Text size="sm" c="dimmed">
{t(
"Automatically provision users and groups from your identity provider via SCIM.",
)}
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle SCIM provisioning")}
/>
</Tooltip>
</Group>
);
}
@@ -1,61 +0,0 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRevokeScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface RevokeScimTokenModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function RevokeScimTokenModal({
opened,
onClose,
scimToken,
}: RevokeScimTokenModalProps) {
const { t } = useTranslation();
const revokeMutation = useRevokeScimTokenMutation();
const handleRevoke = async () => {
if (!scimToken) return;
await revokeMutation.mutateAsync({ tokenId: scimToken.id });
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this {{credential}}", {
credential: t("SCIM token"),
})}{" "}
<strong>{scimToken?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
{t(
"This action cannot be undone. Your identity provider will stop syncing immediately.",
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleRevoke}
loading={revokeMutation.isPending}
>
{t("Revoke")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -1,69 +0,0 @@
import {
Modal,
Text,
Stack,
Alert,
Group,
Button,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface ScimTokenCreatedModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function ScimTokenCreatedModal({
opened,
onClose,
scimToken,
}: ScimTokenCreatedModalProps) {
const { t } = useTranslation();
if (!scimToken) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("{{credential}} created", { credential: t("SCIM token") })}
size="lg"
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("Important")}
color="red"
>
{t(
"Make sure to copy your {{credential}} now. You won't be able to see it again!",
{ credential: t("SCIM token") },
)}
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
{t("SCIM token")}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{ flex: 1 }}
value={scimToken.token}
readOnly
/>
<CopyTextButton text={scimToken.token} />
</Group>
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my {{credential}}", { credential: t("SCIM token") })}
</Button>
</Stack>
</Modal>
);
}
@@ -1,130 +0,0 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface ScimTokenTableProps {
tokens: IScimToken[];
isLoading?: boolean;
onUpdate?: (token: IScimToken) => void;
onRevoke?: (token: IScimToken) => void;
}
export function ScimTokenTable({
tokens,
isLoading,
onUpdate,
onRevoke,
}: ScimTokenTableProps) {
const { t } = useTranslation();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return format(new Date(date), "MMM dd, yyyy");
};
return (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Token")}</Table.Th>
<Table.Th>{t("Created by")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th aria-label={t("Action")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{tokens && tokens.length > 0 ? (
tokens.map((token) => (
<Table.Tr key={token.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{token.name}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" ff="monospace" c="dimmed">
{token.tokenLastFour}
</Text>
</Table.Td>
{token.creator ? (
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={token.creator?.avatarUrl}
name={token.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{token.creator.name}
</Text>
</Group>
</Table.Td>
) : (
<Table.Td>
<Text fz="sm" c="dimmed">
</Text>
</Table.Td>
)}
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(token.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(token.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onUpdate && (
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={() => onUpdate(token)}
>
{t("Rename")}
</Menu.Item>
)}
{onRevoke && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => onRevoke(token)}
>
{t("Revoke")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={6} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -1,30 +0,0 @@
import { Group, Stack, Text, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
export function ScimUrlPanel() {
const { t } = useTranslation();
const scimUrl = `${window.location.origin}/api/scim/v2`;
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("SCIM endpoint URL")}
</Text>
<Text size="xs" c="dimmed">
{t(
"Configure your identity provider with this URL to provision users and groups.",
)}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{ flex: 1 }}
value={scimUrl}
readOnly
/>
<CopyTextButton text={scimUrl} />
</Group>
</Stack>
);
}
@@ -1,77 +0,0 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useEffect } from "react";
import { useUpdateScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface UpdateScimTokenModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function UpdateScimTokenModal({
opened,
onClose,
scimToken,
}: UpdateScimTokenModalProps) {
const { t } = useTranslation();
const updateMutation = useUpdateScimTokenMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: { name: "" },
});
useEffect(() => {
if (opened && scimToken) {
form.setValues({ name: scimToken.name });
}
}, [opened, scimToken]);
const handleSubmit = async (data: FormValues) => {
if (!scimToken) return;
await updateMutation.mutateAsync({
tokenId: scimToken.id,
name: data.name,
});
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Update {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={updateMutation.isPending}>
{t("Update")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
-2
View File
@@ -1,2 +0,0 @@
export * from "./types/scim-token.types";
export * from "./services/scim-token-service";
@@ -1,96 +0,0 @@
import { IPagination, QueryParams } from "@/lib/types.ts";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
createScimToken,
getScimTokens,
revokeScimToken,
updateScimToken,
} from "@/ee/scim/services/scim-token-service";
import {
IScimToken,
ICreateScimTokenRequest,
IRevokeScimTokenRequest,
IUpdateScimTokenRequest,
} from "@/ee/scim/types/scim-token.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetScimTokensQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IScimToken>, Error> {
return useQuery({
queryKey: ["scim-token-list", params],
queryFn: () => getScimTokens(params),
placeholderData: keepPreviousData,
});
}
export function useCreateScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IScimToken, Error, ICreateScimTokenRequest>({
mutationFn: (data) => createScimToken(data),
onSuccess: () => {
notifications.show({
message: t("{{credential}} created successfully", {
credential: t("SCIM token"),
}),
});
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useUpdateScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IUpdateScimTokenRequest>({
mutationFn: (data) => updateScimToken(data),
onSuccess: () => {
notifications.show({ message: t("Updated successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRevokeScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IRevokeScimTokenRequest>({
mutationFn: (data) => revokeScimToken(data),
onSuccess: () => {
notifications.show({ message: t("Revoked successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -1,34 +0,0 @@
import api from "@/lib/api-client";
import {
IScimToken,
ICreateScimTokenRequest,
IRevokeScimTokenRequest,
IUpdateScimTokenRequest,
} from "@/ee/scim/types/scim-token.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getScimTokens(
params?: QueryParams,
): Promise<IPagination<IScimToken>> {
const req = await api.post("/scim-tokens", { ...params });
return req.data;
}
export async function createScimToken(
data: ICreateScimTokenRequest,
): Promise<IScimToken> {
const req = await api.post<IScimToken>("/scim-tokens/create", data);
return req.data;
}
export async function updateScimToken(
data: IUpdateScimTokenRequest,
): Promise<void> {
await api.post("/scim-tokens/update", data);
}
export async function revokeScimToken(
data: IRevokeScimTokenRequest,
): Promise<void> {
await api.post("/scim-tokens/revoke", data);
}
@@ -1,27 +0,0 @@
import { IUser } from "@/features/user/types/user.types.ts";
export interface IScimToken {
id: string;
name: string;
token?: string;
tokenLastFour: string;
isEnabled: boolean;
creatorId: string;
workspaceId: string;
lastUsedAt: string | null;
createdAt: string;
creator?: Partial<IUser>;
}
export interface ICreateScimTokenRequest {
name: string;
}
export interface IUpdateScimTokenRequest {
tokenId: string;
name: string;
}
export interface IRevokeScimTokenRequest {
tokenId: string;
}
@@ -69,8 +69,8 @@ export default function SsoProviderList() {
return ( return (
<> <>
<Card shadow="sm" radius="sm"> <Card shadow="sm" radius="sm">
<Table.ScrollContainer minWidth={600} maxHeight={400}> <Table.ScrollContainer minWidth={600}>
<Table verticalSpacing="sm" stickyHeader> <Table verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>{t("Name")}</Table.Th> <Table.Th>{t("Name")}</Table.Th>
@@ -141,7 +141,6 @@ 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} />
@@ -153,13 +152,7 @@ export default function SsoProviderList() {
withinPortal withinPortal
> >
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon variant="subtle" color="gray">
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>
+6 -137
View File
@@ -1,18 +1,8 @@
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts"; import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import { import { Divider, Title } from "@mantine/core";
Alert, import React from "react";
Button,
Card,
Divider,
Group,
Space,
Title,
Tooltip,
} from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import React, { useState } from "react";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx"; import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx";
import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx"; import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx";
@@ -22,41 +12,16 @@ import { useTranslation } from "react-i18next";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx"; import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import TrashRetention from "@/ee/security/components/trash-retention.tsx"; import TrashRetention from "@/ee/security/components/trash-retention.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
import { useGetScimTokensQuery } from "@/ee/scim/queries/scim-token-query";
import { ScimUrlPanel } from "@/ee/scim/components/scim-url-panel";
import { ScimTokenTable } from "@/ee/scim/components/scim-token-table";
import { CreateScimTokenModal } from "@/ee/scim/components/create-scim-token-modal";
import { ScimTokenCreatedModal } from "@/ee/scim/components/scim-token-created-modal";
import { RevokeScimTokenModal } from "@/ee/scim/components/revoke-scim-token-modal";
import { UpdateScimTokenModal } from "@/ee/scim/components/update-scim-token-modal";
import EnableScim from "@/ee/scim/components/enable-scim";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import Paginate from "@/components/common/paginate";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
const SCIM_TOKEN_LIMIT = 5;
export default function Security() { export default function Security() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM); const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM);
const hasScim = useHasFeature(Feature.SCIM); const hasRetention = useHasFeature(Feature.RETENTION);
const [workspace] = useAtom(workspaceAtom); const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
const isScimEnabled = workspace?.isScimEnabled ?? false;
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data: scimData, isLoading: scimLoading } = useGetScimTokensQuery(
hasScim && isScimEnabled ? { cursor } : undefined,
);
const [createOpen, setCreateOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<IScimToken | null>(null);
const [updateTarget, setUpdateTarget] = useState<IScimToken | null>(null);
const [revokeTarget, setRevokeTarget] = useState<IScimToken | null>(null);
if (!isAdmin) { if (!isAdmin) {
return null; return null;
@@ -80,7 +45,7 @@ export default function Security() {
<Divider my="lg" /> <Divider my="lg" />
<Title order={4} my="lg"> <Title order={4} my="lg">
{t("Single sign-on (SSO)")} Single sign-on (SSO)
</Title> </Title>
<EnforceSso /> <EnforceSso />
@@ -101,102 +66,6 @@ export default function Security() {
)} )}
<SsoProviderList /> <SsoProviderList />
{hasScim && (
<>
<Divider my="xl" />
<Title order={4} my="lg">
{t("SCIM provisioning")}
</Title>
<Alert
icon={<IconInfoCircle size={16} />}
color="blue"
variant="light"
mb="md"
>
{t("SCIM takes precedence over SSO group sync while enabled.")}
</Alert>
<EnableScim />
<Divider my="lg" />
<ScimUrlPanel />
{isScimEnabled && (
<>
<Divider my="lg" />
<Group justify="space-between" mb="md">
<Title order={5}>{t("SCIM tokens")}</Title>
<Tooltip
label={t(
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.",
{ max: SCIM_TOKEN_LIMIT },
)}
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
refProp="rootRef"
>
<Button
onClick={() => setCreateOpen(true)}
disabled={(scimData?.items.length ?? 0) >= SCIM_TOKEN_LIMIT}
>
{t("Create {{credential}}", {
credential: t("SCIM token"),
})}
</Button>
</Tooltip>
</Group>
<Card shadow="sm" radius="sm">
<ScimTokenTable
tokens={scimData?.items}
isLoading={scimLoading}
onUpdate={setUpdateTarget}
onRevoke={setRevokeTarget}
/>
</Card>
<Space h="md" />
{scimData?.items.length > 0 && (
<Paginate
hasPrevPage={scimData?.meta?.hasPrevPage}
hasNextPage={scimData?.meta?.hasNextPage}
onNext={() => goNext(scimData?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
<CreateScimTokenModal
opened={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={setCreatedToken}
/>
<ScimTokenCreatedModal
opened={!!createdToken}
onClose={() => setCreatedToken(null)}
scimToken={createdToken}
/>
<UpdateScimTokenModal
opened={!!updateTarget}
onClose={() => setUpdateTarget(null)}
scimToken={updateTarget}
/>
<RevokeScimTokenModal
opened={!!revokeTarget}
onClose={() => setRevokeTarget(null)}
scimToken={revokeTarget}
/>
</>
)}
</>
)}
</> </>
); );
} }
@@ -56,7 +56,6 @@ 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} aria-label={title}> <Modal.Root size={1200} opened={opened} onClose={onClose}>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header> <Modal.Header>
@@ -144,7 +144,6 @@ 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>
@@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command"; import EmojiCommand from "@/features/editor/extensions/emoji-command";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion"; import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
import MentionView from "@/features/editor/components/mention/mention-view"; import MentionView from "@/features/editor/components/mention/mention-view";
import { platformModifierKey } from "@/lib";
interface CommentEditorProps { interface CommentEditorProps {
defaultContent?: any; defaultContent?: any;
@@ -84,7 +83,7 @@ const CommentEditor = forwardRef(
} }
} }
if (platformModifierKey(event) && event.code === "Enter") { if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault(); event.preventDefault();
if (onSave) onSave(); if (onSave) onSave();
@@ -173,15 +173,6 @@ 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,11 +46,7 @@ function CommentMenu({
return ( return (
<Menu shadow="md" width={200}> <Menu shadow="md" width={200}>
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon variant="default" style={{ border: "none" }}>
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>
@@ -19,9 +19,7 @@ export const uploadAttachmentAction = handleAttachmentUpload({
}, },
validateFn: (file, allowMedia: boolean) => { validateFn: (file, allowMedia: boolean) => {
if ( if (
(file.type.includes("image/") || (file.type.includes("image/") || file.type.includes("video/")) &&
file.type.includes("video/") ||
file.type === "application/pdf") &&
!allowMedia !allowMedia
) { ) {
return false; return false;
@@ -36,7 +36,6 @@ 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 && (
@@ -46,7 +45,6 @@ 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>
@@ -62,7 +60,7 @@ export default function AudioView(props: NodeViewProps) {
</Group> </Group>
)} )}
{!safeSrc && !previewSrc && !placeholder && ( {!safeSrc && !previewSrc && !placeholder && (
<audio className={classes.audio} controls aria-label={t("Audio")} /> <audio className={classes.audio} controls />
)} )}
</div> </div>
</NodeViewWrapper> </NodeViewWrapper>
@@ -172,9 +172,6 @@ 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>
@@ -189,32 +186,20 @@ 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
role="button" onClick={() => {
tabIndex={0} if (name === "Default") {
aria-label={t(name)} editor.commands.unsetColor();
aria-pressed={!!editorState[`text_${color}`]} } else {
onClick={applyTextColor} editor
onKeyDown={(e) => { .chain()
if (e.key === "Enter" || e.key === " ") { .focus()
e.preventDefault(); .setColor(color || "")
applyTextColor(); .run();
} }
setIsOpen(false);
}} }}
style={{ style={{
width: rem(28), width: rem(28),
@@ -236,8 +221,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
A A
</Box> </Box>
</Tooltip> </Tooltip>
); ))}
})}
</SimpleGrid> </SimpleGrid>
</Box> </Box>
@@ -246,35 +230,23 @@ 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
role="button" onClick={() => {
tabIndex={0} if (name === "Default") {
aria-label={t(name)} editor.commands.unsetHighlight();
aria-pressed={!!editorState[`highlight_${color}`]} } else {
onClick={applyHighlight} editor
onKeyDown={(e) => { .chain()
if (e.key === "Enter" || e.key === " ") { .focus()
e.preventDefault(); .toggleMark("highlight", {
applyHighlight(); color: color || "",
colorName: name.toLowerCase() || "",
})
.run();
} }
setIsOpen(false);
}} }}
style={{ style={{
width: rem(28), width: rem(28),
@@ -302,8 +274,7 @@ 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"),
isTransclusionSource: ctx.editor.isActive("transclusionSource"),
}; };
}, },
}); });
@@ -124,12 +122,6 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
.run(), .run(),
isActive: () => editorState?.isBlockquote, isActive: () => editorState?.isBlockquote,
}, },
{
name: "Synced block",
icon: IconQuote,
command: () => editor.chain().focus().toggleTransclusionSource().run(),
isActive: () => editorState?.isTransclusionSource,
},
{ {
name: "Code", name: "Code",
icon: IconCode, icon: IconCode,
@@ -157,12 +149,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
return ( return (
<Popover opened={isOpen} withArrow> <Popover opened={isOpen} withArrow>
<Popover.Target> <Popover.Target>
<Tooltip <Tooltip label={t("Turn into")} withArrow withinPortal={false} disabled={isOpen}>
label={t("Turn into")}
withArrow
withinPortal={false}
disabled={isOpen}
>
<Button <Button
className={classes.buttonRoot} className={classes.buttonRoot}
variant="default" variant="default"
@@ -170,9 +157,6 @@ 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,9 +92,6 @@ 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,13 +137,7 @@ export default function DrawioView(props: NodeViewProps) {
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<Modal.Root <Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
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,17 +107,7 @@ const EmojiList = ({
}, [selectedIndex]); }, [selectedIndex]);
return items.length > 0 || isLoading ? ( return items.length > 0 || isLoading ? (
<Paper <Paper id="emoji-command" p="0" shadow="md" withBorder>
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
@@ -130,10 +120,6 @@ 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,14 +102,6 @@ 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}
@@ -133,16 +125,10 @@ 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,
@@ -170,9 +156,6 @@ 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,16 +287,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
); );
return ( return (
<Paper <Paper id="mention" shadow="md" withBorder radius="md" py={6}>
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}
@@ -310,7 +301,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}`} role="presentation"> <div key={`${item.label}-${index}`}>
{!isFirst && <Divider my={6} />} {!isFirst && <Divider my={6} />}
<Text <Text
c="dimmed" c="dimmed"
@@ -331,9 +322,6 @@ 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,
@@ -360,9 +348,6 @@ 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,
@@ -373,7 +358,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
component="div" component="div"
aria-hidden="true" aria-label={item.label}
color="gray" color="gray"
size="sm" size="sm"
> >
@@ -405,11 +390,6 @@ 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))
} }
@@ -425,7 +405,6 @@ 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,20 +92,7 @@ export default function PdfView(props: NodeViewProps) {
if (hasError) { if (hasError) {
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<div <div data-pdf-error className={clsx(classes.pdfError, { "ProseMirror-selectednode": selected })} onClick={handleSelect}>
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")}
@@ -187,14 +187,12 @@ 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">
@@ -219,12 +217,7 @@ 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 <ActionIcon variant="subtle" color="gray" onClick={previous}>
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}
@@ -232,12 +225,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={t("Next match (Enter)")}> <Tooltip label={t("Next match (Enter)")}>
<ActionIcon <ActionIcon variant="subtle" color="gray" onClick={next}>
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}
@@ -249,8 +237,6 @@ 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%" }}
@@ -264,8 +250,6 @@ 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%" }}
@@ -275,12 +259,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
</Tooltip> </Tooltip>
)} )}
<Tooltip label={t("Close (Escape)")}> <Tooltip label={t("Close (Escape)")}>
<ActionIcon <ActionIcon variant="subtle" color="gray" onClick={closeDialog}>
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>
@@ -290,7 +269,6 @@ 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,15 +86,7 @@ const CommandList = ({
}, [selectedIndex]); }, [selectedIndex]);
return flatItems.length > 0 ? ( return flatItems.length > 0 ? (
<Paper <Paper id="slash-command" shadow="md" p="xs" withBorder>
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}
@@ -102,30 +94,22 @@ const CommandList = ({
scrollbarSize={8} scrollbarSize={8}
overscrollBehavior="contain" overscrollBehavior="contain"
> >
{(() => { {Object.entries(items).map(([category, categoryItems]) => (
let flatIndex = -1; <div key={category}>
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) => { {categoryItems.map((item: SlashMenuItemType, index: number) => (
flatIndex += 1;
const itemIndex = flatIndex;
return (
<UnstyledButton <UnstyledButton
data-item-index={itemIndex} data-item-index={index}
key={itemIndex} key={index}
id={`slash-command-option-${itemIndex}`} onClick={() => selectItem(index)}
role="option"
aria-selected={itemIndex === selectedIndex}
onClick={() => selectItem(itemIndex)}
className={clsx(classes.menuBtn, { className={clsx(classes.menuBtn, {
[classes.selectedItem]: itemIndex === selectedIndex, [classes.selectedItem]: index === selectedIndex,
})} })}
> >
<Group> <Group>
<ActionIcon variant="default" component="div" aria-hidden="true"> <ActionIcon variant="default" component="div">
<item.icon size={18} /> <item.icon size={18} />
</ActionIcon> </ActionIcon>
@@ -140,11 +124,9 @@ 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,
@@ -133,7 +132,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{ {
title: "Numbered list", title: "Numbered list",
description: "Create a list with numbering.", description: "Create a list with numbering.",
searchTerms: ["numbered", "ordered", "list", "ol"], searchTerms: ["numbered", "ordered", "list"],
icon: IconListNumbers, icon: IconListNumbers,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run(); editor.chain().focus().deleteRange(range).toggleOrderedList().run();
@@ -232,15 +231,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{ {
title: "Audio", title: "Audio",
description: "Upload any audio from your device.", description: "Upload any audio from your device.",
searchTerms: [ searchTerms: ["audio", "music", "sound", "mp3", "media", "file", "attachment"],
"audio",
"music",
"sound",
"mp3",
"media",
"file",
"attachment",
],
icon: IconMusic, icon: IconMusic,
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
@@ -480,41 +471,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{ {
title: "Subpages (Child pages)", title: "Subpages (Child pages)",
description: "List all subpages of the current page", description: "List all subpages of the current page",
searchTerms: [ searchTerms: ["subpages", "child", "children", "nested", "hierarchy"],
"subpages",
"child",
"children",
"nested",
"hierarchy",
"toc",
],
icon: IconSitemap, icon: IconSitemap,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertSubpages().run(); editor.chain().focus().deleteRange(range).insertSubpages().run();
}, },
}, },
{
title: "Synced block",
description: "Create a block that stays in sync across pages.",
searchTerms: [
"sync",
"synced",
"synced block",
"excerpt",
"transclusion",
"reusable",
"snippet",
],
icon: IconRotate2,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTransclusionSource()
.run();
},
},
{ {
title: "2 Columns", title: "2 Columns",
description: "Split content into two columns.", description: "Split content into two columns.",
@@ -92,17 +92,8 @@ 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>
@@ -136,16 +127,6 @@ 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,17 +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={18}
stroke={1.6}
className={classes.placeholderIcon}
/>
<span>{t("Failed to load this synced block")}</span>
</div>
);
}
@@ -1,13 +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={18} stroke={1.6} className={classes.placeholderIcon} />
<span>{t("You don't have access to this synced block")}</span>
</div>
);
}
@@ -1,17 +0,0 @@
import { IconInfoCircle } 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}>
<IconInfoCircle
size={18}
stroke={1.6}
className={classes.placeholderIcon}
/>
<span>{t("The original synced block no longer exists")}</span>
</div>
);
}
@@ -1,48 +0,0 @@
import { EditorProvider } from "@tiptap/react";
import { useMemo } from "react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { UniqueID } from "@docmost/editor-ext";
type Props = {
content: unknown;
};
export default function TransclusionContent({ content }: Props) {
const extensions = useMemo(() => {
const filtered = mainExtensions.filter(
(e: any) => e.name !== "uniqueID" && e.name !== "globalDragHandle",
);
return [
...filtered,
UniqueID.configure({
types: ["heading", "paragraph", "transclusionSource"],
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 (
<div
onMouseDown={stop}
onClick={stop}
onDragStart={stop}
onDragOver={stop}
onDrop={stop}
>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={extensions}
content={content as any}
/>
</div>
);
}
@@ -1,213 +0,0 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
lookupTransclusion,
lookupTransclusionForShare,
} 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,
shareId,
}: {
children: React.ReactNode;
/**
* When set, lookups go through the share-scoped public endpoint and are
* gated by the share graph (source page must have its own share or inherit
* one). Used by the public share viewer; left undefined in the authenticated
* app, where personal permissions gate access.
*/
shareId?: string;
}) {
const subscribersRef = useRef(new Map<LookupKey, Subscriber[]>());
const queueRef = useRef(new Set<LookupKey>());
const tickRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Read inside flush() via ref so changing share context doesn't churn the
// memoized callbacks (and thus doesn't re-render every consumer).
const shareIdRef = useRef<string | undefined>(shareId);
shareIdRef.current = shareId;
// 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 activeShareId = shareIdRef.current;
const { items } = activeShareId
? await lookupTransclusionForShare({
shareId: activeShareId,
references,
})
: 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 };
}
@@ -1,212 +0,0 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import {
IconDots,
IconLinkOff,
IconPencil,
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;
const base = source?.spaceSlug
? buildPageUrl(source.spaceSlug, source.slugId, source.title)
: sourcePageId
? `/p/${sourcePageId}`
: null;
if (!base) return null;
return transclusionId ? `${base}#${transclusionId}` : base;
})();
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}
onMouseDown={(e) => e.preventDefault()}
>
{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("Edit source")}>
<ActionIcon
component={Link}
to={sourcePageHref}
variant="subtle"
color="gray"
size="sm"
style={{
textDecoration: "none",
borderBottom: "none",
}}
>
<IconPencil 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 content={result.content} />
) : result.status === "no_access" ? (
<NoAccessPlaceholder />
) : (
<NotFoundPlaceholder />
)}
</>
);
}
@@ -1,126 +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().unsyncTransclusionSource().run();
};
return (
<NodeViewWrapper
className={classes.transclusionWrap}
data-menu-open={openMenus > 0 ? "true" : "false"}
data-id={transclusionId ?? undefined}
>
{isEditable && (
<div
className={classes.transclusionControls}
contentEditable={false}
onMouseDown={(e) => e.preventDefault()}
>
{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 synced block")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
)}
<NodeViewContent />
</NodeViewWrapper>
);
}
@@ -1,209 +0,0 @@
.placeholder {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: var(--mantine-radius-md);
background: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-sm);
user-select: none;
}
.placeholderIcon {
flex: none;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.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: 8px;
border: 2px solid transparent;
transition: border 0.3s;
}
.transclusionWrap:hover,
.transclusionWrap:focus-within {
border: 2px solid
light-dark(
var(--mantine-color-orange-2),
color-mix(in srgb, var(--mantine-color-orange-9), transparent 55%)
);
}
.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);
}
.transclusionControls a[href],
.includeControls a[href] {
color: var(--ai-color);
border-bottom: none;
font-weight: inherit;
}
.includeWrap {
position: relative;
margin-left: -3rem;
margin-right: -3rem;
width: calc(100% + 6rem);
padding: 0.5em 0;
border-radius: 8px;
border: 2px solid transparent;
transition: border 0.3s;
}
.includeWrap:hover,
.includeWrap[data-focused="true"],
.includeWrap[data-menu-open="true"] {
border: 2px solid
light-dark(
var(--mantine-color-orange-2),
color-mix(in srgb, var(--mantine-color-orange-9), transparent 55%)
);
}
.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-transclusionSource.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;
}
.transclusionWrap,
.includeWrap {
border: none !important;
margin-left: 0 !important;
margin-right: 0 !important;
width: 100% !important;
padding: 0 !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,7 +47,6 @@ 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 && (
@@ -57,7 +56,6 @@ 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>
@@ -73,7 +71,7 @@ export default function VideoView(props: NodeViewProps) {
</Group> </Group>
)} )}
{!src && !previewSrc && !placeholder && ( {!src && !previewSrc && !placeholder && (
<video className={classes.video} controls aria-label={t("Video")} /> <video className={classes.video} controls />
)} )}
</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,
scrollThreshold: 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,
TransclusionSource,
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", "transclusionSource"], 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: ["transclusionSource", "transclusionReference"],
}),
TextStyle, TextStyle,
Color, Color,
SlashCommand, SlashCommand,
@@ -357,12 +351,6 @@ export const mainExtensions = [
Status.configure({ Status.configure({
view: StatusView, view: StatusView,
}), }),
TransclusionSource.configure({
view: TransclusionView,
}),
TransclusionReference.configure({
view: TransclusionReferenceView,
}),
MarkdownClipboard.configure({ MarkdownClipboard.configure({
transformPastedText: true, transformPastedText: true,
}), }),
@@ -80,12 +80,10 @@ export const MarkdownClipboard = Extension.create({
const { from, to } = view.state.selection; const { from, to } = view.state.selection;
const parsed = markdownToHtml(text.replace(/\n+$/, "")); const parsed = markdownToHtml(text.replace(/\n+$/, ""));
const body = elementFromString(parsed);
normalizeTableColumnWidths(body);
const contentNodes = DOMParser.fromSchema( const contentNodes = DOMParser.fromSchema(
this.editor.schema, this.editor.schema,
).parseSlice(body, { ).parseSlice(elementFromString(parsed), {
preserveWhitespace: true, preserveWhitespace: true,
}); });
@@ -139,92 +137,3 @@ function elementFromString(value) {
return new window.DOMParser().parseFromString(wrappedValue, "text/html").body; return new window.DOMParser().parseFromString(wrappedValue, "text/html").body;
} }
const DEFAULT_PASTE_COL_WIDTH_PX = 150;
function parsePixelWidth(el: Element): number | null {
const attr = el.getAttribute("width");
if (attr) {
const n = parseInt(attr, 10);
if (Number.isFinite(n) && n > 0) return n;
}
const style = el.getAttribute("style") || "";
const m = style.match(/(?:^|;)\s*width\s*:\s*([\d.]+)\s*px/i);
if (m) {
const n = parseInt(m[1], 10);
if (Number.isFinite(n) && n > 0) return n;
}
return null;
}
function getFirstRow(table: Element): Element | null {
const tbodyRow = table.querySelector(":scope > tbody > tr");
if (tbodyRow) return tbodyRow;
const theadRow = table.querySelector(":scope > thead > tr");
if (theadRow) return theadRow;
return table.querySelector(":scope > tr");
}
function deriveColumnWidths(table: Element): (number | null)[] | null {
const cols = table.querySelectorAll(":scope > colgroup > col");
if (cols.length > 0) {
const widths: (number | null)[] = [];
cols.forEach((col) => widths.push(parsePixelWidth(col)));
if (widths.some((w) => w !== null)) return widths;
}
const firstRow = getFirstRow(table);
if (!firstRow) return null;
const widths: (number | null)[] = [];
Array.from(firstRow.children)
.filter((c) => c.tagName === "TD" || c.tagName === "TH")
.forEach((cell) => {
const colspan = parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
const w = parsePixelWidth(cell);
for (let i = 0; i < colspan; i++) {
widths.push(w !== null ? Math.round(w / colspan) : null);
}
});
if (widths.length === 0 || widths.every((w) => w === null)) return null;
return widths;
}
// Mirror of server normalizeTableColumnWidths (see import/utils/table-utils.ts):
// markdown source has no widths, so without this every pasted table renders
// at table-layout:fixed/100% and squashes columns to fit the editor instead of
// letting .tableWrapper's overflow-x: auto scroll.
export function normalizeTableColumnWidths(root: Element): void {
root.querySelectorAll("table").forEach((table) => {
const firstRow = getFirstRow(table);
if (!firstRow) return;
let colWidths = deriveColumnWidths(table);
if (!colWidths) {
let count = 0;
Array.from(firstRow.children)
.filter((c) => c.tagName === "TD" || c.tagName === "TH")
.forEach((cell) => {
count += parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
});
if (count === 0) return;
colWidths = new Array(count).fill(DEFAULT_PASTE_COL_WIDTH_PX);
}
let col = 0;
Array.from(firstRow.children)
.filter((c) => c.tagName === "TD" || c.tagName === "TH")
.forEach((cell) => {
if (cell.getAttribute("colwidth")) {
col += parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
return;
}
const colspan = parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
const slice = colWidths!.slice(col, col + colspan);
col += colspan;
if (slice.length === 0 || slice.every((w) => w === null)) return;
const values = slice.map((w) => (w == null ? 100 : w));
cell.setAttribute("colwidth", values.join(","));
});
});
}
+49 -63
View File
@@ -62,7 +62,7 @@ import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts"; import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { extractPageSlugId, platformModifierKey } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts"; import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
@@ -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;
@@ -233,19 +232,11 @@ export default function PageEditor({
scrollMargin: 80, scrollMargin: 80,
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => { keydown: (_view, event) => {
if (platformModifierKey(event) && event.code === "KeyS") { if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
event.preventDefault(); event.preventDefault();
return true; return true;
} }
if (event.key === "Tab") { if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
const editor = editorRef.current;
if (!editor) return false;
event.preventDefault();
return editor.view.someProp("handleKeyDown", (f) =>
f(editor.view, event)
);
}
if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open(); searchSpotlight.open();
return true; return true;
} }
@@ -400,60 +391,55 @@ export default function PageEditor({
} }
}, [yjsConnectionStatus, isSynced]); }, [yjsConnectionStatus, isSynced]);
if (showStatic) {
return (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
/>
);
}
return ( return (
<TransclusionLookupProvider> <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,26 +9,17 @@ 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;
content: any; content: any;
pageId?: string; pageId?: string;
/**
* When rendering inside a public share, pass the share's id (or key). Lookups
* for transclusion content then resolve against the share graph instead of
* the viewer's personal permissions, so a share never leaks source content
* that isn't itself shared.
*/
shareId?: string;
} }
export default function ReadonlyPageEditor({ export default function ReadonlyPageEditor({
title, title,
content, content,
pageId, pageId,
shareId,
}: PageEditorProps) { }: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
@@ -74,7 +65,7 @@ export default function ReadonlyPageEditor({
]; ];
return ( return (
<TransclusionLookupProvider shareId={shareId}> <>
<div className="page-title"> <div className="page-title">
<EditorProvider <EditorProvider
editable={false} editable={false}
@@ -104,6 +95,6 @@ export default function ReadonlyPageEditor({
}} }}
></EditorProvider> ></EditorProvider>
<div style={{ paddingBottom: "20vh" }}></div> <div style={{ paddingBottom: "20vh" }}></div>
</TransclusionLookupProvider> </>
); );
} }
@@ -27,7 +27,6 @@ import localEmitter from "@/lib/local-emitter.ts";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts"; import { searchSpotlight } from "@/features/search/constants.ts";
import { platformModifierKey } from "@/lib";
export interface TitleEditorProps { export interface TitleEditorProps {
pageId: string; pageId: string;
@@ -91,11 +90,11 @@ export function TitleEditor({
editorProps: { editorProps: {
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => { keydown: (_view, event) => {
if (platformModifierKey(event) && event.code === "KeyS") { if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
event.preventDefault(); event.preventDefault();
return true; return true;
} }
if (platformModifierKey(event) && event.code === "KeyK") { if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
searchSpotlight.open(); searchSpotlight.open();
return true; return true;
} }
@@ -53,17 +53,15 @@ export default function StarButton(props: StarButtonProps) {
} }
}; };
const label = isFavorited
? t("Remove from favorites")
: t("Add to favorites");
return ( return (
<Tooltip label={label} openDelay={250} withArrow> <Tooltip
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" aria-label={t("Group menu")}> <ActionIcon variant="light">
<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 aria-label={t("Action")} /> <Table.Th></Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@@ -4,7 +4,7 @@ import {
UnstyledButton, UnstyledButton,
Badge, Badge,
Table, Table,
ThemeIcon, ActionIcon,
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 || (
<ThemeIcon <ActionIcon
variant="transparent" variant="transparent"
color="gray" color="gray"
size={18} size={18}
> >
<IconFileDescription size={18} /> <IconFileDescription size={18} />
</ThemeIcon> </ActionIcon>
)} )}
<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,
ThemeIcon, ActionIcon,
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 || (
<ThemeIcon <ActionIcon
variant="transparent" variant="transparent"
color="gray" color="gray"
size={18} size={18}
> >
<IconFileDescription size={18} /> <IconFileDescription size={18} />
</ThemeIcon> </ActionIcon>
)} )}
<Text fw={500} size="md" lineClamp={1}> <Text fw={500} size="md" lineClamp={1}>
{fav.page.title || t("Untitled")} {fav.page.title || t("Untitled")}
@@ -16,7 +16,7 @@
.subtitle { .subtitle {
font-size: var(--mantine-font-size-sm); font-size: var(--mantine-font-size-sm);
color: var(--mantine-color-dimmed); color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
text-align: center; text-align: center;
margin-top: 6px; margin-top: 6px;
margin-bottom: var(--mantine-spacing-lg); margin-bottom: var(--mantine-spacing-lg);
@@ -58,9 +58,6 @@ 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,7 +22,6 @@ 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" }}>
@@ -50,7 +49,6 @@ 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,7 +19,6 @@ 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) {
@@ -29,7 +28,6 @@ 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
@@ -82,7 +80,7 @@ export default function Breadcrumb() {
)); ));
const renderAnchor = useCallback( const renderAnchor = useCallback(
(node: SpaceTreeNode, isCurrent = false) => ( (node: SpaceTreeNode) => (
<Tooltip label={node.name} key={node.id}> <Tooltip label={node.name} key={node.id}>
<Anchor <Anchor
component={Link} component={Link}
@@ -91,7 +89,6 @@ 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>
@@ -118,11 +115,7 @@ export default function Breadcrumb() {
key="hidden-nodes" key="hidden-nodes"
> >
<Popover.Target> <Popover.Target>
<ActionIcon <ActionIcon color="gray" variant="transparent">
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>
@@ -131,13 +124,11 @@ export default function Breadcrumb() {
</Popover.Dropdown> </Popover.Dropdown>
</Popover>, </Popover>,
//renderAnchor(secondLastNode), //renderAnchor(secondLastNode),
renderAnchor(lastNode, true), renderAnchor(lastNode),
]; ];
} }
return breadcrumbNodes.map((node, i) => return breadcrumbNodes.map(renderAnchor);
renderAnchor(node, i === breadcrumbNodes.length - 1),
);
}; };
const getMobileBreadcrumbItems = () => { const getMobileBreadcrumbItems = () => {
@@ -153,12 +144,8 @@ export default function Breadcrumb() {
key="mobile-hidden-nodes" key="mobile-hidden-nodes"
> >
<Popover.Target> <Popover.Target>
<Tooltip label={t("Breadcrumbs")}> <Tooltip label="Breadcrumbs">
<ActionIcon <ActionIcon color="gray" variant="transparent">
color="gray"
variant="transparent"
aria-label={t("Breadcrumbs")}
>
<IconCornerDownRightDouble size={20} stroke={2} /> <IconCornerDownRightDouble size={20} stroke={2} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -170,18 +157,16 @@ export default function Breadcrumb() {
]; ];
} }
return breadcrumbNodes.map((node, i) => return breadcrumbNodes.map(renderAnchor);
renderAnchor(node, i === breadcrumbNodes.length - 1),
);
}; };
return ( return (
<nav aria-label={t("Breadcrumb")} className={classes.breadcrumbDiv}> <div className={classes.breadcrumbDiv}>
{breadcrumbNodes && ( {breadcrumbNodes && (
<Breadcrumbs className={classes.breadcrumbs}> <Breadcrumbs className={classes.breadcrumbs}>
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()} {isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
</Breadcrumbs> </Breadcrumbs>
)} )}
</nav> </div>
); );
} }
@@ -1,4 +1,4 @@
import { ActionIcon, Group, Menu, Text, ThemeIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Group, Menu, Text, Tooltip } from "@mantine/core";
import { import {
IconArrowRight, IconArrowRight,
IconArrowsHorizontal, IconArrowsHorizontal,
@@ -99,7 +99,6 @@ 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} />
@@ -110,7 +109,6 @@ 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} />
@@ -207,11 +205,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
arrowPosition="center" arrowPosition="center"
> >
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon variant="subtle" color="dark">
variant="subtle"
color="dark"
aria-label={t("Page actions")}
>
<IconDots size={20} /> <IconDots size={20} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -422,15 +416,9 @@ function ConnectionWarning() {
openDelay={250} openDelay={250}
withArrow withArrow
> >
<ThemeIcon <ActionIcon variant="default" c="red" style={{ border: "none" }}>
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} />
</ThemeIcon> </ActionIcon>
</Tooltip> </Tooltip>
); );
} }
@@ -12,7 +12,6 @@ import {
IconCheck, IconCheck,
IconFileCode, IconFileCode,
IconFileTypeDocx, IconFileTypeDocx,
IconFileTypePdf,
IconFileTypeZip, IconFileTypeZip,
IconMarkdown, IconMarkdown,
IconX, IconX,
@@ -67,7 +66,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 aria-label={t("Close")} /> <Modal.CloseButton />
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<ImportFormatSelection spaceId={spaceId} onClose={onClose} /> <ImportFormatSelection spaceId={spaceId} onClose={onClose} />
@@ -91,14 +90,12 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const markdownFileRef = useRef<() => void>(null); const markdownFileRef = useRef<() => void>(null);
const htmlFileRef = useRef<() => void>(null); const htmlFileRef = useRef<() => void>(null);
const docxFileRef = useRef<() => void>(null); const docxFileRef = useRef<() => void>(null);
const pdfFileRef = useRef<() => void>(null);
const notionFileRef = useRef<() => void>(null); const notionFileRef = useRef<() => void>(null);
const confluenceFileRef = useRef<() => void>(null); const confluenceFileRef = useRef<() => void>(null);
const zipFileRef = useRef<() => void>(null); const zipFileRef = useRef<() => void>(null);
const canUseConfluence = useHasFeature(Feature.CONFLUENCE_IMPORT); const canUseConfluence = useHasFeature(Feature.CONFLUENCE_IMPORT);
const canUseDocx = useHasFeature(Feature.DOCX_IMPORT); const canUseDocx = useHasFeature(Feature.DOCX_IMPORT);
const canUsePdf = useHasFeature(Feature.PDF_IMPORT);
const upgradeLabel = useUpgradeLabel(); const upgradeLabel = useUpgradeLabel();
const handleZipUpload = async (selectedFile: File, source: string) => { const handleZipUpload = async (selectedFile: File, source: string) => {
@@ -247,7 +244,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
}, 3000); }, 3000);
}, [fileTaskId]); }, [fileTaskId]);
const maxSingleFileSize = bytes("30mb"); const maxSingleFileSize = bytes("20mb");
const handleFileUpload = async (selectedFiles: File[]) => { const handleFileUpload = async (selectedFiles: File[]) => {
if (!selectedFiles) { if (!selectedFiles) {
@@ -301,7 +298,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
if (markdownFileRef.current) markdownFileRef.current(); if (markdownFileRef.current) markdownFileRef.current();
if (htmlFileRef.current) htmlFileRef.current(); if (htmlFileRef.current) htmlFileRef.current();
if (docxFileRef.current) docxFileRef.current(); if (docxFileRef.current) docxFileRef.current();
if (pdfFileRef.current) pdfFileRef.current();
const pageCountText = const pageCountText =
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`; pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
@@ -332,15 +328,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
return ( return (
<> <>
<SimpleGrid cols={2}> <SimpleGrid cols={2}>
<FileButton <FileButton onChange={handleFileUpload} accept=".md" multiple resetRef={markdownFileRef}>
onChange={handleFileUpload}
accept=".md"
multiple
resetRef={markdownFileRef}
inputProps={{
"aria-label": t("Choose {{format}} file", { format: "Markdown" }),
}}
>
{(props) => ( {(props) => (
<Button <Button
justify="start" justify="start"
@@ -353,15 +341,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
)} )}
</FileButton> </FileButton>
<FileButton <FileButton onChange={handleFileUpload} accept="text/html" multiple resetRef={htmlFileRef}>
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"
@@ -379,9 +359,6 @@ 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
@@ -401,40 +378,10 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
)} )}
</FileButton> </FileButton>
<FileButton
onChange={handleFileUpload}
accept=".pdf"
multiple
resetRef={pdfFileRef}
inputProps={{
"aria-label": t("Choose {{format}} file", { format: "PDF" }),
}}
>
{(props) => (
<Tooltip
label={upgradeLabel}
disabled={canUsePdf}
>
<Button
disabled={!canUsePdf}
justify="start"
variant="default"
leftSection={<IconFileTypePdf size={18} />}
{...props}
>
PDF
</Button>
</Tooltip>
)}
</FileButton>
<FileButton <FileButton
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
@@ -451,9 +398,6 @@ 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
@@ -491,9 +435,6 @@ 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} aria-label={t("Preview")}> <Modal.Root size={1200} opened={opened} onClose={onClose}>
<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 aria-label={t("Action")} /> <Table.Th></Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
@@ -458,8 +458,6 @@ 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();
@@ -477,7 +475,6 @@ 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();
@@ -594,7 +591,6 @@ 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();
@@ -729,8 +725,6 @@ 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();
@@ -742,8 +736,6 @@ 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();
@@ -13,7 +13,6 @@ import {
import classes from "./search-control.module.css"; import classes from "./search-control.module.css";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { platformModifierLabel } from "@/lib";
interface SearchControlProps extends BoxProps, ElementProps<"button"> {} interface SearchControlProps extends BoxProps, ElementProps<"button"> {}
@@ -28,7 +27,7 @@ export function SearchControl({ className, ...others }: SearchControlProps) {
{t("Search")} {t("Search")}
</Text> </Text>
<Text fw={700} className={classes.shortcut}> <Text fw={700} className={classes.shortcut}>
{platformModifierLabel} + K Ctrl + K
</Text> </Text>
</Group> </Group>
</UnstyledButton> </UnstyledButton>
@@ -47,7 +46,6 @@ 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"
> >

Some files were not shown because too many files have changed in this diff Show More