Compare commits

..

1 Commits

Author SHA1 Message Date
Philipinho cc343b095a feat: sync blocks - wip 2026-05-04 18:08:34 +01:00
193 changed files with 1861 additions and 4668 deletions
+1 -1
View File
@@ -25,7 +25,7 @@
"@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "5.90.17",
"alfaaz": "^1.1.0",
"axios": "1.16.0",
"axios": "1.15.0",
"blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
@@ -416,7 +416,6 @@
"{{latestVersion}} is available": "{{latestVersion}} is available",
"Default page edit mode": "Default page edit mode",
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
"Choose {{format}} file": "Choose {{format}} file",
"Reading": "Reading",
"Delete member": "Delete member",
"Member deleted successfully": "Member deleted successfully",
@@ -870,12 +869,6 @@
"Previous 7 days": "Previous 7 days",
"Previous 30 days": "Previous 30 days",
"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.",
"Summarize this page": "Summarize this page",
"Toggle AI Chat": "Toggle AI Chat",
@@ -908,80 +901,16 @@
"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",
"Synced block": "Synced block",
"Sync block": "Sync block",
"Create a block that stays in sync across pages.": "Create a block that stays in sync across pages.",
"Sync block name": "Sync block name",
"Editing original": "Editing original",
"Copy synced block": "Copy synced block",
"Unsync": "Unsync",
"Delete synced block": "Delete synced block",
"Delete sync block": "Delete sync 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",
"Fixed editor toolbar": "Fixed editor toolbar",
"Show a formatting toolbar above the editor with quick access to common actions.": "Show a formatting toolbar above the editor with quick access to common actions.",
"Toggle fixed editor toolbar": "Toggle fixed editor toolbar",
"Normal text": "Normal text",
"More inline formatting": "More inline formatting",
"Subscript": "Subscript",
"Superscript": "Superscript",
"Inline code": "Inline code",
"Insert media": "Insert media",
"Mention": "Mention",
"Emoji": "Emoji",
"Columns": "Columns",
"More inserts": "More inserts",
"Embeds": "Embeds",
"Diagrams": "Diagrams",
"Advanced": "Advanced",
"Utility": "Utility",
"Decrease indent": "Decrease indent",
"Increase indent": "Increase indent",
"Clear formatting": "Clear formatting",
"Code block": "Code block",
"Experimental": "Experimental",
"Strikethrough": "Strikethrough",
"Undo": "Undo",
"Redo": "Redo",
"Backlinks": "Backlinks",
"Last updated by": "Last updated by",
"Last updated": "Last updated",
"Stats": "Stats",
"Word count": "Word count",
"Characters": "Characters",
"Incoming links": "Incoming links",
"Outgoing links": "Outgoing links",
"Incoming links ({{count}})": "Incoming links ({{count}})",
"Outgoing links ({{count}})": "Outgoing links ({{count}})",
"No pages link here yet.": "No pages link here yet.",
"This page doesn't link to other pages yet.": "This page doesn't link to other pages yet.",
"Verified until {{date}}": "Verified until {{date}}"
"No pages": "No pages"
}
@@ -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 () => {
if (disabled) return;
@@ -110,8 +104,6 @@ export default function AvatarUploader({
ref={fileInputRef}
onChange={handleFileInputChange}
accept="image/png,image/jpeg,image/jpg"
aria-label={ariaLabel}
tabIndex={-1}
style={{ display: "none" }}
/>
@@ -123,8 +115,6 @@ export default function AvatarUploader({
size={size}
avatarUrl={currentImageUrl}
name={fallbackName}
aria-label={ariaLabel}
aria-haspopup="menu"
style={{
cursor: disabled || isLoading ? "default" : "pointer",
opacity: isLoading ? 0.6 : 1,
@@ -25,7 +25,6 @@ export default function CopyTextButton({ text, size }: CopyProps) {
variant="subtle"
onClick={copy}
size={size}
aria-label={copied ? t("Copied") : t("Copy")}
>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
@@ -4,7 +4,7 @@ import {
UnstyledButton,
Badge,
Table,
ThemeIcon,
ActionIcon,
Button,
} from "@mantine/core";
import { Link } from "react-router-dom";
@@ -49,9 +49,9 @@ export default function RecentChanges({ spaceId }: Props) {
>
<Group wrap="nowrap">
{page.icon || (
<ThemeIcon variant="transparent" color="gray" size={18}>
<ActionIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
</ThemeIcon>
</ActionIcon>
)}
<Text fw={500} size="md" lineClamp={1}>
@@ -6,14 +6,12 @@ import { useTranslation } from "react-i18next";
export interface SearchInputProps {
placeholder?: string;
ariaLabel?: string;
debounceDelay?: number;
onSearch: (value: string) => void;
}
export function SearchInput({
placeholder,
ariaLabel,
debounceDelay = 500,
onSearch,
}: SearchInputProps) {
@@ -30,7 +28,6 @@ export function SearchInput({
<TextInput
size="sm"
placeholder={placeholder || t("Search...")}
aria-label={ariaLabel || placeholder || t("Search")}
leftSection={<IconSearch size={16} />}
value={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 { IconUsersGroup } from "@tabler/icons-react";
export function IconGroupCircle() {
return (
<ThemeIcon variant="light" size="lg" color="gray" radius="xl">
<ActionIcon variant="light" size="lg" color="gray" radius="xl">
<IconUsersGroup stroke={1.5} />
</ThemeIcon>
</ActionIcon>
);
}
@@ -27,3 +27,5 @@
background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5))
}
}
@@ -8,7 +8,6 @@ import { TableOfContents } from "@/features/editor/components/table-of-contents/
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
@@ -31,10 +30,6 @@ export default function Aside() {
component = <AsideChatPanel />;
title = "AI Chat";
break;
case "details":
component = <PageDetailsAside />;
title = "Details";
break;
default:
component = null;
title = null;
@@ -1,7 +1,6 @@
import { AppShell, Container } from "@mantine/core";
import React, { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai";
import {
@@ -24,12 +23,11 @@ export default function GlobalAppShell({
}: {
children: React.ReactNode;
}) {
const { t } = useTranslation();
useTrialEndAction();
const [mobileOpened] = useAtom(mobileSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom);
const [{ isAsideOpen, tab: asideTab }] = useAtom(asideStateAtom);
const [{ isAsideOpen }] = useAtom(asideStateAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef(null);
@@ -107,15 +105,6 @@ export default function GlobalAppShell({
className={classes.navbar}
withBorder={false}
ref={sidebarRef}
aria-label={
isSpaceRoute
? t("Space navigation")
: isSettingsRoute
? t("Settings navigation")
: isAiRoute
? t("AI navigation")
: t("Main navigation")
}
>
{isSpaceRoute && (
<div className={classes.resizeHandle} onMouseDown={startResizing} />
@@ -125,7 +114,7 @@ export default function GlobalAppShell({
{isAiRoute && <AiChatSidebar />}
{showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar>
<AppShell.Main id="main-content">
<AppShell.Main>
{isSettingsRoute ? (
<Container size={900} pb={80}>
{children}
@@ -136,22 +125,7 @@ export default function GlobalAppShell({
</AppShell.Main>
{isPageRoute && (
<AppShell.Aside
className={classes.aside}
p="md"
withBorder={false}
aria-label={
asideTab === "comments"
? t("Comments")
: asideTab === "toc"
? t("Table of contents")
: asideTab === "chat"
? t("AI Chat")
: asideTab === "details"
? t("Details")
: undefined
}
>
<AppShell.Aside className={classes.aside} p="md" withBorder={false}>
<Aside />
</AppShell.Aside>
)}
@@ -50,7 +50,7 @@
.sectionHeader {
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
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;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { ScrollArea, Text, Divider, Modal, UnstyledButton } from "@mantine/core";
import { ScrollArea, Text, Divider, Modal } from "@mantine/core";
import {
IconHome,
IconClock,
@@ -119,13 +119,17 @@ export default function GlobalSidebar() {
</ScrollArea>
<div className={classes.bottomSection}>
<UnstyledButton
<a
className={classes.link}
onClick={openInvite}
onClick={(e) => {
e.preventDefault();
openInvite();
}}
href="#"
>
<IconUserPlus className={classes.linkIcon} stroke={2} />
<span>{t("Invite People")}</span>
</UnstyledButton>
</a>
<Link
className={classes.link}
data-active={active.startsWith("/settings") || undefined}
@@ -10,7 +10,6 @@ export const desktopSidebarAtom = atomWithWebStorage<boolean>(
export const desktopAsideAtom = atom<boolean>(false);
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
type AsideStateType = {
tab: string;
isAsideOpen: boolean;
@@ -230,6 +230,32 @@ export default function SettingsSidebar() {
}
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) {
return (
@@ -239,41 +265,12 @@ export default function SettingsSidebar() {
position="right"
withArrow
>
<span
className={classes.link}
data-disabled
role="link"
aria-disabled="true"
tabIndex={0}
style={{
opacity: 0.5,
cursor: "not-allowed",
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</span>
{linkElement}
</Tooltip>
);
}
return (
<Link
onMouseEnter={prefetchHandler}
className={classes.link}
data-active={active.startsWith(item.path) || undefined}
key={item.label}
to={item.path}
onClick={() => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
);
return linkElement;
})}
</div>
);
@@ -291,7 +288,7 @@ export default function SettingsSidebar() {
}}
variant="transparent"
c="gray"
aria-label={t("Back")}
aria-label="Back"
>
<IconArrowLeft stroke={2} />
</ActionIcon>
@@ -1,5 +1,5 @@
import React from "react";
import { Avatar, MantineColor } from "@mantine/core";
import { Avatar } from "@mantine/core";
import { getAvatarUrl } from "@/lib/config.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
@@ -16,53 +16,19 @@ interface CustomAvatarProps {
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];
}
function sanitizeInitialsSource(name: string) {
const sanitized = name.replace(/[^\p{L}\p{N}\s]/gu, " ").trim();
return sanitized || name;
}
export const CustomAvatar = React.forwardRef<
HTMLInputElement,
CustomAvatarProps
>(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => {
>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl, type);
const resolvedColor =
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
const initialsSource = sanitizeInitialsSource(name ?? "");
return (
<Avatar
ref={ref}
src={avatarLink}
name={initialsSource}
name={name}
alt={name}
color={resolvedColor}
color="initials"
{...props}
/>
);
@@ -74,18 +74,7 @@ export function PageChildren({
/>
))}
{hasNextPage && (
<div
className={classes.loadMore}
onClick={() => fetchNextPage()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fetchNextPage();
}
}}
role="button"
tabIndex={0}
>
<div className={classes.loadMore} onClick={() => fetchNextPage()}>
{t("Load more")}
</div>
)}
@@ -70,14 +70,11 @@ function EmojiPicker({
closeOnEscape={true}
>
<Popover.Target ref={setTarget}>
<ActionIcon
c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"}
<ActionIcon
c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"}
size={actionIconProps?.size}
onClick={handlers.toggle}
aria-label={t("Pick emoji")}
aria-haspopup="dialog"
aria-expanded={opened}
>
{icon}
</ActionIcon>
@@ -132,7 +132,6 @@ export default function AiChatSidebarItem({
size="xs"
color="gray"
onClick={(e) => e.preventDefault()}
aria-label={t("Chat menu")}
>
<IconDots size={14} />
</ActionIcon>
@@ -137,8 +137,7 @@ export default function AiChatSidebar() {
<TextInput
className={classes.searchInput}
placeholder={t("Search chats...")}
aria-label={t("Search chats")}
placeholder="Search chats..."
leftSection={<IconSearch size={14} />}
size="xs"
value={search}
@@ -178,7 +178,6 @@ export default function AsideChatPanel() {
href="/ai"
variant="subtle"
color="dark"
aria-label={t("New chat")}
onClick={handleNewChat}
>
<IconPlus size={20} stroke={1.75} />
@@ -186,23 +185,13 @@ export default function AsideChatPanel() {
</Tooltip>
<Tooltip label={t("Open full page")} openDelay={250}>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Open full page")}
onClick={handleExpand}
>
<ActionIcon variant="subtle" color="dark" onClick={handleExpand}>
<IconArrowsDiagonal size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Close")} openDelay={250}>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Close")}
onClick={handleClose}
>
<ActionIcon variant="subtle" color="dark" onClick={handleClose}>
<IconX size={20} stroke={1.75} />
</ActionIcon>
</Tooltip>
@@ -65,7 +65,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
isStreaming={isStreaming}
onSend={onSend}
onStop={onStop}
placeholder={t("Ask anything... Use @ to mention pages")}
placeholder="Ask anything... Use @ to mention pages"
autofocus
/>
</div>
@@ -200,7 +200,7 @@ export default function ChatInput({
link: false,
}),
Placeholder.configure({
placeholder: placeholder || t("Ask anything... Use @ to mention pages"),
placeholder: placeholder || "Ask anything... Use @ to mention pages",
}),
CharacterCount.configure({
limit: 50000,
@@ -225,10 +225,6 @@ export default function ChatInput({
}),
],
editorProps: {
attributes: {
"aria-label": placeholder || t("Ask anything... Use @ to mention pages"),
"aria-multiline": "true",
},
handleDOMEvents: {
keydown: (_view, event) => {
if (
@@ -279,8 +275,6 @@ export default function ChatInput({
type="file"
accept={ACCEPTED_FILE_TYPES}
multiple
aria-label={t("Add files")}
tabIndex={-1}
style={{ display: "none" }}
onChange={(e) => handleFileSelect(e.target.files)}
/>
@@ -31,16 +31,7 @@ export default function ChatToolGroup({ toolCalls, isStreaming }: Props) {
<div className={classes.toolGroup}>
<div
className={classes.toolGroupHeader}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={() => setExpanded((prev) => !prev)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((prev) => !prev);
}
}}
>
{activeLabel ? (
<IconLoader2 size={12} className={classes.processingSpinner} />
@@ -98,7 +98,7 @@
font-weight: 600;
letter-spacing: 0.08em;
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);
}
@@ -125,7 +125,7 @@
.suggestionsLabel {
font-size: var(--mantine-font-size-xs);
font-weight: 500;
color: var(--mantine-color-dimmed);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--mantine-spacing-sm);
@@ -43,7 +43,7 @@
margin-top: 6px;
text-align: center;
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 {
@@ -36,7 +36,7 @@
padding: 4px var(--mantine-spacing-xs);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
color: var(--mantine-color-dimmed);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
user-select: none;
}
@@ -104,7 +104,7 @@
.chatItemDate {
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;
transition: opacity 150ms;
}
@@ -44,7 +44,7 @@ export function ApiKeyTable({
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Expires")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th aria-label={t("Action")} />
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
@@ -106,11 +106,7 @@ export function ApiKeyTable({
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("API key menu")}
>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
@@ -1,12 +1,4 @@
import {
ActionIcon,
Group,
Menu,
Modal,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { ActionIcon, Group, Menu, Modal, Text, Tooltip } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconRosetteDiscountCheckFilled,
@@ -46,7 +38,6 @@ export function PageVerificationModal({
<Modal
opened={opened}
onClose={onClose}
aria-label={status === "none" ? t("Set up verification") : t("Verify page")}
title={
<Group gap="xs">
<IconShieldCheck
@@ -106,9 +97,9 @@ export function PageVerificationBadge({
withArrow
openDelay={250}
>
<ThemeIcon variant="subtle" color="gray">
<ActionIcon variant="subtle" color="gray">
<IconShieldCheck size={20} stroke={1.5} />
</ThemeIcon>
</ActionIcon>
</Tooltip>
);
}
@@ -118,20 +109,10 @@ export function PageVerificationBadge({
if (status === "none" && readOnly) return null;
const tooltipLabel =
status === "verified" && verificationInfo?.expiresAt
? t("Verified until {{date}}", {
date: new Date(verificationInfo.expiresAt).toLocaleDateString(
undefined,
{ month: "long", day: "numeric", year: "numeric" },
),
})
: getStatusLabel(status, t);
return (
<>
{status !== "none" ? (
<Tooltip label={tooltipLabel} withArrow openDelay={250}>
<Tooltip label={getStatusLabel(status, t)} withArrow openDelay={250}>
<Group
gap={4}
onClick={open}
@@ -149,12 +130,7 @@ export function PageVerificationBadge({
</Tooltip>
) : !readOnly ? (
<Tooltip label={t("Set up verification")} withArrow openDelay={250}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Set up verification")}
onClick={open}
>
<ActionIcon variant="subtle" color="gray" onClick={open}>
<IconShieldCheck size={20} stroke={1.5} />
</ActionIcon>
</Tooltip>
@@ -37,7 +37,7 @@ export function ScimTokenTable({
<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.Th></Table.Th>
</Table.Tr>
</Table.Thead>
@@ -141,7 +141,6 @@ export default function SsoProviderList() {
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Edit {{name}}", { name: provider.name })}
onClick={() => handleEdit(provider)}
>
<IconPencil size={16} />
@@ -153,13 +152,7 @@ export default function SsoProviderList() {
withinPortal
>
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("More actions for {{name}}", {
name: provider.name,
})}
>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
@@ -56,7 +56,6 @@ export default function TemplateCard({
color="gray"
className={classes.menuTarget}
onClick={(e) => e.stopPropagation()}
aria-label={t("Template menu")}
>
<IconDots size={16} />
</ActionIcon>
@@ -24,7 +24,7 @@ export default function TemplatePreviewModal({
const title = template?.title || t("Untitled");
return (
<Modal.Root size={1200} opened={opened} onClose={onClose} aria-label={title}>
<Modal.Root size={1200} opened={opened} onClose={onClose}>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header>
@@ -144,7 +144,6 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
withCloseButton
withBorder
data-comment-dialog
aria-label={t("Add comment")}
>
<Stack gap={2}>
<Group>
@@ -173,15 +173,6 @@ function CommentListItem({
<Box
className={classes.textSelection}
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>
</Box>
@@ -46,11 +46,7 @@ function CommentMenu({
return (
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon
variant="default"
style={{ border: "none" }}
aria-label={t("Comment menu")}
>
<ActionIcon variant="default" style={{ border: "none" }}>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
@@ -36,7 +36,6 @@ export default function AudioView(props: NodeViewProps) {
preload="metadata"
controls
src={safeSrc}
aria-label={placeholder?.name || t("Audio")}
/>
)}
{!safeSrc && previewSrc && (
@@ -46,7 +45,6 @@ export default function AudioView(props: NodeViewProps) {
preload="metadata"
controls
src={previewSrc}
aria-label={placeholder?.name || t("Audio")}
/>
<Loader size={20} pos="absolute" top={6} right={6} />
</Group>
@@ -62,7 +60,7 @@ export default function AudioView(props: NodeViewProps) {
</Group>
)}
{!safeSrc && !previewSrc && !placeholder && (
<audio className={classes.audio} controls aria-label={t("Audio")} />
<audio className={classes.audio} controls />
)}
</div>
</NodeViewWrapper>
@@ -28,18 +28,6 @@
}
}
.colorSwatch:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--mantine-color-blue-6);
position: relative;
z-index: 1;
}
.removeColor:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--mantine-color-blue-6);
}
.buttonRoot {
height: 34px;
padding-left: rem(8);
@@ -27,7 +27,7 @@ import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem {
name: string;
@@ -46,9 +46,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
const user = useAtomValue(userAtom);
const editorToolbarEnabled =
user?.settings?.preferences?.editorToolbar ?? false;
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu);
@@ -152,7 +149,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
return isTextSelected(editor);
},
options: {
placement: editorToolbarEnabled ? "bottom" : "top",
placement: "top",
offset: 8,
onHide: () => {
setIsNodeSelectorOpen(false);
@@ -191,60 +188,56 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
<div className={classes.divider} />
</>
)}
{!editorToolbarEnabled && (
<>
<NodeSelector
editor={props.editor}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsTextAlignmentOpen(false);
setIsColorSelectorOpen(false);
}}
/>
<NodeSelector
editor={props.editor}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsTextAlignmentOpen(false);
setIsColorSelectorOpen(false);
}}
/>
<TextAlignmentSelector
editor={props.editor}
isOpen={isTextAlignmentSelectorOpen}
setIsOpen={() => {
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
<TextAlignmentSelector
editor={props.editor}
isOpen={isTextAlignmentSelectorOpen}
setIsOpen={() => {
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
<ActionIcon.Group>
{items.map((item, index) => (
<Tooltip key={index} label={t(item.name)} withArrow>
<ActionIcon
key={index}
variant="default"
size="lg"
radius="0"
aria-label={t(item.name)}
className={clsx({ [classes.active]: item.isActive() })}
style={{ border: "none" }}
onClick={item.command}
>
<item.icon style={{ width: rem(16) }} stroke={2} />
</ActionIcon>
</Tooltip>
))}
</ActionIcon.Group>
<ActionIcon.Group>
{items.map((item, index) => (
<Tooltip key={index} label={t(item.name)} withArrow>
<ActionIcon
key={index}
variant="default"
size="lg"
radius="0"
aria-label={t(item.name)}
className={clsx({ [classes.active]: item.isActive() })}
style={{ border: "none" }}
onClick={item.command}
>
<item.icon style={{ width: rem(16) }} stroke={2} />
</ActionIcon>
</Tooltip>
))}
</ActionIcon.Group>
<LinkSelector />
<LinkSelector />
<ColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
setIsOpen={() => {
setIsColorSelectorOpen(!isColorSelectorOpen);
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
}}
/>
</>
)}
<ColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
setIsOpen={() => {
setIsColorSelectorOpen(!isColorSelectorOpen);
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
}}
/>
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
<ActionIcon
@@ -4,6 +4,7 @@ import {
Button,
Popover,
rem,
ScrollArea,
Text,
Tooltip,
SimpleGrid,
@@ -113,63 +114,6 @@ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
},
];
const COLOR_GRID_COLS = 5;
function focusSwatch(grid: "text" | "highlight", index: number) {
const el = document.querySelector<HTMLElement>(
`[data-color-grid="${grid}"][data-color-index="${index}"]`,
);
el?.focus();
}
function handleColorKeyNav(
e: React.KeyboardEvent<HTMLDivElement>,
index: number,
grid: "text" | "highlight",
) {
const cols = COLOR_GRID_COLS;
const total =
grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
const col = index % cols;
if (e.key === "ArrowRight") {
e.preventDefault();
if (index < total - 1) focusSwatch(grid, index + 1);
return;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
if (index > 0) focusSwatch(grid, index - 1);
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
const next = index + cols;
if (next < total) {
focusSwatch(grid, next);
} else if (grid === "text") {
focusSwatch("highlight", Math.min(col, HIGHLIGHT_COLORS.length - 1));
} else if (grid === "highlight") {
document
.querySelector<HTMLElement>('[data-color-grid="remove"]')
?.focus();
}
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
const prev = index - cols;
if (prev >= 0) {
focusSwatch(grid, prev);
} else if (grid === "highlight") {
const lastRowStart =
Math.floor((TEXT_COLORS.length - 1) / cols) * cols;
focusSwatch("text", Math.min(lastRowStart + col, TEXT_COLORS.length - 1));
}
return;
}
}
export const ColorSelector: FC<ColorSelectorProps> = ({
editor,
isOpen,
@@ -213,20 +157,13 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
);
return (
<Popover
width={220}
opened={isOpen}
onChange={setIsOpen}
trapFocus
withArrow
>
<Popover width={220} opened={isOpen} withArrow>
<Popover.Target>
<Tooltip label={t("Text color")} withArrow>
<Button
variant="default"
radius="0"
rightSection={<IconChevronDown size={16} />}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setIsOpen(!isOpen)}
data-text-color={activeColorItem?.color || ""}
data-highlight-color={activeHighlightItem?.color || ""}
@@ -235,54 +172,34 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
fontWeight: 500,
fontSize: rem(16),
}}
aria-label={t("Text color")}
aria-haspopup="dialog"
aria-expanded={isOpen}
>
A
</Button>
</Tooltip>
</Popover.Target>
<Popover.Dropdown onMouseDown={(e) => e.preventDefault()}>
<Stack gap="md" p="2px">
<Popover.Dropdown>
<ScrollArea.Autosize type="scroll" mah="400">
<Stack gap="md">
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Text color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => {
const applyTextColor = () => {
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
}
setIsOpen(false);
};
return (
{TEXT_COLORS.map(({ name, color }, index) => (
<Tooltip key={index} label={t(name)} withArrow>
<Box
role="button"
tabIndex={0}
data-autofocus={index === 0 ? true : undefined}
data-color-grid="text"
data-color-index={index}
className={classes.colorSwatch}
aria-label={t(name)}
aria-pressed={!!editorState[`text_${color}`]}
onClick={applyTextColor}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
applyTextColor();
return;
onClick={() => {
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
}
handleColorKeyNav(e, index, "text");
setIsOpen(false);
}}
style={{
width: rem(28),
@@ -304,8 +221,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
A
</Box>
</Tooltip>
);
})}
))}
</SimpleGrid>
</Box>
@@ -314,40 +230,23 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
{t("Highlight color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{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 (
{HIGHLIGHT_COLORS.map(({ name, color }, index) => (
<Tooltip key={index} label={t(name)} withArrow>
<Box
role="button"
tabIndex={0}
data-color-grid="highlight"
data-color-index={index}
className={classes.colorSwatch}
aria-label={t(name)}
aria-pressed={!!editorState[`highlight_${color}`]}
onClick={applyHighlight}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
applyHighlight();
return;
onClick={() => {
if (name === "Default") {
editor.commands.unsetHighlight();
} else {
editor
.chain()
.focus()
.toggleMark("highlight", {
color: color || "",
colorName: name.toLowerCase() || "",
})
.run();
}
handleColorKeyNav(e, index, "highlight");
setIsOpen(false);
}}
style={{
width: rem(28),
@@ -375,35 +274,23 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
)}
</Box>
</Tooltip>
);
})}
))}
</SimpleGrid>
</Box>
<Button
variant="default"
fullWidth
data-color-grid="remove"
className={classes.removeColor}
onClick={() => {
editor.commands.unsetColor();
editor.commands.unsetHighlight();
setIsOpen(false);
}}
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
const lastRowStart =
Math.floor(
(HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS,
) * COLOR_GRID_COLS;
focusSwatch("highlight", lastRowStart);
}
}}
>
{t("Remove color")}
</Button>
</Stack>
</ScrollArea.Autosize>
</Popover.Dropdown>
</Popover>
);
@@ -60,7 +60,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
isCodeBlock: ctx.editor.isActive("codeBlock"),
isCallout: ctx.editor.isActive("callout"),
isDetails: ctx.editor.isActive("details"),
isTransclusionSource: ctx.editor.isActive("transclusionSource"),
isTransclusion: ctx.editor.isActive("transclusion"),
};
},
});
@@ -124,12 +124,6 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
.run(),
isActive: () => editorState?.isBlockquote,
},
{
name: "Synced block",
icon: IconQuote,
command: () => editor.chain().focus().toggleTransclusionSource().run(),
isActive: () => editorState?.isTransclusionSource,
},
{
name: "Code",
icon: IconCode,
@@ -148,6 +142,12 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
command: () => editor.chain().focus().setDetails().run(),
isActive: () => editorState?.isDetails,
},
{
name: "Sync block",
icon: IconQuote,
command: () => editor.chain().focus().toggleTransclusion().run(),
isActive: () => editorState?.isTransclusion,
},
];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
@@ -155,14 +155,9 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
};
return (
<Popover opened={isOpen} onChange={setIsOpen} withArrow>
<Popover opened={isOpen} withArrow>
<Popover.Target>
<Tooltip
label={t("Turn into")}
withArrow
withinPortal={false}
disabled={isOpen}
>
<Tooltip label={t("Turn into")} withArrow withinPortal={false} disabled={isOpen}>
<Button
className={classes.buttonRoot}
variant="default"
@@ -170,9 +165,6 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
aria-label={t("Turn into")}
aria-haspopup="menu"
aria-expanded={isOpen}
>
{t(activeItem?.name)}
</Button>
@@ -7,7 +7,7 @@ import {
IconCheck,
IconChevronDown,
} from "@tabler/icons-react";
import { Menu, Button, Tooltip, rem } from "@mantine/core";
import { Popover, Button, ScrollArea, Tooltip, rem } from "@mantine/core";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
@@ -82,49 +82,47 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0];
return (
<Menu
shadow="md"
position="bottom-start"
withArrow={false}
opened={isOpen}
onChange={setIsOpen}
>
<Menu.Target>
<Tooltip label={t("Text align")} withArrow disabled={isOpen}>
<Popover opened={isOpen} withArrow>
<Popover.Target>
<Tooltip label={t("Text align")} withArrow withinPortal={false} disabled={isOpen}>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
px="5"
radius="0"
rightSection={<IconChevronDown size={16} />}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setIsOpen(!isOpen)}
aria-label={t("Text align")}
aria-haspopup="menu"
aria-expanded={isOpen}
>
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button>
</Tooltip>
</Menu.Target>
</Popover.Target>
<Menu.Dropdown>
{items.map((item, index) => (
<Menu.Item
key={index}
leftSection={<item.icon size={16} />}
rightSection={
activeItem.name === item.name ? <IconCheck size={16} /> : null
}
onClick={() => {
item.command();
setIsOpen(false);
}}
>
{t(item.name)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
<Popover.Dropdown>
<ScrollArea.Autosize type="scroll" mah={400}>
<Button.Group orientation="vertical">
{items.map((item, index) => (
<Button
key={index}
variant="default"
leftSection={<item.icon size={16} />}
rightSection={
activeItem.name === item.name && <IconCheck size={16} />
}
justify="left"
fullWidth
onClick={() => {
item.command();
setIsOpen(false);
}}
style={{ border: "none" }}
>
{t(item.name)}
</Button>
))}
</Button.Group>
</ScrollArea.Autosize>
</Popover.Dropdown>
</Popover>
);
};
@@ -137,13 +137,7 @@ export default function DrawioView(props: NodeViewProps) {
return (
<NodeViewWrapper data-drag-handle>
<Modal.Root
opened={opened}
onClose={handleClose}
fullScreen
closeOnEscape={false}
aria-label={t("Diagram editor")}
>
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body pos="relative">
@@ -1,26 +1,30 @@
import { CommandProps, EmojiMenuItemType } from "./types";
import { buildEmojiIndex, getFrequentlyUsedEmoji, sortFrequentlyUsedEmoji } from "./utils";
import { SearchIndex } from "emoji-mart";
import { getFrequentlyUsedEmoji, sortFrequentlyUsedEmoji } from "./utils";
const MAX_RESULTS = 5;
const searchEmoji = async (query: string): Promise<EmojiMenuItemType[]> => {
if (query === "") {
return sortFrequentlyUsedEmoji(getFrequentlyUsedEmoji());
const searchEmoji = async (value: string): Promise<EmojiMenuItemType[]> => {
if (value === "") {
const frequentlyUsedEmoji = getFrequentlyUsedEmoji();
return sortFrequentlyUsedEmoji(frequentlyUsedEmoji);
}
const q = query.toLowerCase();
const index = await buildEmojiIndex();
return index
.filter((e) => e.name.includes(q) || e.id.includes(q))
.slice(0, MAX_RESULTS)
.map((entry) => ({
id: entry.id,
emoji: entry.native,
const emojis = await SearchIndex.search(value);
const results = emojis.map((emoji: any) => {
return {
id: emoji.id,
emoji: emoji.skins[0].native,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertContent(entry.native + " ").run();
editor
.chain()
.focus()
.deleteRange(range)
.insertContent(emoji.skins[0].native + " ")
.run();
},
}));
};
});
return results;
};
export const getEmojiItems = async ({
@@ -1,208 +1,140 @@
import { Loader, Paper, ScrollArea, Text, UnstyledButton } from "@mantine/core";
import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { EmojiMenuItemType } from "./types";
import {
EmojiCategory,
EmojiIndexEntry,
getEmojiCategories,
incrementEmojiUsage,
} from "./utils";
ActionIcon,
Loader,
Paper,
ScrollArea,
SimpleGrid,
Text,
} from "@mantine/core";
import { EmojiMenuItemType } from "./types";
import clsx from "clsx";
import classes from "./emoji-menu.module.css";
import { useCallback, useEffect, useRef, useState } from "react";
import { GRID_COLUMNS, incrementEmojiUsage } from "./utils";
const COLS = 8;
const CAT_ICONS: Record<string, string> = {
people: "😀",
nature: "🌿",
foods: "🍕",
activity: "🎮",
places: "🗺️",
objects: "🔧",
symbols: "💯",
flags: "🚩",
};
function EmojiList({
const EmojiList = ({
items,
isLoading,
command,
editor,
range,
query = "",
}: {
items: EmojiMenuItemType[];
isLoading: boolean;
command: (item: EmojiMenuItemType) => void;
command: any;
editor: any;
range: any;
query?: string;
}) {
const { t } = useTranslation();
const [idx, setIdx] = useState(0);
const [cats, setCats] = useState<EmojiCategory[]>([]);
const [activeCat, setActiveCat] = useState("");
const [focusZone, setFocusZone] = useState<"grid" | "tabs">("grid");
const listViewport = useRef<HTMLDivElement>(null);
const gridViewport = useRef<HTMLDivElement>(null);
const catBar = useRef<HTMLDivElement>(null);
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const viewportRef = useRef<HTMLDivElement>(null);
const searching = query.length > 0;
const browseLoading = !searching && cats.length === 0;
const gridItems = cats.find((c) => c.id === activeCat)?.emojis ?? [];
useEffect(() => {
getEmojiCategories().then((data) => {
setCats(data);
setActiveCat((prev) => prev || data[0]?.id || "");
});
}, []);
useEffect(() => { setIdx(0); }, [query, activeCat]);
useEffect(() => { if (searching) setFocusZone("grid"); }, [searching]);
useEffect(() => {
if (focusZone !== "tabs") return;
catBar.current?.querySelector<HTMLElement>(`[data-cat="${activeCat}"]`)?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [activeCat, focusZone]);
useEffect(() => {
if (focusZone === "tabs") return;
const vp = searching ? listViewport.current : gridViewport.current;
vp?.querySelector<HTMLElement>(`[data-i="${idx}"]`)?.scrollIntoView({ block: "nearest" });
}, [idx, searching, focusZone]);
const pickSearchItem = useCallback(
(i: number) => {
const item = items[i];
if (!item) return;
command(item);
incrementEmojiUsage(item.id);
},
[command, items],
);
const pickGridItem = useCallback(
(entry: EmojiIndexEntry) => {
editor.chain().focus().deleteRange(range).insertContent(entry.native + " ").run();
incrementEmojiUsage(entry.id);
},
[editor, range],
);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (searching) {
if (e.key === "ArrowDown") { e.preventDefault(); setIdx((i) => Math.min(i + 1, items.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setIdx((i) => Math.max(i - 1, 0)); }
else if (e.key === "Enter") { e.preventDefault(); pickSearchItem(idx); }
} else if (focusZone === "tabs") {
const catIdx = cats.findIndex((c) => c.id === activeCat);
if (e.key === "ArrowRight") { e.preventDefault(); const next = cats[Math.min(catIdx + 1, cats.length - 1)]; if (next) setActiveCat(next.id); }
else if (e.key === "ArrowLeft") { e.preventDefault(); const prev = cats[Math.max(catIdx - 1, 0)]; if (prev) setActiveCat(prev.id); }
else if (e.key === "ArrowDown" || e.key === "Enter") { e.preventDefault(); setFocusZone("grid"); }
else if (e.key === "ArrowUp") { e.preventDefault(); }
} else {
const total = gridItems.length;
if (e.key === "ArrowRight") { e.preventDefault(); setIdx((i) => Math.min(i + 1, total - 1)); }
else if (e.key === "ArrowLeft") { e.preventDefault(); setIdx((i) => Math.max(i - 1, 0)); }
else if (e.key === "ArrowDown") { e.preventDefault(); setIdx((i) => Math.min(i + COLS, total - 1)); }
else if (e.key === "ArrowUp") {
e.preventDefault();
if (idx < COLS) setFocusZone("tabs");
else setIdx((i) => Math.max(i - COLS, 0));
}
else if (e.key === "Enter") { e.preventDefault(); if (gridItems[idx]) pickGridItem(gridItems[idx]); }
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (item) {
command(item);
incrementEmojiUsage(item.id);
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [searching, items, idx, gridItems, pickSearchItem, pickGridItem, focusZone, cats, activeCat]);
},
[command, items]
);
return (
<Paper
id="emoji-command"
p={0}
shadow="md"
withBorder
style={{ width: 280 }}
role="listbox"
aria-label={t("Emoji picker")}
>
{searching ? (
<>
{isLoading && <Loader m="xs" size="xs" color="blue" type="dots" />}
<ScrollArea.Autosize mah={260} scrollbarSize={6} viewportRef={listViewport}>
<div style={{ padding: 4 }}>
{items.length === 0 && !isLoading ? (
<Text size="sm" c="dimmed" p="xs">{t("No results")}</Text>
) : items.map((item, i) => (
<UnstyledButton
key={item.id}
data-i={i}
w="100%"
className={clsx(classes.row, { [classes.active]: i === idx })}
onClick={() => pickSearchItem(i)}
onMouseEnter={() => setIdx(i)}
role="option"
aria-selected={i === idx}
>
<span style={{ fontSize: 20, lineHeight: 1, minWidth: 26 }}>{item.emoji}</span>
<Text size="sm" c="dimmed" ff="monospace" span>:{item.id}:</Text>
</UnstyledButton>
))}
</div>
</ScrollArea.Autosize>
</>
) : browseLoading ? (
<Loader m="xs" size="xs" color="blue" type="dots" />
) : (
<>
<div className={classes.catBar} role="tablist" ref={catBar}>
{cats.map((c) => {
const isActive = c.id === activeCat;
const isFocused = isActive && focusZone === "tabs";
return (
<button
key={c.id}
data-cat={c.id}
title={c.id}
role="tab"
aria-selected={isActive}
className={clsx(classes.catTab, {
[classes.catTabActive]: isActive,
[classes.catTabFocused]: isFocused,
})}
onClick={() => { setActiveCat(c.id); setFocusZone("grid"); }}
onMouseEnter={() => setFocusZone("grid")}
>
{CAT_ICONS[c.id] ?? "🔣"}
</button>
);
})}
</div>
<ScrollArea.Autosize mah={220} scrollbarSize={6} viewportRef={gridViewport}>
<div className={classes.grid} style={{ gridTemplateColumns: `repeat(${COLS}, 1fr)` }}>
{gridItems.map((entry, i) => (
<button
key={entry.id}
data-i={i}
title={`:${entry.id}:`}
className={clsx(classes.emojiBtn, { [classes.active]: i === idx })}
onClick={() => pickGridItem(entry)}
onMouseEnter={() => setIdx(i)}
>
{entry.native}
</button>
))}
</div>
</ScrollArea.Autosize>
</>
useEffect(() => {
const navigationKeys = [
"ArrowRight",
"ArrowLeft",
"ArrowUp",
"ArrowDown",
"Enter",
];
const onKeyDown = (e: KeyboardEvent) => {
if (navigationKeys.includes(e.key)) {
e.preventDefault();
if (e.key === "ArrowRight") {
setSelectedIndex(
selectedIndex + 1 < items.length ? selectedIndex + 1 : selectedIndex
);
return true;
}
if (e.key === "ArrowLeft") {
setSelectedIndex(
selectedIndex - 1 >= 0 ? selectedIndex - 1 : selectedIndex
);
return true;
}
if (e.key === "ArrowUp") {
setSelectedIndex(
selectedIndex - GRID_COLUMNS >= 0
? selectedIndex - GRID_COLUMNS
: selectedIndex
);
return true;
}
if (e.key === "ArrowDown") {
setSelectedIndex(
selectedIndex + GRID_COLUMNS < items.length
? selectedIndex + GRID_COLUMNS
: selectedIndex
);
return true;
}
if (e.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [items, selectedIndex, setSelectedIndex]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
useEffect(() => {
viewportRef.current
?.querySelector(`[data-item-index="${selectedIndex}"]`)
?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
return items.length > 0 || isLoading ? (
<Paper id="emoji-command" p="0" shadow="md" withBorder>
{isLoading && <Loader m="xs" color="blue" type="dots" />}
{items.length > 0 && (
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={250}
scrollbarSize={8}
pr="5"
>
<SimpleGrid cols={GRID_COLUMNS} p="xs" spacing="xs">
{items.map((item, index: number) => (
<ActionIcon
data-item-index={index}
variant="transparent"
key={item.id}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
})}
onClick={() => selectItem(index)}
>
<Text size="xl">{item.emoji}</Text>
</ActionIcon>
))}
</SimpleGrid>
</ScrollArea.Autosize>
)}
</Paper>
);
}
) : null;
};
export default EmojiList;
@@ -1,13 +1,9 @@
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
.menuBtn {
border-radius: var(--mantine-radius-sm);
&:hover {
@mixin light {
background: var(--mantine-color-gray-1);
background: var(--mantine-color-gray-2);
}
@mixin dark {
@@ -16,7 +12,7 @@
}
}
.active {
.selectedItem {
@mixin light {
background: var(--mantine-color-gray-2);
}
@@ -25,83 +21,3 @@
background: var(--mantine-color-gray-light);
}
}
.catBar {
display: flex;
gap: 2px;
padding: 4px 6px;
overflow-x: auto;
scrollbar-width: none;
@mixin light {
border-bottom: 1px solid var(--mantine-color-gray-2);
}
@mixin dark {
border-bottom: 1px solid var(--mantine-color-dark-4);
}
}
.catTab {
background: transparent;
border: none;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 4px 5px;
border-radius: var(--mantine-radius-sm);
flex-shrink: 0;
&:hover {
@mixin light {
background: var(--mantine-color-gray-1);
}
@mixin dark {
background: var(--mantine-color-gray-light);
}
}
}
.catTabActive {
@mixin light {
background: var(--mantine-color-gray-2);
}
@mixin dark {
background: var(--mantine-color-gray-light);
}
}
.catTabFocused {
outline: 1px solid var(--mantine-color-blue-filled);
outline-offset: -1px;
}
.grid {
display: grid;
gap: 1px;
padding: 6px;
}
.emojiBtn {
background: transparent;
border: none;
cursor: pointer;
font-size: 20px;
line-height: 1;
padding: 3px;
border-radius: var(--mantine-radius-sm);
aspect-ratio: 1 / 1;
&:hover {
@mixin light {
background: var(--mantine-color-gray-1);
}
@mixin dark {
background: var(--mantine-color-gray-light);
}
}
}
@@ -1,5 +1,6 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import EmojiList from "./emoji-list";
import { init } from "emoji-mart";
import {
autoUpdate,
computePosition,
@@ -36,6 +37,10 @@ const renderEmojiItems = () => {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
}) => {
init({
data: async () => (await import("@emoji-mart/data")).default,
});
component = new ReactRenderer(EmojiList, {
props: { isLoading: true, items: [] },
editor: props.editor,
@@ -1,4 +1,8 @@
import { CommandProps, EmojiMartFrequentlyType, EmojiMenuItemType } from "./types";
import { CommandProps } from "./types";
import { getEmojiDataFromNative } from "emoji-mart";
import { EmojiMartFrequentlyType, EmojiMenuItemType } from "./types";
export const GRID_COLUMNS = 10;
export const LOCAL_STORAGE_FREQUENT_KEY = "emoji-mart.frequently";
@@ -15,76 +19,41 @@ export const DEFAULT_FREQUENTLY_USED_EMOJI_MART = `{
"rocket": 1
}`;
export type EmojiIndexEntry = { id: string; name: string; native: string };
let _emojiIndex: EmojiIndexEntry[] | null = null;
export const buildEmojiIndex = async (): Promise<EmojiIndexEntry[]> => {
if (_emojiIndex) return _emojiIndex;
const { default: data } = await import("@emoji-mart/data");
_emojiIndex = (Object.values((data as any).emojis) as any[])
.filter((e) => e.id && e.name && e.skins?.[0]?.native)
.map((e) => ({
id: e.id as string,
name: (e.name as string).toLowerCase(),
native: e.skins[0].native as string,
}));
return _emojiIndex;
};
export const incrementEmojiUsage = (emojiId: string) => {
const stored = JSON.parse(
localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART,
const frequentlyUsedEmoji =
JSON.parse(localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART);
frequentlyUsedEmoji[emojiId]
? (frequentlyUsedEmoji[emojiId] += 1)
: (frequentlyUsedEmoji[emojiId] = 1);
localStorage.setItem(
LOCAL_STORAGE_FREQUENT_KEY,
JSON.stringify(frequentlyUsedEmoji)
);
stored[emojiId] = (stored[emojiId] ?? 0) + 1;
localStorage.setItem(LOCAL_STORAGE_FREQUENT_KEY, JSON.stringify(stored));
};
export const sortFrequentlyUsedEmoji = async (
frequentlyUsedEmoji: EmojiMartFrequentlyType,
frequentlyUsedEmoji: EmojiMartFrequentlyType
): Promise<EmojiMenuItemType[]> => {
const index = await buildEmojiIndex();
const results: EmojiMenuItemType[] = Object.entries(frequentlyUsedEmoji)
.map(([id, count]): EmojiMenuItemType | null => {
const entry = index.find((e) => e.id === id);
if (!entry) return null;
return {
const data = await Promise.all(
Object.entries(frequentlyUsedEmoji).map(
async ([id, count]): Promise<EmojiMenuItemType> => ({
id,
count,
emoji: entry.native,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertContent(entry.native + " ").run();
emoji: (await getEmojiDataFromNative(id))?.native,
command: async ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent((await getEmojiDataFromNative(id))?.native + " ")
.run();
},
};
})
.filter((e): e is EmojiMenuItemType => e !== null);
return results.sort((a, b) => (b.count ?? 0) - (a.count ?? 0)).slice(0, 5);
};
export const getFrequentlyUsedEmoji = (): EmojiMartFrequentlyType => {
return JSON.parse(
localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART,
})
)
);
return data.sort((a, b) => b.count - a.count);
};
export type EmojiCategory = { id: string; emojis: EmojiIndexEntry[] };
let _cats: EmojiCategory[] | null = null;
export const getEmojiCategories = async (): Promise<EmojiCategory[]> => {
if (_cats) return _cats;
const [{ default: data }, index] = await Promise.all([
import("@emoji-mart/data"),
buildEmojiIndex(),
]);
const byId = new Map(index.map((e) => [e.id, e]));
_cats = ((data as any).categories as { id: string; emojis: string[] }[])
.map((cat) => ({
id: cat.id,
emojis: cat.emojis
.map((id) => byId.get(id))
.filter((e): e is EmojiIndexEntry => !!e),
}))
.filter((c) => c.emojis.length > 0);
return _cats;
};
export const getFrequentlyUsedEmoji = () => {
return JSON.parse(localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART);
}
@@ -1,72 +0,0 @@
.fixedToolbar {
position: fixed;
top: calc(var(--app-shell-header-offset, 0rem) + 45px);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
z-index: 50;
display: flex;
align-items: center;
background: var(--mantine-color-body);
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
overflow-x: auto;
}
.fixedToolbar::-webkit-scrollbar {
height: 2px;
}
.fixedToolbar::-webkit-scrollbar-track {
background: transparent;
}
.fixedToolbar::-webkit-scrollbar-thumb {
background: light-dark(
var(--mantine-color-gray-4),
var(--mantine-color-dark-3)
);
border-radius: 1px;
}
.inner {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 4px;
padding: 4px 8px;
margin-inline: auto;
}
.inner > * {
flex-shrink: 0;
}
.spacer {
height: 45px;
}
.divider {
flex-shrink: 0;
width: 1px;
height: 20px;
margin: 0 4px;
background: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-4)
);
}
.active,
.active:hover {
color: var(--mantine-color-blue-6);
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
}
@media print {
.fixedToolbar {
display: none;
}
}
@@ -1,65 +0,0 @@
import { FC } from "react";
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import { useToolbarState } from "./use-toolbar-state";
import { BlockTypeGroup } from "./groups/block-type-group";
import { InlineMarksGroup } from "./groups/inline-marks-group";
import { ColorGroup } from "./groups/color-group";
import { ListsGroup } from "./groups/lists-group";
import { LinkGroup } from "./groups/link-group";
import { AlignmentGroup } from "./groups/alignment-group";
import { MediaGroup } from "./groups/media-group";
import { QuickInsertsGroup } from "./groups/quick-inserts-group";
import { MoreInsertsGroup } from "./groups/more-inserts-group";
import { HistoryGroup } from "./groups/history-group";
import { AskAiGroup } from "./groups/ask-ai-group";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import classes from "./fixed-toolbar.module.css";
export const FixedToolbar: FC = () => {
const editor = useAtomValue(pageEditorAtom);
const state = useToolbarState(editor);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
if (!editor || !state) return null;
return (
<>
<div
className={classes.fixedToolbar}
role="toolbar"
aria-label="Editor toolbar"
onMouseDown={(e) => e.preventDefault()}
>
<div className={classes.inner}>
{/* {isGenerativeAiEnabled && (
<>
<AskAiGroup />
<div className={classes.divider} />
</>
)} */}
<BlockTypeGroup editor={editor} />
<div className={classes.divider} />
<InlineMarksGroup editor={editor} state={state} />
<div className={classes.divider} />
<ColorGroup editor={editor} />
<div className={classes.divider} />
<ListsGroup editor={editor} state={state} />
<div className={classes.divider} />
<LinkGroup />
<div className={classes.divider} />
<AlignmentGroup editor={editor} />
<div className={classes.divider} />
<MediaGroup editor={editor} />
<div className={classes.divider} />
<QuickInsertsGroup editor={editor} />
<MoreInsertsGroup editor={editor} />
<div className={classes.divider} />
<HistoryGroup editor={editor} state={state} />
</div>
</div>
<div className={classes.spacer} aria-hidden />
</>
);
};
@@ -1,28 +0,0 @@
import { FC, useEffect, useState } from "react";
import type { Editor } from "@tiptap/react";
import { TextAlignmentSelector } from "@/features/editor/components/bubble-menu/text-alignment-selector";
interface Props {
editor: Editor;
}
export const AlignmentGroup: FC<Props> = ({ editor }) => {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);
return (
<TextAlignmentSelector
editor={editor}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>
);
};
@@ -1,23 +0,0 @@
import { FC } from "react";
import { Button } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useSetAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
export const AskAiGroup: FC = () => {
const { t } = useTranslation();
const setShowAiMenu = useSetAtom(showAiMenuAtom);
return (
<Button
variant="subtle"
color="dark"
size="xs"
leftSection={<IconSparkles size={14} />}
onClick={() => setShowAiMenu(true)}
>
{t("Ask AI")}
</Button>
);
};
@@ -1,108 +0,0 @@
import { FC } from "react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { Button, Menu } from "@mantine/core";
import {
IconBlockquote,
IconBraces,
IconChevronDown,
IconH1,
IconH2,
IconH3,
IconMenu4,
IconTypography,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
interface Props {
editor: Editor;
}
export const BlockTypeGroup: FC<Props> = ({ editor }) => {
const { t } = useTranslation();
const state = useEditorState({
editor,
selector: (ctx) => ({
isHeading1: ctx.editor.isActive("heading", { level: 1 }),
isHeading2: ctx.editor.isActive("heading", { level: 2 }),
isHeading3: ctx.editor.isActive("heading", { level: 3 }),
isBlockquote: ctx.editor.isActive("blockquote"),
isCodeBlock: ctx.editor.isActive("codeBlock"),
}),
});
let label = t("Normal text");
if (state.isHeading1) label = t("Heading 1");
else if (state.isHeading2) label = t("Heading 2");
else if (state.isHeading3) label = t("Heading 3");
else if (state.isBlockquote) label = t("Quote");
else if (state.isCodeBlock) label = t("Code block");
return (
<Menu shadow="md" position="bottom-start" withArrow={false}>
<Menu.Target>
<Button
variant="subtle"
color="dark"
size="xs"
rightSection={<IconChevronDown size={14} />}
>
{label}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconTypography size={16} />}
onClick={() =>
editor.chain().focus().toggleNode("paragraph", "paragraph").run()
}
>
{t("Text")}
</Menu.Item>
<Menu.Item
leftSection={<IconH1 size={16} />}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
>
{t("Heading 1")}
</Menu.Item>
<Menu.Item
leftSection={<IconH2 size={16} />}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
>
{t("Heading 2")}
</Menu.Item>
<Menu.Item
leftSection={<IconH3 size={16} />}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
>
{t("Heading 3")}
</Menu.Item>
<Menu.Item
leftSection={<IconBlockquote size={16} />}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
{t("Quote")}
</Menu.Item>
<Menu.Item
leftSection={<IconBraces size={16} />}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
>
{t("Code block")}
</Menu.Item>
<Menu.Item
leftSection={<IconMenu4 size={16} />}
onClick={() => editor.chain().focus().setHorizontalRule().run()}
>
{t("Divider")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
@@ -1,24 +0,0 @@
import { FC, useEffect, useState } from "react";
import type { Editor } from "@tiptap/react";
import { ColorSelector } from "@/features/editor/components/bubble-menu/color-selector";
interface Props {
editor: Editor;
}
export const ColorGroup: FC<Props> = ({ editor }) => {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);
return (
<ColorSelector editor={editor} isOpen={isOpen} setIsOpen={setIsOpen} />
);
};
@@ -1,44 +0,0 @@
import { FC } from "react";
import type { Editor } from "@tiptap/react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconArrowBackUp, IconArrowForwardUp } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import type { ToolbarState } from "../use-toolbar-state";
interface Props {
editor: Editor;
state: ToolbarState;
}
export const HistoryGroup: FC<Props> = ({ editor, state }) => {
const { t } = useTranslation();
return (
<ActionIcon.Group>
<Tooltip label={t("Undo")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("Undo")}
disabled={!state.canUndo}
onClick={() => editor.chain().focus().undo().run()}
>
<IconArrowBackUp size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Redo")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("Redo")}
disabled={!state.canRedo}
onClick={() => editor.chain().focus().redo().run()}
>
<IconArrowForwardUp size={16} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
);
};
@@ -1,131 +0,0 @@
import { FC } from "react";
import type { Editor } from "@tiptap/react";
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import {
IconBold,
IconChevronDown,
IconClearFormatting,
IconCode,
IconIndentDecrease,
IconIndentIncrease,
IconItalic,
IconStrikethrough,
IconSubscript,
IconSuperscript,
IconUnderline,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import type { ToolbarState } from "../use-toolbar-state";
import classes from "../fixed-toolbar.module.css";
interface Props {
editor: Editor;
state: ToolbarState;
}
export const InlineMarksGroup: FC<Props> = ({ editor, state }) => {
const { t } = useTranslation();
return (
<ActionIcon.Group>
<Tooltip label={t("Bold")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("Bold")}
aria-pressed={state.isBold}
className={clsx({ [classes.active]: state.isBold })}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<IconBold size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Underline")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("Underline")}
aria-pressed={state.isUnderline}
className={clsx({ [classes.active]: state.isUnderline })}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<IconUnderline size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Italic")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("Italic")}
aria-pressed={state.isItalic}
className={clsx({ [classes.active]: state.isItalic })}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<IconItalic size={16} />
</ActionIcon>
</Tooltip>
<Menu shadow="md" position="bottom-start" withArrow={false}>
<Menu.Target>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("More inline formatting")}
>
<IconChevronDown size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconStrikethrough size={16} />}
onClick={() => editor.chain().focus().toggleStrike().run()}
>
{t("Strikethrough")}
</Menu.Item>
<Menu.Item
leftSection={<IconCode size={16} />}
onClick={() => editor.chain().focus().toggleCode().run()}
>
{t("Inline code")}
</Menu.Item>
<Menu.Item
leftSection={<IconSubscript size={16} />}
onClick={() => editor.chain().focus().toggleSubscript().run()}
>
{t("Subscript")}
</Menu.Item>
<Menu.Item
leftSection={<IconSuperscript size={16} />}
onClick={() => editor.chain().focus().toggleSuperscript().run()}
>
{t("Superscript")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconIndentIncrease size={16} />}
onClick={() => editor.chain().focus().indent().run()}
>
{t("Increase indent")}
</Menu.Item>
<Menu.Item
leftSection={<IconIndentDecrease size={16} />}
onClick={() => editor.chain().focus().outdent().run()}
>
{t("Decrease indent")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconClearFormatting size={16} />}
onClick={() => editor.chain().focus().unsetAllMarks().run()}
>
{t("Clear formatting")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</ActionIcon.Group>
);
};
@@ -1,6 +0,0 @@
import { FC } from "react";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector";
export const LinkGroup: FC = () => {
return <LinkSelector />;
};
@@ -1,61 +0,0 @@
import { FC } from "react";
import type { Editor } from "@tiptap/react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconCheckbox, IconList, IconListNumbers } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import type { ToolbarState } from "../use-toolbar-state";
import classes from "../fixed-toolbar.module.css";
interface Props {
editor: Editor;
state: ToolbarState;
}
export const ListsGroup: FC<Props> = ({ editor, state }) => {
const { t } = useTranslation();
return (
<ActionIcon.Group>
<Tooltip label={t("Bullet List")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("Bullet List")}
aria-pressed={state.isBulletList}
className={clsx({ [classes.active]: state.isBulletList })}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<IconList size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Numbered List")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("Numbered List")}
aria-pressed={state.isOrderedList}
className={clsx({ [classes.active]: state.isOrderedList })}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<IconListNumbers size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("To-do List")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("To-do List")}
aria-pressed={state.isTaskList}
className={clsx({ [classes.active]: state.isTaskList })}
onClick={() => editor.chain().focus().toggleTaskList().run()}
>
<IconCheckbox size={16} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
);
};
@@ -1,118 +0,0 @@
import { FC } from "react";
import type { Editor } from "@tiptap/react";
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import {
IconFileTypePdf,
IconMovie,
IconMusic,
IconPaperclip,
IconPhoto,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action";
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action";
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action";
import { uploadPdfAction } from "@/features/editor/components/pdf/upload-pdf-action";
interface Props {
editor: Editor;
}
type UploadFn = (
file: File,
editor: Editor,
pos: number,
pageId: string,
...rest: any[]
) => void;
function pickFile(
editor: Editor,
accept: string,
multiple: boolean,
upload: UploadFn,
extra?: boolean,
) {
// @ts-ignore — editor.storage.pageId is set by PageEditor.onCreate
const pageId = editor.storage?.pageId as string | undefined;
if (!pageId) return;
const input = document.createElement("input");
input.type = "file";
input.accept = accept;
input.multiple = multiple;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = () => {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
if (extra !== undefined) {
upload(file, editor, pos, pageId, extra);
} else {
upload(file, editor, pos, pageId);
}
}
}
input.remove();
};
input.click();
}
export const MediaGroup: FC<Props> = ({ editor }) => {
const { t } = useTranslation();
return (
<Menu shadow="md" position="bottom-start" withArrow={false}>
<Menu.Target>
<Tooltip label={t("Insert media")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("Insert media")}
>
<IconPhoto size={16} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconPhoto size={16} />}
onClick={() => pickFile(editor, "image/*", true, uploadImageAction)}
>
{t("Image")}
</Menu.Item>
<Menu.Item
leftSection={<IconMovie size={16} />}
onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)}
>
{t("Video")}
</Menu.Item>
<Menu.Item
leftSection={<IconMusic size={16} />}
onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)}
>
{t("Audio")}
</Menu.Item>
<Menu.Item
leftSection={<IconFileTypePdf size={16} />}
onClick={() =>
pickFile(editor, "application/pdf", false, uploadPdfAction)
}
>
PDF
</Menu.Item>
<Menu.Item
leftSection={<IconPaperclip size={16} />}
onClick={() =>
pickFile(editor, "", true, uploadAttachmentAction, true)
}
>
{t("File attachment")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
@@ -1,217 +0,0 @@
import { FC } from "react";
import type { Editor } from "@tiptap/react";
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import {
IconAppWindow,
IconCalendar,
IconCaretRightFilled,
IconChevronDown,
IconInfoCircle,
IconMath,
IconMathFunction,
IconRotate2,
IconSitemap,
IconTag,
} from "@tabler/icons-react";
import IconExcalidraw from "@/components/icons/icon-excalidraw";
import IconMermaid from "@/components/icons/icon-mermaid";
import IconDrawio from "@/components/icons/icon-drawio";
import {
AirtableIcon,
FigmaIcon,
FramerIcon,
GoogleDriveIcon,
GoogleSheetsIcon,
LoomIcon,
MiroIcon,
TypeformIcon,
VimeoIcon,
YoutubeIcon,
} from "@/components/icons";
import { useTranslation } from "react-i18next";
interface Props {
editor: Editor;
}
export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
const { t } = useTranslation();
const setEmbed = (provider: string) =>
editor.chain().focus().setEmbed({ provider }).run();
const insertDate = () => {
const currentDate = new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
editor.chain().focus().insertContent(currentDate).run();
};
return (
<Menu shadow="md" position="bottom-start" withArrow={false} width={240}>
<Menu.Target>
<Tooltip label={t("More inserts")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("More inserts")}
>
<IconChevronDown size={16} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown mah={400} style={{ overflowY: "auto" }}>
<Menu.Label>{t("Advanced")}</Menu.Label>
<Menu.Item
leftSection={<IconInfoCircle size={16} />}
onClick={() => editor.chain().focus().toggleCallout().run()}
>
{t("Callout")}
</Menu.Item>
<Menu.Item
leftSection={<IconCaretRightFilled size={16} />}
onClick={() => editor.chain().focus().setDetails().run()}
>
{t("Toggle block")}
</Menu.Item>
<Menu.Item
leftSection={<IconTag size={16} />}
onClick={() =>
editor.chain().focus().setStatus({ text: "", color: "gray" }).run()
}
>
{t("Status")}
</Menu.Item>
<Menu.Item
leftSection={<IconSitemap size={16} />}
onClick={() => editor.chain().focus().insertSubpages().run()}
>
{t("Subpages")}
</Menu.Item>
<Menu.Item
leftSection={<IconRotate2 size={16} />}
onClick={() =>
editor.chain().focus().insertTransclusionSource().run()
}
>
{t("Synced block")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>{t("Diagrams")}</Menu.Label>
<Menu.Item
leftSection={<IconMermaid size={16} />}
onClick={() =>
editor
.chain()
.focus()
.setCodeBlock({ language: "mermaid" })
.insertContent("flowchart LR\n A --> B")
.run()
}
>
{t("Mermaid diagram")}
</Menu.Item>
<Menu.Item
leftSection={<IconDrawio size={16} />}
onClick={() => editor.chain().focus().setDrawio().run()}
>
Draw.io
</Menu.Item>
<Menu.Item
leftSection={<IconExcalidraw size={16} />}
onClick={() => editor.chain().focus().setExcalidraw().run()}
>
Excalidraw
</Menu.Item>
<Menu.Divider />
<Menu.Label>{t("Embeds")}</Menu.Label>
<Menu.Item
leftSection={<IconAppWindow size={16} />}
onClick={() => setEmbed("iframe")}
>
Iframe
</Menu.Item>
<Menu.Item
leftSection={<YoutubeIcon size={16} />}
onClick={() => setEmbed("youtube")}
>
YouTube
</Menu.Item>
<Menu.Item
leftSection={<VimeoIcon size={16} />}
onClick={() => setEmbed("vimeo")}
>
Vimeo
</Menu.Item>
<Menu.Item leftSection={<LoomIcon size={16} />} onClick={() => setEmbed("loom")}>
Loom
</Menu.Item>
<Menu.Item
leftSection={<FigmaIcon size={16} />}
onClick={() => setEmbed("figma")}
>
Figma
</Menu.Item>
<Menu.Item
leftSection={<AirtableIcon size={16} />}
onClick={() => setEmbed("airtable")}
>
Airtable
</Menu.Item>
<Menu.Item
leftSection={<TypeformIcon size={16} />}
onClick={() => setEmbed("typeform")}
>
Typeform
</Menu.Item>
<Menu.Item leftSection={<MiroIcon size={16} />} onClick={() => setEmbed("miro")}>
Miro
</Menu.Item>
<Menu.Item
leftSection={<FramerIcon size={16} />}
onClick={() => setEmbed("framer")}
>
Framer
</Menu.Item>
<Menu.Item
leftSection={<GoogleDriveIcon size={16} />}
onClick={() => setEmbed("gdrive")}
>
Google Drive
</Menu.Item>
<Menu.Item
leftSection={<GoogleSheetsIcon size={16} />}
onClick={() => setEmbed("gsheets")}
>
Google Sheets
</Menu.Item>
<Menu.Divider />
<Menu.Label>{t("Utility")}</Menu.Label>
<Menu.Item
leftSection={<IconCalendar size={16} />}
onClick={insertDate}
>
{t("Date")}
</Menu.Item>
<Menu.Item
leftSection={<IconMathFunction size={16} />}
onClick={() => editor.chain().focus().setMathInline().run()}
>
{t("Math inline")}
</Menu.Item>
<Menu.Item
leftSection={<IconMath size={16} />}
onClick={() => editor.chain().focus().setMathBlock().run()}
>
{t("Math block")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
@@ -1,117 +0,0 @@
import { FC } from "react";
import type { Editor } from "@tiptap/react";
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import {
IconAt,
IconColumns2,
IconColumns3,
IconMoodSmile,
IconTable,
} from "@tabler/icons-react";
import { IconColumns4 } from "@/components/icons/icon-columns-4";
import { IconColumns5 } from "@/components/icons/icon-columns-5";
import { useTranslation } from "react-i18next";
interface Props {
editor: Editor;
}
export const QuickInsertsGroup: FC<Props> = ({ editor }) => {
const { t } = useTranslation();
return (
<ActionIcon.Group>
<Tooltip label={t("Mention")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("Mention")}
onClick={() => editor.chain().focus().insertContent("@").run()}
>
<IconAt size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Emoji")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("Emoji")}
onClick={() => editor.chain().focus().insertContent(":").run()}
>
<IconMoodSmile size={16} />
</ActionIcon>
</Tooltip>
<Menu shadow="md" position="bottom-start" withArrow={false}>
<Menu.Target>
<Tooltip label={t("Columns")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("Columns")}
>
<IconColumns2 size={16} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconColumns2 size={16} />}
onClick={() =>
editor.chain().focus().insertColumns({ layout: "two_equal" }).run()
}
>
{t("{{count}} Columns", { count: 2 })}
</Menu.Item>
<Menu.Item
leftSection={<IconColumns3 size={16} />}
onClick={() =>
editor
.chain()
.focus()
.insertColumns({ layout: "three_equal" })
.run()
}
>
{t("{{count}} Columns", { count: 3 })}
</Menu.Item>
<Menu.Item
leftSection={<IconColumns4 size={16} />}
onClick={() =>
editor.chain().focus().insertColumns({ layout: "four_equal" }).run()
}
>
{t("{{count}} Columns", { count: 4 })}
</Menu.Item>
<Menu.Item
leftSection={<IconColumns5 size={16} />}
onClick={() =>
editor.chain().focus().insertColumns({ layout: "five_equal" }).run()
}
>
{t("{{count}} Columns", { count: 5 })}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Tooltip label={t("Table")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="md"
aria-label={t("Table")}
onClick={() =>
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run()
}
>
<IconTable size={16} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
);
};
@@ -1,50 +0,0 @@
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
export interface ToolbarState {
isBold: boolean;
isItalic: boolean;
isUnderline: boolean;
isStrike: boolean;
isCode: boolean;
isSubscript: boolean;
isSuperscript: boolean;
isBulletList: boolean;
isOrderedList: boolean;
isTaskList: boolean;
canUndo: boolean;
canRedo: boolean;
}
// Undo/redo come from either StarterKit's history or the Yjs collaboration
// history extension. During the brief moment a page is rendered with the
// static editor (mainExtensions only, undoRedo disabled), neither is loaded
// and editor.can().undo/redo is undefined.
function safeCan(editor: Editor, command: "undo" | "redo"): boolean {
const can = editor.can() as Record<string, unknown>;
const fn = can[command];
return typeof fn === "function" ? (fn as () => boolean)() : false;
}
export function useToolbarState(editor: Editor | null): ToolbarState | null {
return useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) return null;
return {
isBold: ctx.editor.isActive("bold"),
isItalic: ctx.editor.isActive("italic"),
isUnderline: ctx.editor.isActive("underline"),
isStrike: ctx.editor.isActive("strike"),
isCode: ctx.editor.isActive("code"),
isSubscript: ctx.editor.isActive("subscript"),
isSuperscript: ctx.editor.isActive("superscript"),
isBulletList: ctx.editor.isActive("bulletList"),
isOrderedList: ctx.editor.isActive("orderedList"),
isTaskList: ctx.editor.isActive("taskList"),
canUndo: safeCan(ctx.editor, "undo"),
canRedo: safeCan(ctx.editor, "redo"),
};
},
});
}
@@ -102,14 +102,6 @@ export const LinkEditorPanel = ({
leftSection={<IconLink size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />}
classNames={{ input: classes.linkInput }}
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}
onChange={state.onChange}
onKeyDown={handleKeyDown}
@@ -133,16 +125,10 @@ export const LinkEditorPanel = ({
scrollbarSize={6}
mt={state.url.length > 0 ? 8 : 0}
styles={{ content: { minWidth: 0 } }}
id="link-editor-results"
role="listbox"
aria-label={t("Link suggestions")}
>
{showUrlItem && (
<UnstyledButton
data-item-index={0}
id="link-editor-option-0"
role="option"
aria-selected={selectedIndex === 0}
onClick={() => onSetLink(state.url, false)}
className={clsx(classes.searchItem, {
[classes.selectedSearchItem]: selectedIndex === 0,
@@ -170,9 +156,6 @@ export const LinkEditorPanel = ({
return (
<UnstyledButton
data-item-index={itemIndex}
id={`link-editor-option-${itemIndex}`}
role="option"
aria-selected={itemIndex === selectedIndex}
key={page.id || index}
onClick={() => selectPage(page)}
className={clsx(classes.searchItem, {
@@ -287,16 +287,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
);
return (
<Paper
id="mention"
shadow="md"
withBorder
radius="md"
py={6}
role="listbox"
aria-label={t("Mention suggestions")}
aria-activedescendant={`mention-option-${selectedIndex}`}
>
<Paper id="mention" shadow="md" withBorder radius="md" py={6}>
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={350}
@@ -310,7 +301,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
if (item.entityType === "header") {
const isFirst = index === 0;
return (
<div key={`${item.label}-${index}`} role="presentation">
<div key={`${item.label}-${index}`}>
{!isFirst && <Divider my={6} />}
<Text
c="dimmed"
@@ -331,9 +322,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
<UnstyledButton
data-item-index={index}
key={index}
id={`mention-option-${index}`}
role="option"
aria-selected={index === selectedIndex}
onClick={() => selectItem(index)}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
@@ -360,9 +348,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
<UnstyledButton
data-item-index={index}
key={index}
id={`mention-option-${index}`}
role="option"
aria-selected={index === selectedIndex}
onClick={() => selectItem(index)}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
@@ -373,7 +358,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
<ActionIcon
variant="subtle"
component="div"
aria-hidden="true"
aria-label={item.label}
color="gray"
size="sm"
>
@@ -405,11 +390,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
{(hasUsers || hasPages) && <Divider my={6} />}
<UnstyledButton
data-item-index={renderItems.indexOf(createPageItemData)}
id={`mention-option-${renderItems.indexOf(createPageItemData)}`}
role="option"
aria-selected={
renderItems.indexOf(createPageItemData) === selectedIndex
}
onClick={() =>
selectItem(renderItems.indexOf(createPageItemData))
}
@@ -425,7 +405,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
component="div"
color="gray"
size="sm"
aria-hidden="true"
>
<IconPlus size={16} stroke={1.5} />
</ActionIcon>
@@ -92,20 +92,7 @@ export default function PdfView(props: NodeViewProps) {
if (hasError) {
return (
<NodeViewWrapper data-drag-handle>
<div
data-pdf-error
className={clsx(classes.pdfError, { "ProseMirror-selectednode": selected })}
onClick={handleSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect();
}
}}
role="button"
tabIndex={0}
aria-label={t("Failed to load PDF")}
>
<div data-pdf-error className={clsx(classes.pdfError, { "ProseMirror-selectednode": selected })} onClick={handleSelect}>
<IconFileTypePdf size={32} stroke={1.5} />
<Text size="sm" c="dimmed">
{t("Failed to load PDF")}
@@ -187,14 +187,12 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
position={{ top: 90, right: 50 }}
withBorder
transitionProps={{ transition: "slide-down" }}
aria-label={t("Find and replace")}
>
<Stack gap="xs">
<Flex align="center" gap="xs">
<Input
ref={inputRef}
placeholder={t("Find")}
aria-label={t("Find")}
leftSection={<IconSearch size={16} />}
rightSection={
<Text size="xs" ta="right">
@@ -219,12 +217,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
<ActionIcon.Group>
<Tooltip label={t("Previous match (Shift+Enter)")}>
<ActionIcon
variant="subtle"
color="gray"
onClick={previous}
aria-label={t("Previous match (Shift+Enter)")}
>
<ActionIcon variant="subtle" color="gray" onClick={previous}>
<IconArrowNarrowUp
style={{ width: "70%", height: "70%" }}
stroke={1.5}
@@ -232,12 +225,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
</ActionIcon>
</Tooltip>
<Tooltip label={t("Next match (Enter)")}>
<ActionIcon
variant="subtle"
color="gray"
onClick={next}
aria-label={t("Next match (Enter)")}
>
<ActionIcon variant="subtle" color="gray" onClick={next}>
<IconArrowNarrowDown
style={{ width: "70%", height: "70%" }}
stroke={1.5}
@@ -249,8 +237,6 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
variant="subtle"
color={caseSensitive.color}
onClick={() => caseSensitiveToggle()}
aria-label={t("Match case (Alt+C)")}
aria-pressed={caseSensitive.isCaseSensitive}
>
<IconLetterCase
style={{ width: "70%", height: "70%" }}
@@ -264,8 +250,6 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
variant="subtle"
color={replaceButton.color}
onClick={() => replaceButtonToggle()}
aria-label={t("Replace")}
aria-pressed={replaceButton.isReplaceShow}
>
<IconReplace
style={{ width: "70%", height: "70%" }}
@@ -275,12 +259,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
</Tooltip>
)}
<Tooltip label={t("Close (Escape)")}>
<ActionIcon
variant="subtle"
color="gray"
onClick={closeDialog}
aria-label={t("Close (Escape)")}
>
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}>
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
</ActionIcon>
</Tooltip>
@@ -290,7 +269,6 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
<Flex align="center" gap="xs">
<Input
placeholder={t("Replace")}
aria-label={t("Replace")}
leftSection={<IconReplace size={16} />}
rightSection={<div></div>}
rightSectionPointerEvents="all"
@@ -86,15 +86,7 @@ const CommandList = ({
}, [selectedIndex]);
return flatItems.length > 0 ? (
<Paper
id="slash-command"
shadow="md"
p="xs"
withBorder
role="listbox"
aria-label={t("Slash commands")}
aria-activedescendant={`slash-command-option-${selectedIndex}`}
>
<Paper id="slash-command" shadow="md" p="xs" withBorder>
<ScrollArea
viewportRef={viewportRef}
h={350}
@@ -102,30 +94,22 @@ const CommandList = ({
scrollbarSize={8}
overscrollBehavior="contain"
>
{(() => {
let flatIndex = -1;
return Object.entries(items).map(([category, categoryItems]) => (
<div key={category} role="group" aria-label={category}>
{Object.entries(items).map(([category, categoryItems]) => (
<div key={category}>
<Text c="dimmed" mb={4} fw={500} tt="capitalize">
{category}
</Text>
{categoryItems.map((item: SlashMenuItemType) => {
flatIndex += 1;
const itemIndex = flatIndex;
return (
{categoryItems.map((item: SlashMenuItemType, index: number) => (
<UnstyledButton
data-item-index={itemIndex}
key={itemIndex}
id={`slash-command-option-${itemIndex}`}
role="option"
aria-selected={itemIndex === selectedIndex}
onClick={() => selectItem(itemIndex)}
data-item-index={index}
key={index}
onClick={() => selectItem(index)}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: itemIndex === selectedIndex,
[classes.selectedItem]: index === selectedIndex,
})}
>
<Group>
<ActionIcon variant="default" component="div" aria-hidden="true">
<ActionIcon variant="default" component="div">
<item.icon size={18} />
</ActionIcon>
@@ -140,11 +124,9 @@ const CommandList = ({
</div>
</Group>
</UnstyledButton>
);
})}
))}
</div>
));
})()}
))}
</ScrollArea>
</Paper>
) : null;
@@ -25,7 +25,6 @@ import {
IconColumns3,
IconColumns2,
IconTag,
IconMoodSmile,
IconRotate2,
} from "@tabler/icons-react";
import {
@@ -134,7 +133,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "Numbered list",
description: "Create a list with numbering.",
searchTerms: ["numbered", "ordered", "list", "ol"],
searchTerms: ["numbered", "ordered", "list"],
icon: IconListNumbers,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
@@ -233,15 +232,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "Audio",
description: "Upload any audio from your device.",
searchTerms: [
"audio",
"music",
"sound",
"mp3",
"media",
"file",
"attachment",
],
searchTerms: ["audio", "music", "sound", "mp3", "media", "file", "attachment"],
icon: IconMusic,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
@@ -478,38 +469,22 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run();
},
},
{
title: "Emoji",
description: "Insert emoji.",
searchTerms: ["emoji", "icon", "smiley", "emoticon", "symbol", "reaction"],
icon: IconMoodSmile,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertContent(":").run();
},
},
{
title: "Subpages (Child pages)",
description: "List all subpages of the current page",
searchTerms: [
"subpages",
"child",
"children",
"nested",
"hierarchy",
"toc",
],
searchTerms: ["subpages", "child", "children", "nested", "hierarchy"],
icon: IconSitemap,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertSubpages().run();
},
},
{
title: "Synced block",
title: "Sync block",
description: "Create a block that stays in sync across pages.",
searchTerms: [
"sync",
"synced",
"synced block",
"sync block",
"excerpt",
"transclusion",
"reusable",
@@ -517,12 +492,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
],
icon: IconRotate2,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTransclusionSource()
.run();
editor.chain().focus().deleteRange(range).insertTransclusion().run();
},
},
{
@@ -92,17 +92,8 @@ export default function StatusView(props: NodeViewProps) {
colorClassMap[color],
)}
onClick={() => isEditable && setOpened(true)}
onKeyDown={(e) => {
if (isEditable && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setOpened(true);
}
}}
role="button"
tabIndex={0}
aria-label={text || "SET STATUS"}
aria-haspopup="dialog"
aria-expanded={opened}
>
{text || "SET STATUS"}
</span>
@@ -136,16 +127,6 @@ export default function StatusView(props: NodeViewProps) {
)}
style={{ backgroundColor: bg }}
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} />}
</Box>
@@ -25,7 +25,7 @@ const recalculateLinks = (nodePos: NodePos[]) => {
(acc, item) => {
const label = item.node.textContent;
const level = Number(item.node.attrs.level);
if (label.length && level <= 4) {
if (label.length && level <= 3) {
acc.push({
label,
level,
@@ -7,11 +7,16 @@ export default function ErrorPlaceholder() {
return (
<div className={classes.placeholder}>
<IconAlertTriangle
size={18}
stroke={1.6}
size={20}
stroke={1.5}
className={classes.placeholderIcon}
/>
<span>{t("Failed to load this synced block")}</span>
<div className={classes.placeholderTitle}>
{t("Failed to load transclusion")}
</div>
<div className={classes.placeholderSubtext}>
{t("An error occurred while rendering this reference")}
</div>
</div>
);
}
@@ -6,8 +6,11 @@ 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>
<IconEyeOff size={20} stroke={1.5} className={classes.placeholderIcon} />
<div className={classes.placeholderTitle}>{t("No access")}</div>
<div className={classes.placeholderSubtext}>
{t("You don't have access to this content")}
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { IconInfoCircle } from "@tabler/icons-react";
import { IconQuestionMark } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "./transclusion.module.css";
@@ -6,12 +6,19 @@ export default function NotFoundPlaceholder() {
const { t } = useTranslation();
return (
<div className={classes.placeholder}>
<IconInfoCircle
size={18}
stroke={1.6}
<IconQuestionMark
size={20}
stroke={1.5}
className={classes.placeholderIcon}
/>
<span>{t("The original synced block no longer exists")}</span>
<div className={classes.placeholderTitle}>
{t("Synced block unavailable")}
</div>
<div className={classes.placeholderSubtext}>
{t(
"The source may have been removed, or embedding it here would create a loop.",
)}
</div>
</div>
);
}
@@ -2,12 +2,14 @@ import { EditorProvider } from "@tiptap/react";
import { useMemo } from "react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { UniqueID } from "@docmost/editor-ext";
import { TransclusionLookupProvider } from "./transclusion-lookup-context";
type Props = {
hostPageId: string;
content: unknown;
};
export default function TransclusionContent({ content }: Props) {
export default function TransclusionContent({ hostPageId, content }: Props) {
const extensions = useMemo(() => {
const filtered = mainExtensions.filter(
(e: any) => e.name !== "uniqueID" && e.name !== "globalDragHandle",
@@ -15,7 +17,7 @@ export default function TransclusionContent({ content }: Props) {
return [
...filtered,
UniqueID.configure({
types: ["heading", "paragraph", "transclusionSource"],
types: ["heading", "paragraph", "transclusion"],
updateDocument: false,
}),
];
@@ -30,19 +32,21 @@ export default function TransclusionContent({ content }: Props) {
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>
<TransclusionLookupProvider hostPageId={hostPageId}>
<div
onMouseDown={stop}
onClick={stop}
onDragStart={stop}
onDragOver={stop}
onDrop={stop}
>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={extensions}
content={content as any}
/>
</div>
</TransclusionLookupProvider>
);
}
@@ -7,10 +7,7 @@ import React, {
useRef,
useState,
} from "react";
import {
lookupTransclusion,
lookupTransclusionForShare,
} from "@/features/transclusion/services/transclusion-api";
import { lookupTransclusion } from "@/features/transclusion/services/transclusion-api";
import type { TransclusionLookup } from "@/features/transclusion/types/transclusion.types";
type LookupKey = string; // `${sourcePageId}::${transclusionId}`
@@ -37,24 +34,18 @@ 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.
* Retained for API compatibility with previous callers that passed the
* host page id; no longer used internally now that cycle prevention lives
* on the server side and lookups are stateless.
*/
shareId?: string;
hostPageId?: string;
children: React.ReactNode;
}) {
const subscribersRef = useRef(new Map<LookupKey, Subscriber[]>());
const queueRef = useRef(new Set<LookupKey>());
const tickRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 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.
@@ -90,13 +81,7 @@ export function TransclusionLookupProvider({
};
try {
const activeShareId = shareIdRef.current;
const { items } = activeShareId
? await lookupTransclusionForShare({
shareId: activeShareId,
references,
})
: await lookupTransclusion({ references });
const { items } = await lookupTransclusion({ references });
for (const r of items) {
const key = `${r.sourcePageId}::${r.transclusionId}`;
resultCacheRef.current.set(key, r);
@@ -2,8 +2,8 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import {
IconDots,
IconExternalLink,
IconLinkOff,
IconPencil,
IconRefresh,
IconTrash,
} from "@tabler/icons-react";
@@ -87,13 +87,10 @@ function TransclusionReferenceBody({
);
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;
if (source?.spaceSlug) {
return buildPageUrl(source.spaceSlug, source.slugId, source.title);
}
return sourcePageId ? `/p/${sourcePageId}` : null;
})();
const handleUnsync = async () => {
@@ -121,11 +118,7 @@ function TransclusionReferenceBody({
return (
<>
{isEditable && (
<div
className={classes.includeControls}
contentEditable={false}
onMouseDown={(e) => e.preventDefault()}
>
<div className={classes.includeControls} contentEditable={false}>
{sourcePageId && transclusionId && hostPageId && (
<SyncBlockReferencesDropdown
sourcePageId={sourcePageId}
@@ -149,19 +142,15 @@ function TransclusionReferenceBody({
</ActionIcon>
</Tooltip>
{sourcePageHref && (
<Tooltip label={t("Edit source")}>
<Tooltip label={t("Go to source page")}>
<ActionIcon
component={Link}
to={sourcePageHref}
variant="subtle"
color="gray"
size="sm"
style={{
textDecoration: "none",
borderBottom: "none",
}}
>
<IconPencil size={14} />
<IconExternalLink size={14} />
</ActionIcon>
</Tooltip>
)}
@@ -201,7 +190,10 @@ function TransclusionReferenceBody({
) : !result ? (
<div style={{ minHeight: 24 }} />
) : !("status" in result) ? (
<TransclusionContent content={result.content} />
<TransclusionContent
hostPageId={hostPageId ?? sourcePageId}
content={result.content}
/>
) : result.status === "no_access" ? (
<NoAccessPlaceholder />
) : (
@@ -56,21 +56,17 @@ export default function TransclusionView(props: NodeViewProps) {
};
const handleUnsync = () => {
editor.chain().focus().unsyncTransclusionSource().run();
editor.chain().focus().unsyncTransclusion().run();
};
return (
<NodeViewWrapper
className={classes.transclusionWrap}
data-drag-handle
data-menu-open={openMenus > 0 ? "true" : "false"}
data-id={transclusionId ?? undefined}
>
{isEditable && (
<div
className={classes.transclusionControls}
contentEditable={false}
onMouseDown={(e) => e.preventDefault()}
>
<div className={classes.transclusionControls} contentEditable={false}>
{sourcePageId && transclusionId && (
<SyncBlockReferencesDropdown
sourcePageId={sourcePageId}
@@ -113,7 +109,7 @@ export default function TransclusionView(props: NodeViewProps) {
leftSection={<IconTrash size={14} />}
onClick={() => deleteNode()}
>
{t("Delete synced block")}
{t("Delete sync block")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
@@ -1,22 +1,33 @@
.placeholder {
display: flex;
flex-direction: row;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 8px 12px;
justify-content: center;
gap: 4px;
padding: var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
background: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
border: 1px dashed
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-sm);
user-select: none;
}
.placeholderIcon {
flex: none;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.placeholderTitle {
font-weight: 600;
font-size: var(--mantine-font-size-sm);
}
.placeholderSubtext {
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
text-align: center;
}
.transclusionBadge {
@@ -39,18 +50,15 @@
margin-right: -3rem;
width: calc(100% + 6rem);
padding: 0.5em 3rem;
border-radius: 8px;
border: 2px solid transparent;
border-radius: 4px;
border: 1px 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%)
);
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-7));
}
.transclusionControls {
@@ -96,32 +104,22 @@
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;
border-radius: 4px;
border: 1px 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%)
);
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-7));
}
.includeControls {
@@ -161,7 +159,7 @@
pointer-events: auto;
}
:global(.react-renderer.node-transclusionSource.ProseMirror-selectednode),
:global(.react-renderer.node-transclusion.ProseMirror-selectednode),
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode) {
outline: none;
}
@@ -185,14 +183,6 @@
.includeControls {
display: none !important;
}
.transclusionWrap,
.includeWrap {
border: none !important;
margin-left: 0 !important;
margin-right: 0 !important;
width: 100% !important;
padding: 0 !important;
}
}
.editingOriginalTag {
@@ -47,7 +47,6 @@ export default function VideoView(props: NodeViewProps) {
preload="metadata"
controls
src={getFileUrl(src)}
aria-label={placeholder?.name || t("Video")}
/>
)}
{!src && previewSrc && (
@@ -57,7 +56,6 @@ export default function VideoView(props: NodeViewProps) {
preload="metadata"
controls
src={previewSrc}
aria-label={placeholder?.name || t("Video")}
/>
<Loader size={20} pos="absolute" top={6} right={6} />
</Group>
@@ -73,7 +71,7 @@ export default function VideoView(props: NodeViewProps) {
</Group>
)}
{!src && !previewSrc && !placeholder && (
<video className={classes.video} controls aria-label={t("Video")} />
<video className={classes.video} controls />
)}
</div>
</NodeViewWrapper>
@@ -396,7 +396,7 @@ const GlobalDragHandle = Extension.create({
addOptions() {
return {
dragHandleWidth: 20,
scrollThreshold: 100,
scrollTreshold: 100,
excludedTags: [],
customNodes: [],
};
@@ -10,9 +10,7 @@ import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
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 getSuggestionItems from "@/features/editor/components/slash-menu/menu-items";
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
@@ -48,13 +46,12 @@ import {
Subpages,
Heading,
Highlight,
Indent,
UniqueID,
SharedStorage,
Columns,
Column,
Status,
TransclusionSource,
Transclusion,
TransclusionReference,
} from "@docmost/editor-ext";
import {
@@ -174,7 +171,7 @@ export const mainExtensions = [
SharedStorage,
Heading,
UniqueID.configure({
types: ["heading", "paragraph", "transclusionSource"],
types: ["heading", "paragraph", "transclusion"],
filterTransaction: (transaction) => !isChangeOrigin(transaction),
}),
Placeholder.configure({
@@ -204,7 +201,6 @@ export const mainExtensions = [
showOnlyWhenEditable: true,
}),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Indent,
TaskList,
TaskItem.configure({
nested: true,
@@ -224,7 +220,7 @@ export const mainExtensions = [
Typography,
TrailingNode,
GlobalDragHandle.configure({
customNodes: ["transclusionSource", "transclusionReference"],
customNodes: ["transclusion", "transclusionReference"],
}),
TextStyle,
Color,
@@ -315,8 +311,6 @@ export const mainExtensions = [
view: CodeBlockView,
//@ts-ignore
lowlight,
enableTabIndentation: true,
tabSize: 2,
HTMLAttributes: {
spellcheck: false,
},
@@ -363,7 +357,7 @@ export const mainExtensions = [
Status.configure({
view: StatusView,
}),
TransclusionSource.configure({
Transclusion.configure({
view: TransclusionView,
}),
TransclusionReference.configure({
@@ -411,10 +405,7 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
const TemplateSlashCommand = Command.configure({
suggestion: {
items: ({ query }: { query: string }) =>
getSuggestionItems({
query,
excludeItems: TEMPLATE_EXCLUDED_SLASH_ITEMS,
}),
getSuggestionItems({ query, excludeItems: TEMPLATE_EXCLUDED_SLASH_ITEMS }),
render: renderItems,
},
});
@@ -3,27 +3,20 @@ import React from "react";
import { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from "@/features/editor/page-editor";
import {
ActionIcon,
Container,
Divider,
Group,
Popover,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { PageVerificationBadge } from "@/ee/page-verification";
import { useTranslation } from "react-i18next";
import { IContributor } from "@/features/page/types/page.types.ts";
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import clsx from "clsx";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
@@ -59,11 +52,6 @@ export function FullEditor({
}: FullEditorProps) {
const [user] = useAtom(userAtom);
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false;
const userPageEditMode =
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const isEditMode = userPageEditMode === PageEditMode.Edit;
return (
<Container
@@ -71,7 +59,6 @@ export function FullEditor({
size={!fullPageWidth && 900}
className={classes.editor}
>
{editorToolbarEnabled && editable && isEditMode && <FixedToolbar />}
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
@@ -106,7 +93,6 @@ function PageByline({
readOnly,
}: PageBylineProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
const otherContributors = (contributors ?? []).filter(
(c) => c.id !== creator?.id,
@@ -116,8 +102,8 @@ function PageByline({
<Group
gap="sm"
mb="md"
className={clsx("print-hide", classes.byline)}
style={{ marginTop: "-0.5em" }}
className="print-hide"
style={{ marginTop: "-0.5em", paddingLeft: "3rem" }}
>
{creator && (
<Popover position="bottom-start" shadow="md" width={280} withArrow>
@@ -179,17 +165,6 @@ function PageByline({
</Popover.Dropdown>
</Popover>
)}
<Tooltip label={t("Details")} withArrow openDelay={250}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Details")}
onClick={() => toggleAside("details")}
>
<IconInfoCircle size={20} stroke={1.5} />
</ActionIcon>
</Tooltip>
<PageVerificationBadge readOnly={readOnly} />
</Group>
);
@@ -401,7 +401,7 @@ export default function PageEditor({
}, [yjsConnectionStatus, isSynced]);
return (
<TransclusionLookupProvider>
<TransclusionLookupProvider hostPageId={pageId}>
{showStatic ? (
<EditorProvider
editable={false}
@@ -15,20 +15,12 @@ interface PageEditorProps {
title: string;
content: any;
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({
title,
content,
pageId,
shareId,
}: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const isComponentMounted = useRef(false);
@@ -74,7 +66,7 @@ export default function ReadonlyPageEditor({
];
return (
<TransclusionLookupProvider shareId={shareId}>
<TransclusionLookupProvider hostPageId={pageId ?? "anonymous"}>
<div className="page-title">
<EditorProvider
editable={false}
@@ -9,15 +9,3 @@
}
}
.byline {
padding-left: 3rem;
@media (max-width: $mantine-breakpoint-sm) {
padding-left: 1rem;
}
@media print {
padding-left: 0;
}
}
@@ -1,14 +0,0 @@
.ProseMirror {
--indent-step: 2rem;
}
.ProseMirror [data-indent="1"] { padding-inline-start: calc(var(--indent-step) * 1); }
.ProseMirror [data-indent="2"] { padding-inline-start: calc(var(--indent-step) * 2); }
.ProseMirror [data-indent="3"] { padding-inline-start: calc(var(--indent-step) * 3); }
.ProseMirror [data-indent="4"] { padding-inline-start: calc(var(--indent-step) * 4); }
.ProseMirror [data-indent="5"] { padding-inline-start: calc(var(--indent-step) * 5); }
.ProseMirror [data-indent="6"] { padding-inline-start: calc(var(--indent-step) * 6); }
.ProseMirror [data-indent="7"] { padding-inline-start: calc(var(--indent-step) * 7); }
.ProseMirror [data-indent="8"] { padding-inline-start: calc(var(--indent-step) * 8); }
.ProseMirror [data-indent="9"] { padding-inline-start: calc(var(--indent-step) * 9); }
.ProseMirror [data-indent="10"] { padding-inline-start: calc(var(--indent-step) * 10); }
@@ -13,6 +13,5 @@
@import "./mention.css";
@import "./ordered-list.css";
@import "./highlight.css";
@import "./indent.css";
@import "./columns.css";
@import "./status.css";
@@ -53,17 +53,15 @@ export default function StarButton(props: StarButtonProps) {
}
};
const label = isFavorited
? t("Remove from favorites")
: t("Add to favorites");
return (
<Tooltip label={label} openDelay={250} withArrow>
<Tooltip
label={isFavorited ? t("Remove from favorites") : t("Add to favorites")}
openDelay={250}
withArrow
>
<ActionIcon
variant="subtle"
color={isFavorited ? "yellow" : "gray"}
aria-label={label}
aria-pressed={isFavorited}
onClick={handleToggle}
loading={isPending}
>
@@ -53,7 +53,7 @@ export default function GroupActionMenu() {
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="light" aria-label={t("Group menu")}>
<ActionIcon variant="light">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
@@ -54,7 +54,7 @@ export default function GroupMembersList() {
<Table.Tr>
<Table.Th>{t("User")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th aria-label={t("Action")} />
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
@@ -4,7 +4,7 @@ import {
UnstyledButton,
Badge,
Table,
ThemeIcon,
ActionIcon,
Button,
} from "@mantine/core";
import { Link } from "react-router-dom";
@@ -61,13 +61,13 @@ export default function CreatedByMe({ spaceId }: Props) {
>
<Group wrap="nowrap">
{page.icon || (
<ThemeIcon
<ActionIcon
variant="transparent"
color="gray"
size={18}
>
<IconFileDescription size={18} />
</ThemeIcon>
</ActionIcon>
)}
<Text fw={500} size="md" lineClamp={1}>
{page.title || t("Untitled")}
@@ -4,7 +4,7 @@ import {
UnstyledButton,
Badge,
Table,
ThemeIcon,
ActionIcon,
Button,
} from "@mantine/core";
import { Link } from "react-router-dom";
@@ -62,13 +62,13 @@ export default function FavoritesPages({ spaceId }: Props) {
>
<Group wrap="nowrap">
{fav.page.icon || (
<ThemeIcon
<ActionIcon
variant="transparent"
color="gray"
size={18}
>
<IconFileDescription size={18} />
</ThemeIcon>
</ActionIcon>
)}
<Text fw={500} size="md" lineClamp={1}>
{fav.page.title || t("Untitled")}
@@ -16,7 +16,7 @@
.subtitle {
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;
margin-top: 6px;
margin-bottom: var(--mantine-spacing-lg);
@@ -58,9 +58,6 @@ export function NotificationPopover() {
variant="subtle"
color="dark"
size="sm"
aria-label={t("Notifications")}
aria-haspopup="dialog"
aria-expanded={opened}
onClick={() => setOpened((o) => !o)}
>
<Indicator
@@ -1,113 +0,0 @@
import {
Button,
Center,
Group,
Loader,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useBacklinksQuery } from "@/features/page-details/queries/backlinks-query.ts";
import {
BacklinkDirection,
IBacklinkPageItem,
} from "@/features/page-details/types/backlink.types.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib";
interface BacklinksListProps {
pageId: string;
direction: BacklinkDirection;
enabled: boolean;
onItemClick: () => void;
}
export function BacklinksList({
pageId,
direction,
enabled,
onItemClick,
}: BacklinksListProps) {
const { t } = useTranslation();
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
useBacklinksQuery(pageId, direction, enabled);
if (!enabled) return null;
if (isLoading) {
return (
<Center py="sm">
<Loader size="sm" />
</Center>
);
}
const items: IBacklinkPageItem[] =
data?.pages.flatMap((page) => page.items) ?? [];
if (items.length === 0) {
return (
<Text c="dimmed" size="sm" py="md">
{direction === "incoming"
? t("No pages link here yet.")
: t("This page doesn't link to other pages yet.")}
</Text>
);
}
const handleClick = (e: React.MouseEvent) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
return;
}
onItemClick();
};
return (
<Stack gap={4}>
{items.map((item) => (
<UnstyledButton
key={item.id}
component={Link}
to={
item.space?.slug
? buildPageUrl(
item.space.slug,
item.slugId,
item.title ?? undefined,
)
: "#"
}
onClick={handleClick}
style={{ padding: "8px 4px", borderRadius: 4, userSelect: "none" }}
>
<Group gap="xs" wrap="nowrap">
{getPageIcon(item.icon ?? "")}
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} lineClamp={1}>
{item.title || t("Untitled")}
</Text>
{item.space?.name && (
<Text size="xs" c="dimmed" lineClamp={1}>
{item.space.name}
</Text>
)}
</Stack>
</Group>
</UnstyledButton>
))}
{hasNextPage && (
<Button
variant="subtle"
size="xs"
loading={isFetchingNextPage}
onClick={() => fetchNextPage()}
mt="xs"
>
{t("Load more")}
</Button>
)}
</Stack>
);
}
@@ -1,62 +0,0 @@
import { Modal, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
import { BacklinksList } from "./backlinks-list";
interface BacklinksModalProps {
pageId: string;
opened: boolean;
onClose: () => void;
}
export function BacklinksModal({
pageId,
opened,
onClose,
}: BacklinksModalProps) {
const { t } = useTranslation();
const { data: counts } = useBacklinksCountQuery(pageId);
return (
<Modal.Root opened={opened} onClose={onClose} size={640} yOffset="10vh">
<Modal.Overlay />
<Modal.Content>
<Modal.Header>
<Modal.Title fw={500}>{t("Backlinks")}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Stack gap="lg">
<Stack gap="xs">
<Text size="sm" fw={500} c="dimmed">
{t("Incoming links ({{count}})", {
count: counts?.incoming ?? 0,
})}
</Text>
<BacklinksList
pageId={pageId}
direction="incoming"
enabled={opened}
onItemClick={onClose}
/>
</Stack>
<Stack gap="xs">
<Text size="sm" fw={500} c="dimmed">
{t("Outgoing links ({{count}})", {
count: counts?.outgoing ?? 0,
})}
</Text>
<BacklinksList
pageId={pageId}
direction="outgoing"
enabled={opened}
onItemClick={onClose}
/>
</Stack>
</Stack>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
@@ -1,233 +0,0 @@
import {
Divider,
Group,
Skeleton,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import { IconChevronRight } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { useAtomValue } from "jotai";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { extractPageSlugId } from "@/lib";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
import { BacklinksModal } from "./backlinks-modal";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
export function PageDetailsAside() {
const { pageSlug } = useParams();
const { data: page } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
const pageEditor = useAtomValue(pageEditorAtom);
const { data: counts, isLoading: countsLoading } = useBacklinksCountQuery(page?.id);
const [modalOpened, { open: openModal, close: closeModal }] =
useDisclosure(false);
if (!page) return null;
const wordCount: number =
pageEditor?.storage?.characterCount?.words?.() ?? 0;
const characterCount: number =
pageEditor?.storage?.characterCount?.characters?.() ?? 0;
return (
<>
<Stack gap="md">
<PeopleSection
creator={page.creator}
lastUpdatedBy={page.lastUpdatedBy}
/>
<Divider />
<StatsSection
wordCount={wordCount}
characterCount={characterCount}
createdAt={page.createdAt}
updatedAt={page.updatedAt}
/>
<Divider />
<BacklinksSection
incomingCount={counts?.incoming ?? 0}
outgoingCount={counts?.outgoing ?? 0}
isLoading={countsLoading}
onClick={openModal}
/>
</Stack>
<BacklinksModal
pageId={page.id}
opened={modalOpened}
onClose={closeModal}
/>
</>
);
}
function PeopleSection({
creator,
lastUpdatedBy,
}: {
creator: { id: string; name: string; avatarUrl: string } | null;
lastUpdatedBy: { id: string; name: string; avatarUrl: string } | null;
}) {
const { t } = useTranslation();
return (
<Stack gap="xs">
<PersonRow label={t("Created by")} person={creator} />
<PersonRow label={t("Last updated by")} person={lastUpdatedBy} />
</Stack>
);
}
function PersonRow({
label,
person,
}: {
label: string;
person: { id: string; name: string; avatarUrl: string } | null;
}) {
return (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{label}
</Text>
{person ? (
<Group gap={6} wrap="nowrap">
<CustomAvatar
avatarUrl={person.avatarUrl}
name={person.name}
size={20}
radius="xl"
/>
<Text size="sm" lineClamp={1}>
{person.name}
</Text>
</Group>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Group>
);
}
function StatsSection({
wordCount,
characterCount,
createdAt,
updatedAt,
}: {
wordCount: number;
characterCount: number;
createdAt: Date | string;
updatedAt: Date | string;
}) {
const { t } = useTranslation();
return (
<Stack gap="xs">
<Text size="xs" fw={500} c="dimmed">
{t("Stats")}
</Text>
<StatRow label={t("Word count")} value={String(wordCount)} />
<StatRow label={t("Characters")} value={String(characterCount)} />
<StatRow
label={t("Created")}
value={formattedDate(new Date(createdAt))}
/>
<StatRow
label={t("Last updated")}
value={timeAgo(new Date(updatedAt))}
/>
</Stack>
);
}
function StatRow({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{label}
</Text>
<Text size="sm">{value}</Text>
</Group>
);
}
function BacklinksSection({
incomingCount,
outgoingCount,
isLoading,
onClick,
}: {
incomingCount: number;
outgoingCount: number;
isLoading: boolean;
onClick: () => void;
}) {
const { t } = useTranslation();
return (
<Stack gap="xs">
<Text size="xs" fw={500} c="dimmed">
{t("Backlinks")}
</Text>
<BacklinksRow
label={t("Incoming links")}
count={incomingCount}
isLoading={isLoading}
onClick={onClick}
/>
<BacklinksRow
label={t("Outgoing links")}
count={outgoingCount}
isLoading={isLoading}
onClick={onClick}
/>
</Stack>
);
}
function BacklinksRow({
label,
count,
isLoading,
onClick,
}: {
label: string;
count: number;
isLoading: boolean;
onClick: () => void;
}) {
return (
<UnstyledButton
onClick={onClick}
style={{
padding: "4px 4px",
borderRadius: 4,
}}
>
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{label}
</Text>
<Group gap={6} wrap="nowrap">
{isLoading ? (
<Skeleton height={18} width={20} />
) : (
<Text size="sm">{count}</Text>
)}
<IconChevronRight size={16} stroke={2} color="var(--mantine-color-dimmed)" />
</Group>
</Group>
</UnstyledButton>
);
}
@@ -1,45 +0,0 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import {
getBacklinks,
getBacklinksCount,
} from "@/features/page-details/services/backlinks-service.ts";
import {
BacklinkDirection,
IBacklinkCount,
} from "@/features/page-details/types/backlink.types.ts";
const BACKLINKS_STALE_TIME = 30 * 1000;
const BACKLINKS_PAGE_LIMIT = 100;
export function useBacklinksCountQuery(pageId: string | undefined) {
return useQuery<IBacklinkCount>({
queryKey: ["backlinks-count", pageId],
queryFn: () => getBacklinksCount(pageId as string),
enabled: !!pageId,
staleTime: BACKLINKS_STALE_TIME,
});
}
export function useBacklinksQuery(
pageId: string | undefined,
direction: BacklinkDirection,
enabled: boolean,
) {
return useInfiniteQuery({
queryKey: ["backlinks", pageId, direction],
queryFn: ({ pageParam }) =>
getBacklinks({
pageId: pageId as string,
direction,
cursor: pageParam,
limit: BACKLINKS_PAGE_LIMIT,
}),
enabled: enabled && !!pageId,
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage
? (lastPage.meta.nextCursor ?? undefined)
: undefined,
staleTime: BACKLINKS_STALE_TIME,
});
}
@@ -1,26 +0,0 @@
import api from "@/lib/api-client";
import { IPagination } from "@/lib/types.ts";
import {
IBacklinkCount,
IBacklinkPageItem,
IBacklinksListParams,
} from "@/features/page-details/types/backlink.types.ts";
export async function getBacklinksCount(
pageId: string,
): Promise<IBacklinkCount> {
const req = await api.post<IBacklinkCount>("/pages/backlinks-count", {
pageId,
});
return req.data;
}
export async function getBacklinks(
params: IBacklinksListParams,
): Promise<IPagination<IBacklinkPageItem>> {
const req = await api.post<IPagination<IBacklinkPageItem>>(
"/pages/backlinks",
params,
);
return req.data;
}
@@ -1,23 +0,0 @@
export type BacklinkDirection = "incoming" | "outgoing";
export interface IBacklinkCount {
incoming: number;
outgoing: number;
}
export interface IBacklinkPageItem {
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
space: { id: string; slug: string; name: string } | null;
updatedAt: string;
}
export interface IBacklinksListParams {
pageId: string;
direction: BacklinkDirection;
cursor?: string;
limit?: number;
}
@@ -22,7 +22,6 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
opened={isModalOpen}
onClose={() => setModalOpen(false)}
fullScreen
aria-label={t("Page history")}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
@@ -50,7 +49,6 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
size={1400}
opened={isModalOpen}
onClose={() => setModalOpen(false)}
aria-label={t("Page history")}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>

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