Compare commits

..

2 Commits

Author SHA1 Message Date
Philipinho d20c9e5256 feat(editor): indentation 2026-05-08 21:05:59 +01:00
Philipinho 56dc2e4eca switch to default codeblock tab handling 2026-05-08 17:32:50 +01:00
41 changed files with 316 additions and 1745 deletions
+1 -1
View File
@@ -25,7 +25,7 @@
"@tabler/icons-react": "^3.40.0", "@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "5.90.17", "@tanstack/react-query": "5.90.17",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "1.16.0", "axios": "1.15.0",
"blueimp-load-image": "^5.16.0", "blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
@@ -932,6 +932,7 @@
"Settings navigation": "Settings navigation", "Settings navigation": "Settings navigation",
"AI navigation": "AI navigation", "AI navigation": "AI navigation",
"Breadcrumb": "Breadcrumb", "Breadcrumb": "Breadcrumb",
"Skip to main content": "Skip to main content"
"Synced block": "Synced block", "Synced block": "Synced block",
"Create a block that stays in sync across pages.": "Create a block that stays in sync across pages.", "Create a block that stays in sync across pages.": "Create a block that stays in sync across pages.",
"Editing original": "Editing original", "Editing original": "Editing original",
@@ -945,30 +946,5 @@
"No pages": "No pages", "No pages": "No pages",
"The original synced block no longer exists": "The original synced block no longer exists", "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", "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", "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"
} }
@@ -27,3 +27,23 @@
background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5)) background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5))
} }
} }
.skipLink {
position: fixed;
left: 8px;
top: 8px;
padding: 8px 12px;
background: var(--mantine-color-blue-6);
color: #fff;
border-radius: 4px;
text-decoration: none;
z-index: 1000;
transform: translateY(-150%);
&:focus {
transform: translateY(0);
outline: 2px solid var(--mantine-color-blue-3);
}
}
@@ -81,7 +81,11 @@ export default function GlobalAppShell({
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute; const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
return ( return (
<AppShell <>
<a href="#main-content" className={classes.skipLink}>
{t("Skip to main content")}
</a>
<AppShell
header={{ height: 45 }} header={{ height: 45 }}
navbar={{ navbar={{
width: isSpaceRoute ? sidebarWidth : 300, width: isSpaceRoute ? sidebarWidth : 300,
@@ -154,5 +158,6 @@ export default function GlobalAppShell({
</AppShell.Aside> </AppShell.Aside>
)} )}
</AppShell> </AppShell>
</>
); );
} }
@@ -29,7 +29,7 @@ export default function AppVersion() {
> >
<Indicator <Indicator
label={t("New update")} label={t("New update")}
color="gray" color="dark"
inline inline
size={16} size={16}
position="middle-end" position="middle-end"
@@ -42,11 +42,6 @@ function pickInitialsColor(name: string) {
return SAFE_INITIALS_COLORS[hashName(name) % SAFE_INITIALS_COLORS.length]; 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< export const CustomAvatar = React.forwardRef<
HTMLInputElement, HTMLInputElement,
CustomAvatarProps CustomAvatarProps
@@ -54,13 +49,12 @@ export const CustomAvatar = React.forwardRef<
const avatarLink = getAvatarUrl(avatarUrl, type); const avatarLink = getAvatarUrl(avatarUrl, type);
const resolvedColor = const resolvedColor =
!color || color === "initials" ? pickInitialsColor(name ?? "") : color; !color || color === "initials" ? pickInitialsColor(name ?? "") : color;
const initialsSource = sanitizeInitialsSource(name ?? "");
return ( return (
<Avatar <Avatar
ref={ref} ref={ref}
src={avatarLink} src={avatarLink}
name={initialsSource} name={name}
alt={name} alt={name}
color={resolvedColor} color={resolvedColor}
{...props} {...props}
@@ -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 { .buttonRoot {
height: 34px; height: 34px;
padding-left: rem(8); 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 { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms"; 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 { export interface BubbleMenuItem {
name: string; name: string;
@@ -46,9 +46,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const workspace = useAtomValue(workspaceAtom); const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true; const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
const user = useAtomValue(userAtom);
const editorToolbarEnabled =
user?.settings?.preferences?.editorToolbar ?? false;
const [, setDraftCommentId] = useAtom(draftCommentIdAtom); const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup); const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu); const showAiMenuRef = useRef(showAiMenu);
@@ -152,7 +149,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
return isTextSelected(editor); return isTextSelected(editor);
}, },
options: { options: {
placement: editorToolbarEnabled ? "bottom" : "top", placement: "top",
offset: 8, offset: 8,
onHide: () => { onHide: () => {
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
@@ -191,60 +188,56 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
<div className={classes.divider} /> <div className={classes.divider} />
</> </>
)} )}
{!editorToolbarEnabled && ( <NodeSelector
<> editor={props.editor}
<NodeSelector isOpen={isNodeSelectorOpen}
editor={props.editor} setIsOpen={() => {
isOpen={isNodeSelectorOpen} setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsOpen={() => { setIsTextAlignmentOpen(false);
setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsColorSelectorOpen(false);
setIsTextAlignmentOpen(false); }}
setIsColorSelectorOpen(false); />
}}
/>
<TextAlignmentSelector <TextAlignmentSelector
editor={props.editor} editor={props.editor}
isOpen={isTextAlignmentSelectorOpen} isOpen={isTextAlignmentSelectorOpen}
setIsOpen={() => { setIsOpen={() => {
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen); setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
}} }}
/> />
<ActionIcon.Group> <ActionIcon.Group>
{items.map((item, index) => ( {items.map((item, index) => (
<Tooltip key={index} label={t(item.name)} withArrow> <Tooltip key={index} label={t(item.name)} withArrow>
<ActionIcon <ActionIcon
key={index} key={index}
variant="default" variant="default"
size="lg" size="lg"
radius="0" radius="0"
aria-label={t(item.name)} aria-label={t(item.name)}
className={clsx({ [classes.active]: item.isActive() })} className={clsx({ [classes.active]: item.isActive() })}
style={{ border: "none" }} style={{ border: "none" }}
onClick={item.command} onClick={item.command}
> >
<item.icon style={{ width: rem(16) }} stroke={2} /> <item.icon style={{ width: rem(16) }} stroke={2} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
))} ))}
</ActionIcon.Group> </ActionIcon.Group>
<LinkSelector /> <LinkSelector />
<ColorSelector <ColorSelector
editor={props.editor} editor={props.editor}
isOpen={isColorSelectorOpen} isOpen={isColorSelectorOpen}
setIsOpen={() => { setIsOpen={() => {
setIsColorSelectorOpen(!isColorSelectorOpen); setIsColorSelectorOpen(!isColorSelectorOpen);
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
}} }}
/> />
</>
)}
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}> <Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
<ActionIcon <ActionIcon
@@ -4,6 +4,7 @@ import {
Button, Button,
Popover, Popover,
rem, rem,
ScrollArea,
Text, Text,
Tooltip, Tooltip,
SimpleGrid, 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> = ({ export const ColorSelector: FC<ColorSelectorProps> = ({
editor, editor,
isOpen, isOpen,
@@ -213,20 +157,13 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
); );
return ( return (
<Popover <Popover width={220} opened={isOpen} withArrow>
width={220}
opened={isOpen}
onChange={setIsOpen}
trapFocus
withArrow
>
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text color")} withArrow> <Tooltip label={t("Text color")} withArrow>
<Button <Button
variant="default" variant="default"
radius="0" radius="0"
rightSection={<IconChevronDown size={16} />} rightSection={<IconChevronDown size={16} />}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
data-text-color={activeColorItem?.color || ""} data-text-color={activeColorItem?.color || ""}
data-highlight-color={activeHighlightItem?.color || ""} data-highlight-color={activeHighlightItem?.color || ""}
@@ -244,8 +181,9 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
</Tooltip> </Tooltip>
</Popover.Target> </Popover.Target>
<Popover.Dropdown onMouseDown={(e) => e.preventDefault()}> <Popover.Dropdown>
<Stack gap="md" p="2px"> <ScrollArea.Autosize type="scroll" mah="400">
<Stack gap="md">
<Box> <Box>
<Text size="sm" fw={600} mb="xs"> <Text size="sm" fw={600} mb="xs">
{t("Text color")} {t("Text color")}
@@ -269,10 +207,6 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<Box <Box
role="button" role="button"
tabIndex={0} tabIndex={0}
data-autofocus={index === 0 ? true : undefined}
data-color-grid="text"
data-color-index={index}
className={classes.colorSwatch}
aria-label={t(name)} aria-label={t(name)}
aria-pressed={!!editorState[`text_${color}`]} aria-pressed={!!editorState[`text_${color}`]}
onClick={applyTextColor} onClick={applyTextColor}
@@ -280,9 +214,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
applyTextColor(); applyTextColor();
return;
} }
handleColorKeyNav(e, index, "text");
}} }}
style={{ style={{
width: rem(28), width: rem(28),
@@ -335,9 +267,6 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<Box <Box
role="button" role="button"
tabIndex={0} tabIndex={0}
data-color-grid="highlight"
data-color-index={index}
className={classes.colorSwatch}
aria-label={t(name)} aria-label={t(name)}
aria-pressed={!!editorState[`highlight_${color}`]} aria-pressed={!!editorState[`highlight_${color}`]}
onClick={applyHighlight} onClick={applyHighlight}
@@ -345,9 +274,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
applyHighlight(); applyHighlight();
return;
} }
handleColorKeyNav(e, index, "highlight");
}} }}
style={{ style={{
width: rem(28), width: rem(28),
@@ -383,27 +310,16 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<Button <Button
variant="default" variant="default"
fullWidth fullWidth
data-color-grid="remove"
className={classes.removeColor}
onClick={() => { onClick={() => {
editor.commands.unsetColor(); editor.commands.unsetColor();
editor.commands.unsetHighlight(); editor.commands.unsetHighlight();
setIsOpen(false); 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")} {t("Remove color")}
</Button> </Button>
</Stack> </Stack>
</ScrollArea.Autosize>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
); );
@@ -155,7 +155,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
}; };
return ( return (
<Popover opened={isOpen} onChange={setIsOpen} withArrow> <Popover opened={isOpen} withArrow>
<Popover.Target> <Popover.Target>
<Tooltip <Tooltip
label={t("Turn into")} label={t("Turn into")}
@@ -7,7 +7,7 @@ import {
IconCheck, IconCheck,
IconChevronDown, IconChevronDown,
} from "@tabler/icons-react"; } 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 type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react"; import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -82,22 +82,15 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0]; const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0];
return ( return (
<Menu <Popover opened={isOpen} withArrow>
shadow="md" <Popover.Target>
position="bottom-start" <Tooltip label={t("Text align")} withArrow withinPortal={false} disabled={isOpen}>
withArrow={false}
opened={isOpen}
onChange={setIsOpen}
>
<Menu.Target>
<Tooltip label={t("Text align")} withArrow disabled={isOpen}>
<Button <Button
variant="default" variant="default"
style={{ border: "none", height: "34px" }} style={{ border: "none", height: "34px" }}
px="5" px="5"
radius="0" radius="0"
rightSection={<IconChevronDown size={16} />} rightSection={<IconChevronDown size={16} />}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
aria-label={t("Text align")} aria-label={t("Text align")}
aria-haspopup="menu" aria-haspopup="menu"
@@ -106,25 +99,33 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
<activeItem.icon style={{ width: rem(16) }} stroke={2} /> <activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button> </Button>
</Tooltip> </Tooltip>
</Menu.Target> </Popover.Target>
<Menu.Dropdown> <Popover.Dropdown>
{items.map((item, index) => ( <ScrollArea.Autosize type="scroll" mah={400}>
<Menu.Item <Button.Group orientation="vertical">
key={index} {items.map((item, index) => (
leftSection={<item.icon size={16} />} <Button
rightSection={ key={index}
activeItem.name === item.name ? <IconCheck size={16} /> : null variant="default"
} leftSection={<item.icon size={16} />}
onClick={() => { rightSection={
item.command(); activeItem.name === item.name && <IconCheck size={16} />
setIsOpen(false); }
}} justify="left"
> fullWidth
{t(item.name)} onClick={() => {
</Menu.Item> item.command();
))} setIsOpen(false);
</Menu.Dropdown> }}
</Menu> style={{ border: "none" }}
>
{t(item.name)}
</Button>
))}
</Button.Group>
</ScrollArea.Autosize>
</Popover.Dropdown>
</Popover>
); );
}; };
@@ -1,26 +1,30 @@
import { CommandProps, EmojiMenuItemType } from "./types"; 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 (value: string): Promise<EmojiMenuItemType[]> => {
if (value === "") {
const searchEmoji = async (query: string): Promise<EmojiMenuItemType[]> => { const frequentlyUsedEmoji = getFrequentlyUsedEmoji();
if (query === "") { return sortFrequentlyUsedEmoji(frequentlyUsedEmoji);
return sortFrequentlyUsedEmoji(getFrequentlyUsedEmoji());
} }
const q = query.toLowerCase(); const emojis = await SearchIndex.search(value);
const index = await buildEmojiIndex(); const results = emojis.map((emoji: any) => {
return {
return index id: emoji.id,
.filter((e) => e.name.includes(q) || e.id.includes(q)) emoji: emoji.skins[0].native,
.slice(0, MAX_RESULTS)
.map((entry) => ({
id: entry.id,
emoji: entry.native,
command: ({ editor, range }: CommandProps) => { 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 ({ export const getEmojiItems = async ({
@@ -1,208 +1,154 @@
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 { import {
EmojiCategory, ActionIcon,
EmojiIndexEntry, Loader,
getEmojiCategories, Paper,
incrementEmojiUsage, ScrollArea,
} from "./utils"; SimpleGrid,
Text,
} from "@mantine/core";
import { EmojiMenuItemType } from "./types";
import clsx from "clsx";
import classes from "./emoji-menu.module.css"; import classes from "./emoji-menu.module.css";
import { useCallback, useEffect, useRef, useState } from "react";
import { GRID_COLUMNS, incrementEmojiUsage } from "./utils";
const COLS = 8; const EmojiList = ({
const CAT_ICONS: Record<string, string> = {
people: "😀",
nature: "🌿",
foods: "🍕",
activity: "🎮",
places: "🗺️",
objects: "🔧",
symbols: "💯",
flags: "🚩",
};
function EmojiList({
items, items,
isLoading, isLoading,
command, command,
editor, editor,
range, range,
query = "",
}: { }: {
items: EmojiMenuItemType[]; items: EmojiMenuItemType[];
isLoading: boolean; isLoading: boolean;
command: (item: EmojiMenuItemType) => void; command: any;
editor: any; editor: any;
range: any; range: any;
query?: string; }) => {
}) { const [selectedIndex, setSelectedIndex] = useState(0);
const { t } = useTranslation(); const viewportRef = useRef<HTMLDivElement>(null);
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 searching = query.length > 0; const selectItem = useCallback(
const browseLoading = !searching && cats.length === 0; (index: number) => {
const gridItems = cats.find((c) => c.id === activeCat)?.emojis ?? []; const item = items[index];
if (item) {
useEffect(() => { command(item);
getEmojiCategories().then((data) => { incrementEmojiUsage(item.id);
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]); }
} }
} },
document.addEventListener("keydown", onKey); [command, items]
return () => document.removeEventListener("keydown", onKey); );
}, [searching, items, idx, gridItems, pickSearchItem, pickGridItem, focusZone, cats, activeCat]);
return ( 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 <Paper
id="emoji-command" id="emoji-command"
p={0} p="0"
shadow="md" shadow="md"
withBorder withBorder
style={{ width: 280 }}
role="listbox" role="listbox"
aria-label={t("Emoji picker")} aria-label="Emoji results"
aria-activedescendant={
items.length > 0 ? `emoji-command-option-${selectedIndex}` : undefined
}
> >
{searching ? ( {isLoading && <Loader m="xs" color="blue" type="dots" />}
<> {items.length > 0 && (
{isLoading && <Loader m="xs" size="xs" color="blue" type="dots" />} <ScrollArea.Autosize
<ScrollArea.Autosize mah={260} scrollbarSize={6} viewportRef={listViewport}> viewportRef={viewportRef}
<div style={{ padding: 4 }}> mah={250}
{items.length === 0 && !isLoading ? ( scrollbarSize={8}
<Text size="sm" c="dimmed" p="xs">{t("No results")}</Text> pr="5"
) : items.map((item, i) => ( >
<UnstyledButton <SimpleGrid cols={GRID_COLUMNS} p="xs" spacing="xs">
key={item.id} {items.map((item, index: number) => (
data-i={i} <ActionIcon
w="100%" data-item-index={index}
className={clsx(classes.row, { [classes.active]: i === idx })} id={`emoji-command-option-${index}`}
onClick={() => pickSearchItem(i)} role="option"
onMouseEnter={() => setIdx(i)} aria-selected={index === selectedIndex}
role="option" aria-label={item.id}
aria-selected={i === idx} variant="transparent"
> key={item.id}
<span style={{ fontSize: 20, lineHeight: 1, minWidth: 26 }}>{item.emoji}</span> className={clsx(classes.menuBtn, {
<Text size="sm" c="dimmed" ff="monospace" span>:{item.id}:</Text> [classes.selectedItem]: index === selectedIndex,
</UnstyledButton> })}
))} onClick={() => selectItem(index)}
</div> >
</ScrollArea.Autosize> <Text size="xl">{item.emoji}</Text>
</> </ActionIcon>
) : browseLoading ? ( ))}
<Loader m="xs" size="xs" color="blue" type="dots" /> </SimpleGrid>
) : ( </ScrollArea.Autosize>
<>
<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>
</>
)} )}
</Paper> </Paper>
); ) : null;
} };
export default EmojiList; export default EmojiList;
@@ -1,13 +1,9 @@
.row { .menuBtn {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--mantine-radius-sm); border-radius: var(--mantine-radius-sm);
&:hover { &:hover {
@mixin light { @mixin light {
background: var(--mantine-color-gray-1); background: var(--mantine-color-gray-2);
} }
@mixin dark { @mixin dark {
@@ -16,7 +12,7 @@
} }
} }
.active { .selectedItem {
@mixin light { @mixin light {
background: var(--mantine-color-gray-2); background: var(--mantine-color-gray-2);
} }
@@ -25,83 +21,3 @@
background: var(--mantine-color-gray-light); 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 { ReactRenderer, useEditor } from "@tiptap/react";
import EmojiList from "./emoji-list"; import EmojiList from "./emoji-list";
import { init } from "emoji-mart";
import { import {
autoUpdate, autoUpdate,
computePosition, computePosition,
@@ -36,6 +37,10 @@ const renderEmojiItems = () => {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect; clientRect: () => DOMRect;
}) => { }) => {
init({
data: async () => (await import("@emoji-mart/data")).default,
});
component = new ReactRenderer(EmojiList, { component = new ReactRenderer(EmojiList, {
props: { isLoading: true, items: [] }, props: { isLoading: true, items: [] },
editor: props.editor, 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"; export const LOCAL_STORAGE_FREQUENT_KEY = "emoji-mart.frequently";
@@ -15,76 +19,41 @@ export const DEFAULT_FREQUENTLY_USED_EMOJI_MART = `{
"rocket": 1 "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) => { export const incrementEmojiUsage = (emojiId: string) => {
const stored = JSON.parse( const frequentlyUsedEmoji =
localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART, 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 ( export const sortFrequentlyUsedEmoji = async (
frequentlyUsedEmoji: EmojiMartFrequentlyType, frequentlyUsedEmoji: EmojiMartFrequentlyType
): Promise<EmojiMenuItemType[]> => { ): Promise<EmojiMenuItemType[]> => {
const index = await buildEmojiIndex(); const data = await Promise.all(
const results: EmojiMenuItemType[] = Object.entries(frequentlyUsedEmoji) Object.entries(frequentlyUsedEmoji).map(
.map(([id, count]): EmojiMenuItemType | null => { async ([id, count]): Promise<EmojiMenuItemType> => ({
const entry = index.find((e) => e.id === id);
if (!entry) return null;
return {
id, id,
count, count,
emoji: entry.native, emoji: (await getEmojiDataFromNative(id))?.native,
command: ({ editor, range }: CommandProps) => { command: async ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertContent(entry.native + " ").run(); 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[] }; export const getFrequentlyUsedEmoji = () => {
return JSON.parse(localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART);
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;
};
@@ -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"),
};
},
});
}
@@ -25,7 +25,6 @@ import {
IconColumns3, IconColumns3,
IconColumns2, IconColumns2,
IconTag, IconTag,
IconMoodSmile,
IconRotate2, IconRotate2,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
@@ -478,15 +477,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run(); .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)", title: "Subpages (Child pages)",
description: "List all subpages of the current page", description: "List all subpages of the current page",
@@ -25,7 +25,7 @@ const recalculateLinks = (nodePos: NodePos[]) => {
(acc, item) => { (acc, item) => {
const label = item.node.textContent; const label = item.node.textContent;
const level = Number(item.node.attrs.level); const level = Number(item.node.attrs.level);
if (label.length && level <= 4) { if (label.length && level <= 3) {
acc.push({ acc.push({
label, label,
level, level,
@@ -17,8 +17,6 @@ import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { PageVerificationBadge } from "@/ee/page-verification"; import { PageVerificationBadge } from "@/ee/page-verification";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IContributor } from "@/features/page/types/page.types.ts"; 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";
const MemoizedTitleEditor = React.memo(TitleEditor); const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor); const MemoizedPageEditor = React.memo(PageEditor);
@@ -54,11 +52,6 @@ export function FullEditor({
}: FullEditorProps) { }: FullEditorProps) {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const fullPageWidth = user.settings?.preferences?.fullPageWidth; 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 ( return (
<Container <Container
@@ -66,7 +59,6 @@ export function FullEditor({
size={!fullPageWidth && 900} size={!fullPageWidth && 900}
className={classes.editor} className={classes.editor}
> >
{editorToolbarEnabled && editable && isEditMode && <FixedToolbar />}
<MemoizedTitleEditor <MemoizedTitleEditor
pageId={pageId} pageId={pageId}
slugId={slugId} slugId={slugId}
@@ -1,57 +0,0 @@
import { userAtom } from "@/features/user/atoms/current-user-atom";
import { updateUser } from "@/features/user/services/user-service";
import { Badge, Group, Switch, Text } from "@mantine/core";
import { useAtom } from "jotai";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ResponsiveSettingsRow,
ResponsiveSettingsContent,
ResponsiveSettingsControl,
} from "@/components/ui/responsive-settings-row";
export default function FixedToolbarPref() {
const { t } = useTranslation();
const [user, setUser] = useAtom(userAtom);
const [checked, setChecked] = useState(
user.settings?.preferences?.editorToolbar ?? false,
);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
setChecked(value);
try {
const updatedUser = await updateUser({ editorToolbar: value });
setUser(updatedUser);
} catch {
setChecked(!value);
}
};
return (
<ResponsiveSettingsRow>
<ResponsiveSettingsContent>
<Group gap="xs">
<Text size="md">{t("Fixed editor toolbar")}</Text>
<Badge size="xs" color="gray" variant="light">
{t("Experimental")}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{t(
"Show a formatting toolbar above the editor with quick access to common actions.",
)}
</Text>
</ResponsiveSettingsContent>
<ResponsiveSettingsControl>
<Switch
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
aria-label={t("Toggle fixed editor toolbar")}
/>
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
);
}
@@ -20,7 +20,6 @@ export interface IUser {
deletedAt: Date; deletedAt: Date;
fullPageWidth: boolean; // used for update fullPageWidth: boolean; // used for update
pageEditMode: string; // used for update pageEditMode: string; // used for update
editorToolbar: boolean; // used for update
notificationPageUpdates: boolean; // used for update notificationPageUpdates: boolean; // used for update
notificationPageUserMention: boolean; // used for update notificationPageUserMention: boolean; // used for update
notificationCommentUserMention: boolean; // used for update notificationCommentUserMention: boolean; // used for update
@@ -38,7 +37,6 @@ export interface IUserSettings {
preferences: { preferences: {
fullPageWidth: boolean; fullPageWidth: boolean;
pageEditMode: string; pageEditMode: string;
editorToolbar: boolean;
}; };
notifications?: { notifications?: {
"page.updated"?: boolean; "page.updated"?: boolean;
@@ -60,10 +60,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
} }
}, [data, isLoading]); }, [data, isLoading]);
useEffect(() => {
document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en-US";
}, [i18n.language, i18n.resolvedLanguage]);
useEffect(() => { useEffect(() => {
if (entitlements) { if (entitlements) {
setEntitlements(entitlements); setEntitlements(entitlements);
@@ -3,7 +3,6 @@ import AccountLanguage from "@/features/user/components/account-language.tsx";
import AccountTheme from "@/features/user/components/account-theme.tsx"; import AccountTheme from "@/features/user/components/account-theme.tsx";
import PageWidthPref from "@/features/user/components/page-width-pref.tsx"; import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import PageEditPref from "@/features/user/components/page-state-pref"; import PageEditPref from "@/features/user/components/page-state-pref";
import FixedToolbarPref from "@/features/user/components/fixed-toolbar-pref";
import NotificationPref from "@/features/user/components/notification-pref"; import NotificationPref from "@/features/user/components/notification-pref";
import { getAppName } from "@/lib/config.ts"; import { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core"; import { Divider } from "@mantine/core";
@@ -38,10 +37,6 @@ export default function AccountPreferences() {
<Divider my={"md"} /> <Divider my={"md"} />
<FixedToolbarPref />
<Divider my={"md"} />
<NotificationPref /> <NotificationPref />
</> </>
); );
@@ -22,10 +22,6 @@ export class UpdateUserDto extends PartialType(
@IsIn(['read', 'edit']) @IsIn(['read', 'edit'])
pageEditMode: string; pageEditMode: string;
@IsOptional()
@IsBoolean()
editorToolbar: boolean;
@IsOptional() @IsOptional()
@IsString() @IsString()
locale: string; locale: string;
@@ -61,14 +61,6 @@ export class UserService {
); );
} }
if (typeof updateUserDto.editorToolbar !== 'undefined') {
return this.userRepo.updatePreference(
userId,
'editorToolbar',
updateUserDto.editorToolbar,
);
}
const notificationSettings: Record<string, NotificationSettingKey> = { const notificationSettings: Record<string, NotificationSettingKey> = {
notificationPageUpdates: 'page.updated', notificationPageUpdates: 'page.updated',
notificationPageUserMention: 'page.userMention', notificationPageUserMention: 'page.userMention',
+1 -1
View File
@@ -131,7 +131,7 @@
"brace-expansion@^5": "5.0.5", "brace-expansion@^5": "5.0.5",
"@xmldom/xmldom": "0.8.13", "@xmldom/xmldom": "0.8.13",
"handlebars": "4.7.9", "handlebars": "4.7.9",
"axios": "1.16.0", "axios": "1.15.0",
"langsmith": "0.5.19", "langsmith": "0.5.19",
"follow-redirects": "1.16.0", "follow-redirects": "1.16.0",
"protobufjs": "7.5.5" "protobufjs": "7.5.5"
+9 -9
View File
@@ -37,7 +37,7 @@ overrides:
brace-expansion@^5: 5.0.5 brace-expansion@^5: 5.0.5
'@xmldom/xmldom': 0.8.13 '@xmldom/xmldom': 0.8.13
handlebars: 4.7.9 handlebars: 4.7.9
axios: 1.16.0 axios: 1.15.0
langsmith: 0.5.19 langsmith: 0.5.19
follow-redirects: 1.16.0 follow-redirects: 1.16.0
protobufjs: 7.5.5 protobufjs: 7.5.5
@@ -296,8 +296,8 @@ importers:
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.0 version: 1.1.0
axios: axios:
specifier: 1.16.0 specifier: 1.15.0
version: 1.16.0 version: 1.15.0
blueimp-load-image: blueimp-load-image:
specifier: ^5.16.0 specifier: ^5.16.0
version: 5.16.0 version: 5.16.0
@@ -5449,8 +5449,8 @@ packages:
avvio@9.1.0: avvio@9.1.0:
resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==}
axios@1.16.0: axios@1.15.0:
resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==}
babel-jest@30.3.0: babel-jest@30.3.0:
resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==} resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==}
@@ -15853,7 +15853,7 @@ snapshots:
'@fastify/error': 4.0.0 '@fastify/error': 4.0.0
fastq: 1.17.1 fastq: 1.17.1
axios@1.16.0: axios@1.15.0:
dependencies: dependencies:
follow-redirects: 1.16.0 follow-redirects: 1.16.0
form-data: 4.0.5 form-data: 4.0.5
@@ -19187,7 +19187,7 @@ snapshots:
'@yarnpkg/lockfile': 1.1.0 '@yarnpkg/lockfile': 1.1.0
'@yarnpkg/parsers': 3.0.2 '@yarnpkg/parsers': 3.0.2
'@zkochan/js-yaml': 0.0.7 '@zkochan/js-yaml': 0.0.7
axios: 1.16.0 axios: 1.15.0
cli-cursor: 3.1.0 cli-cursor: 3.1.0
cli-spinners: 2.6.1 cli-spinners: 2.6.1
cliui: 8.0.1 cliui: 8.0.1
@@ -19728,7 +19728,7 @@ snapshots:
postmark@4.0.7: postmark@4.0.7:
dependencies: dependencies:
axios: 1.16.0 axios: 1.15.0
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
@@ -21105,7 +21105,7 @@ snapshots:
typesense@3.0.5(@babel/runtime@7.29.2): typesense@3.0.5(@babel/runtime@7.29.2):
dependencies: dependencies:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
axios: 1.16.0 axios: 1.15.0
loglevel: 1.9.2 loglevel: 1.9.2
tslib: 2.8.1 tslib: 2.8.1
transitivePeerDependencies: transitivePeerDependencies: