Compare commits

..

10 Commits

Author SHA1 Message Date
Philipinho a3bd65ee3d update axios 2026-05-09 13:20:14 +01:00
Philipinho b92829c3a6 cleanup translation strings 2026-05-09 13:05:01 +01:00
Philipinho 468c96bb4f remove key 2026-05-09 12:55:21 +01:00
Philipinho a92fa2334b feat(editor): fixed toolbar preference 2026-05-09 12:53:20 +01:00
Philip Okugbe 2d8b470495 feat(editor): indentation (#2174)
* switch to default codeblock tab handling

* feat(editor): indentation
2026-05-08 21:40:37 +01:00
David Gallardo c66c08fa78 fix: ignore emoji when deriving avatar initials (#2167) 2026-05-08 21:36:10 +01:00
David Gallardo 6046d04375 feat(editor): replace emoji picker with browse + search (#2171)
* feat(editor): show emoji name in suggestion list

Replace the fixed-column emoji grid with a vertical list that displays
each emoji alongside its :shortcode: name. This makes the picker more
discoverable—users can see and learn shortcodes without prior knowledge.

Changes:
- EmojiList: switch from SimpleGrid/ActionIcon to UnstyledButton list
  rows showing emoji glyph + monospace 🆔 label
- Navigation simplified to ArrowUp/ArrowDown (list has no columns)
- Results capped at 8 items for a focused, scannable dropdown
- CSS module: rename menuBtn -> menuItem, tighten padding

* feat(editor): replace SearchIndex with name/id includes search

Port the exact search algorithm from the original extension:
- Build a flat index from @emoji-mart/data: { id, name (lowercase), native }
- Filter with name.includes(q) || id.includes(q) — predictable, no
  keyword indirection
- Results capped at 5 (same as extension)
- Frequently-used emojis (sorted by usage) shown when query is empty
- Remove emoji-mart init() / SearchIndex / getEmojiDataFromNative
  dependencies; index is built lazily and cached in memory
- Remove unused GRID_COLUMNS constant

* feat(editor): emoji picker with browse and search modes

When the query is empty the picker shows a category bar with 8 tabs
(people, nature, food…) and a scrollable emoji grid. Typing after ':'
switches to a compact list that shows the glyph and :shortcode: side by
side, making it easy to discover emoji names while you type.

- Category data is loaded lazily from @emoji-mart/data and cached, so
  opening the picker more than once has no overhead
- Grid keyboard nav: arrow keys move by cell/row, Enter picks
- List keyboard nav: up/down through results, Enter picks
- Mouse hover syncs the keyboard selection index in both modes
- incrementEmojiUsage tracks picks so frequently used ones bubble up
  in future sessions

* fix(editor): polish emoji picker copy and loading

* feat: add emoji to slash command

* Add keyboard support to emoji group navigation

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-05-08 21:33:43 +01:00
David Gallardo 5d8c11e741 fix: sync html lang with current user locale (#2165) 2026-05-08 21:15:04 +01:00
Philip Okugbe de60aa7e61 feat: synced blocks (transclusion) (#2163)
* feat: synced blocks (transclusion)

* fix:remove name

* make placeholders smaller

* feat: enforce strict transclusion schema

* fix: scope synced blocks to workspace, gate unsync on edit permission

* fix collab module error
2026-05-08 13:23:16 +01:00
Peter Tripp c9fa6e20b3 Add alias: /toc and /ol (#2161) 2026-05-08 01:20:27 +01:00
51 changed files with 2079 additions and 341 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.15.0",
"axios": "1.16.0",
"blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
@@ -932,7 +932,6 @@
"Settings navigation": "Settings navigation",
"AI navigation": "AI navigation",
"Breadcrumb": "Breadcrumb",
"Skip to main content": "Skip to main content"
"Synced block": "Synced block",
"Create a block that stays in sync across pages.": "Create a block that stays in sync across pages.",
"Editing original": "Editing original",
@@ -946,5 +945,30 @@
"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"
"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,23 +27,3 @@
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,11 +81,7 @@ export default function GlobalAppShell({
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
return (
<>
<a href="#main-content" className={classes.skipLink}>
{t("Skip to main content")}
</a>
<AppShell
<AppShell
header={{ height: 45 }}
navbar={{
width: isSpaceRoute ? sidebarWidth : 300,
@@ -158,6 +154,5 @@ export default function GlobalAppShell({
</AppShell.Aside>
)}
</AppShell>
</>
);
}
@@ -29,7 +29,7 @@ export default function AppVersion() {
>
<Indicator
label={t("New update")}
color="dark"
color="gray"
inline
size={16}
position="middle-end"
@@ -42,6 +42,11 @@ 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
@@ -49,12 +54,13 @@ export const CustomAvatar = React.forwardRef<
const avatarLink = getAvatarUrl(avatarUrl, type);
const resolvedColor =
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
const initialsSource = sanitizeInitialsSource(name ?? "");
return (
<Avatar
ref={ref}
src={avatarLink}
name={name}
name={initialsSource}
alt={name}
color={resolvedColor}
{...props}
@@ -28,6 +28,18 @@
}
}
.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 { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem {
name: string;
@@ -46,6 +46,9 @@ 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);
@@ -149,7 +152,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
return isTextSelected(editor);
},
options: {
placement: "top",
placement: editorToolbarEnabled ? "bottom" : "top",
offset: 8,
onHide: () => {
setIsNodeSelectorOpen(false);
@@ -188,56 +191,60 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
<div className={classes.divider} />
</>
)}
<NodeSelector
editor={props.editor}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsTextAlignmentOpen(false);
setIsColorSelectorOpen(false);
}}
/>
{!editorToolbarEnabled && (
<>
<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,7 +4,6 @@ import {
Button,
Popover,
rem,
ScrollArea,
Text,
Tooltip,
SimpleGrid,
@@ -114,6 +113,63 @@ 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,
@@ -157,13 +213,20 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
);
return (
<Popover width={220} opened={isOpen} withArrow>
<Popover
width={220}
opened={isOpen}
onChange={setIsOpen}
trapFocus
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 || ""}
@@ -181,9 +244,8 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<ScrollArea.Autosize type="scroll" mah="400">
<Stack gap="md">
<Popover.Dropdown onMouseDown={(e) => e.preventDefault()}>
<Stack gap="md" p="2px">
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Text color")}
@@ -207,6 +269,10 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<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}
@@ -214,7 +280,9 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
applyTextColor();
return;
}
handleColorKeyNav(e, index, "text");
}}
style={{
width: rem(28),
@@ -267,6 +335,9 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<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}
@@ -274,7 +345,9 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
applyHighlight();
return;
}
handleColorKeyNav(e, index, "highlight");
}}
style={{
width: rem(28),
@@ -310,16 +383,27 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<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>
);
@@ -155,7 +155,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
};
return (
<Popover opened={isOpen} withArrow>
<Popover opened={isOpen} onChange={setIsOpen} withArrow>
<Popover.Target>
<Tooltip
label={t("Turn into")}
@@ -7,7 +7,7 @@ import {
IconCheck,
IconChevronDown,
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea, Tooltip, rem } from "@mantine/core";
import { Menu, Button, Tooltip, rem } from "@mantine/core";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
@@ -82,15 +82,22 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0];
return (
<Popover opened={isOpen} withArrow>
<Popover.Target>
<Tooltip label={t("Text align")} withArrow withinPortal={false} disabled={isOpen}>
<Menu
shadow="md"
position="bottom-start"
withArrow={false}
opened={isOpen}
onChange={setIsOpen}
>
<Menu.Target>
<Tooltip label={t("Text align")} withArrow 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"
@@ -99,33 +106,25 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button>
</Tooltip>
</Popover.Target>
</Menu.Target>
<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>
<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>
);
};
@@ -1,30 +1,26 @@
import { CommandProps, EmojiMenuItemType } from "./types";
import { SearchIndex } from "emoji-mart";
import { getFrequentlyUsedEmoji, sortFrequentlyUsedEmoji } from "./utils";
import { buildEmojiIndex, getFrequentlyUsedEmoji, sortFrequentlyUsedEmoji } from "./utils";
const searchEmoji = async (value: string): Promise<EmojiMenuItemType[]> => {
if (value === "") {
const frequentlyUsedEmoji = getFrequentlyUsedEmoji();
return sortFrequentlyUsedEmoji(frequentlyUsedEmoji);
const MAX_RESULTS = 5;
const searchEmoji = async (query: string): Promise<EmojiMenuItemType[]> => {
if (query === "") {
return sortFrequentlyUsedEmoji(getFrequentlyUsedEmoji());
}
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(emoji.skins[0].native + " ")
.run();
},
};
});
const q = query.toLowerCase();
const index = await buildEmojiIndex();
return results;
return index
.filter((e) => e.name.includes(q) || e.id.includes(q))
.slice(0, MAX_RESULTS)
.map((entry) => ({
id: entry.id,
emoji: entry.native,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertContent(entry.native + " ").run();
},
}));
};
export const getEmojiItems = async ({
@@ -1,154 +1,208 @@
import {
ActionIcon,
Loader,
Paper,
ScrollArea,
SimpleGrid,
Text,
} from "@mantine/core";
import { EmojiMenuItemType } from "./types";
import { Loader, Paper, ScrollArea, Text, UnstyledButton } from "@mantine/core";
import clsx from "clsx";
import classes from "./emoji-menu.module.css";
import { useCallback, useEffect, useRef, useState } from "react";
import { GRID_COLUMNS, incrementEmojiUsage } from "./utils";
import { useTranslation } from "react-i18next";
import { EmojiMenuItemType } from "./types";
import {
EmojiCategory,
EmojiIndexEntry,
getEmojiCategories,
incrementEmojiUsage,
} from "./utils";
import classes from "./emoji-menu.module.css";
const EmojiList = ({
const COLS = 8;
const CAT_ICONS: Record<string, string> = {
people: "😀",
nature: "🌿",
foods: "🍕",
activity: "🎮",
places: "🗺️",
objects: "🔧",
symbols: "💯",
flags: "🚩",
};
function EmojiList({
items,
isLoading,
command,
editor,
range,
query = "",
}: {
items: EmojiMenuItemType[];
isLoading: boolean;
command: any;
command: (item: EmojiMenuItemType) => void;
editor: any;
range: any;
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const viewportRef = useRef<HTMLDivElement>(null);
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 selectItem = useCallback(
(index: number) => {
const item = items[index];
if (item) {
command(item);
incrementEmojiUsage(item.id);
}
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]
[command, items],
);
const pickGridItem = useCallback(
(entry: EmojiIndexEntry) => {
editor.chain().focus().deleteRange(range).insertContent(entry.native + " ").run();
incrementEmojiUsage(entry.id);
},
[editor, range],
);
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;
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));
}
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;
else if (e.key === "Enter") { e.preventDefault(); if (gridItems[idx]) pickGridItem(gridItems[idx]); }
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [items, selectedIndex, setSelectedIndex]);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [searching, items, idx, gridItems, pickSearchItem, pickGridItem, focusZone, cats, activeCat]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
useEffect(() => {
viewportRef.current
?.querySelector(`[data-item-index="${selectedIndex}"]`)
?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
return items.length > 0 || isLoading ? (
return (
<Paper
id="emoji-command"
p="0"
p={0}
shadow="md"
withBorder
style={{ width: 280 }}
role="listbox"
aria-label="Emoji results"
aria-activedescendant={
items.length > 0 ? `emoji-command-option-${selectedIndex}` : undefined
}
aria-label={t("Emoji picker")}
>
{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}
id={`emoji-command-option-${index}`}
role="option"
aria-selected={index === selectedIndex}
aria-label={item.id}
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>
{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>
</>
)}
</Paper>
) : null;
};
);
}
export default EmojiList;
@@ -1,9 +1,13 @@
.menuBtn {
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--mantine-radius-sm);
&:hover {
@mixin light {
background: var(--mantine-color-gray-2);
background: var(--mantine-color-gray-1);
}
@mixin dark {
@@ -12,7 +16,7 @@
}
}
.selectedItem {
.active {
@mixin light {
background: var(--mantine-color-gray-2);
}
@@ -21,3 +25,83 @@
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,6 +1,5 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import EmojiList from "./emoji-list";
import { init } from "emoji-mart";
import {
autoUpdate,
computePosition,
@@ -37,10 +36,6 @@ 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,8 +1,4 @@
import { CommandProps } from "./types";
import { getEmojiDataFromNative } from "emoji-mart";
import { EmojiMartFrequentlyType, EmojiMenuItemType } from "./types";
export const GRID_COLUMNS = 10;
import { CommandProps, EmojiMartFrequentlyType, EmojiMenuItemType } from "./types";
export const LOCAL_STORAGE_FREQUENT_KEY = "emoji-mart.frequently";
@@ -19,41 +15,76 @@ 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 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)
const stored = JSON.parse(
localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART,
);
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 data = await Promise.all(
Object.entries(frequentlyUsedEmoji).map(
async ([id, count]): 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 {
id,
count,
emoji: (await getEmojiDataFromNative(id))?.native,
command: async ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent((await getEmojiDataFromNative(id))?.native + " ")
.run();
emoji: entry.native,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertContent(entry.native + " ").run();
},
})
)
);
return data.sort((a, b) => b.count - a.count);
};
})
.filter((e): e is EmojiMenuItemType => e !== null);
return results.sort((a, b) => (b.count ?? 0) - (a.count ?? 0)).slice(0, 5);
};
export const getFrequentlyUsedEmoji = () => {
return JSON.parse(localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART);
}
export const getFrequentlyUsedEmoji = (): EmojiMartFrequentlyType => {
return JSON.parse(
localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART,
);
};
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;
};
@@ -0,0 +1,72 @@
.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;
}
}
@@ -0,0 +1,65 @@
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 />
</>
);
};
@@ -0,0 +1,28 @@
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}
/>
);
};
@@ -0,0 +1,23 @@
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>
);
};
@@ -0,0 +1,108 @@
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>
);
};
@@ -0,0 +1,24 @@
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} />
);
};
@@ -0,0 +1,44 @@
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>
);
};
@@ -0,0 +1,131 @@
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>
);
};
@@ -0,0 +1,6 @@
import { FC } from "react";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector";
export const LinkGroup: FC = () => {
return <LinkSelector />;
};
@@ -0,0 +1,61 @@
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>
);
};
@@ -0,0 +1,118 @@
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>
);
};
@@ -0,0 +1,217 @@
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>
);
};
@@ -0,0 +1,117 @@
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>
);
};
@@ -0,0 +1,50 @@
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,6 +25,7 @@ import {
IconColumns3,
IconColumns2,
IconTag,
IconMoodSmile,
IconRotate2,
} from "@tabler/icons-react";
import {
@@ -133,7 +134,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "Numbered list",
description: "Create a list with numbering.",
searchTerms: ["numbered", "ordered", "list"],
searchTerms: ["numbered", "ordered", "list", "ol"],
icon: IconListNumbers,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
@@ -477,10 +478,26 @@ 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"],
searchTerms: [
"subpages",
"child",
"children",
"nested",
"hierarchy",
"toc",
],
icon: IconSitemap,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertSubpages().run();
@@ -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 <= 3) {
if (label.length && level <= 4) {
acc.push({
label,
level,
@@ -10,7 +10,9 @@ 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";
@@ -46,6 +48,7 @@ import {
Subpages,
Heading,
Highlight,
Indent,
UniqueID,
SharedStorage,
Columns,
@@ -201,6 +204,7 @@ export const mainExtensions = [
showOnlyWhenEditable: true,
}),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Indent,
TaskList,
TaskItem.configure({
nested: true,
@@ -311,6 +315,8 @@ export const mainExtensions = [
view: CodeBlockView,
//@ts-ignore
lowlight,
enableTabIndentation: true,
tabSize: 2,
HTMLAttributes: {
spellcheck: false,
},
@@ -405,7 +411,10 @@ 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,
},
});
@@ -17,6 +17,8 @@ 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";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
@@ -52,6 +54,11 @@ 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
@@ -59,6 +66,7 @@ export function FullEditor({
size={!fullPageWidth && 900}
className={classes.editor}
>
{editorToolbarEnabled && editable && isEditMode && <FixedToolbar />}
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
@@ -0,0 +1,14 @@
.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,5 +13,6 @@
@import "./mention.css";
@import "./ordered-list.css";
@import "./highlight.css";
@import "./indent.css";
@import "./columns.css";
@import "./status.css";
@@ -0,0 +1,57 @@
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,6 +20,7 @@ export interface IUser {
deletedAt: Date;
fullPageWidth: boolean; // used for update
pageEditMode: string; // used for update
editorToolbar: boolean; // used for update
notificationPageUpdates: boolean; // used for update
notificationPageUserMention: boolean; // used for update
notificationCommentUserMention: boolean; // used for update
@@ -37,6 +38,7 @@ export interface IUserSettings {
preferences: {
fullPageWidth: boolean;
pageEditMode: string;
editorToolbar: boolean;
};
notifications?: {
"page.updated"?: boolean;
@@ -60,6 +60,10 @@ export function UserProvider({ children }: React.PropsWithChildren) {
}
}, [data, isLoading]);
useEffect(() => {
document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en-US";
}, [i18n.language, i18n.resolvedLanguage]);
useEffect(() => {
if (entitlements) {
setEntitlements(entitlements);
@@ -3,6 +3,7 @@ import AccountLanguage from "@/features/user/components/account-language.tsx";
import AccountTheme from "@/features/user/components/account-theme.tsx";
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
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 { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core";
@@ -37,6 +38,10 @@ export default function AccountPreferences() {
<Divider my={"md"} />
<FixedToolbarPref />
<Divider my={"md"} />
<NotificationPref />
</>
);
+2 -1
View File
@@ -163,10 +163,11 @@
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"happy-dom.+\\.js$": ["babel-jest", { "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] }],
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked)(@|/))"
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
@@ -34,6 +34,7 @@ import {
Mention,
Subpages,
Highlight,
Indent,
UniqueID,
Columns,
Column,
@@ -62,10 +63,11 @@ export const tiptapExtensions = [
}),
Heading,
UniqueID.configure({
types: ['heading', 'paragraph'],
types: ['heading', 'paragraph', 'transclusionSource'],
}),
Comment,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Indent,
TaskList,
TaskItem.configure({
nested: true,
@@ -0,0 +1,63 @@
import { htmlToJson, jsonToHtml } from '../../../collaboration/collaboration.util';
const findFirstChild = (
json: any,
type: string,
): any | undefined => {
if (!json || typeof json !== 'object') return undefined;
if (json.type === type) return json;
if (Array.isArray(json.content)) {
for (const child of json.content) {
const found = findFirstChild(child, type);
if (found) return found;
}
}
return undefined;
};
describe('indent attribute round-trip', () => {
it('parses data-indent on a paragraph into the indent attribute', () => {
const html = '<p data-indent="3">Hello</p>';
const json = htmlToJson(html);
const paragraph = findFirstChild(json, 'paragraph');
expect(paragraph).toBeDefined();
expect(paragraph.attrs.indent).toBe(3);
});
it('parses data-indent on a heading into the indent attribute', () => {
const html = '<h2 data-indent="2">Heading</h2>';
const json = htmlToJson(html);
const heading = findFirstChild(json, 'heading');
expect(heading).toBeDefined();
expect(heading.attrs.indent).toBe(2);
expect(heading.attrs.level).toBe(2);
});
it('clamps out-of-range data-indent values', () => {
const html = '<p data-indent="42">Too deep</p>';
const json = htmlToJson(html);
const paragraph = findFirstChild(json, 'paragraph');
expect(paragraph.attrs.indent).toBe(8);
});
it('renders nonzero indent back to data-indent on HTML serialization', () => {
const html = '<p data-indent="4">Round-trip</p>';
const json = htmlToJson(html);
const out = jsonToHtml(json);
expect(out).toContain('data-indent="4"');
});
it('omits data-indent for indent zero', () => {
const html = '<p>No indent</p>';
const json = htmlToJson(html);
const out = jsonToHtml(json);
expect(out).not.toContain('data-indent');
});
it('preserves indent through HTML → JSON → HTML', () => {
const original = '<p data-indent="5">Five deep</p>';
const json = htmlToJson(original);
const final = jsonToHtml(json);
expect(final).toContain('data-indent="5"');
});
});
@@ -22,6 +22,10 @@ export class UpdateUserDto extends PartialType(
@IsIn(['read', 'edit'])
pageEditMode: string;
@IsOptional()
@IsBoolean()
editorToolbar: boolean;
@IsOptional()
@IsString()
locale: string;
@@ -61,6 +61,14 @@ export class UserService {
);
}
if (typeof updateUserDto.editorToolbar !== 'undefined') {
return this.userRepo.updatePreference(
userId,
'editorToolbar',
updateUserDto.editorToolbar,
);
}
const notificationSettings: Record<string, NotificationSettingKey> = {
notificationPageUpdates: 'page.updated',
notificationPageUserMention: 'page.userMention',
+1 -1
View File
@@ -131,7 +131,7 @@
"brace-expansion@^5": "5.0.5",
"@xmldom/xmldom": "0.8.13",
"handlebars": "4.7.9",
"axios": "1.15.0",
"axios": "1.16.0",
"langsmith": "0.5.19",
"follow-redirects": "1.16.0",
"protobufjs": "7.5.5"
+1
View File
@@ -23,6 +23,7 @@ export * from "./lib/embed-provider";
export * from "./lib/subpages";
export * from "./lib/transclusion";
export * from "./lib/highlight";
export * from "./lib/indent";
export * from "./lib/heading/heading";
export * from "./lib/unique-id";
export * from "./lib/shared-storage";
@@ -1,8 +1,8 @@
import type { CodeBlockOptions } from "@tiptap/extension-code-block";
import CodeBlock from "@tiptap/extension-code-block";
import type { CodeBlockOptions } from '@tiptap/extension-code-block';
import CodeBlock from '@tiptap/extension-code-block';
import { LowlightPlugin } from "./lowlight-plugin.js";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { LowlightPlugin } from './lowlight-plugin.js';
import { ReactNodeViewRenderer } from '@tiptap/react';
export interface CodeBlockLowlightOptions extends CodeBlockOptions {
/**
@@ -12,7 +12,7 @@ export interface CodeBlockLowlightOptions extends CodeBlockOptions {
view: any;
}
const TAB_CHAR = "\u00A0\u00A0";
const TAB_CHAR = '\u00A0\u00A0';
/**
* This extension allows you to highlight code blocks with lowlight.
@@ -25,7 +25,7 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
return {
...this.parent?.(),
lowlight: {},
languageClassPrefix: "language-",
languageClassPrefix: 'language-',
exitOnTripleEnter: true,
exitOnArrowDown: true,
defaultLanguage: null,
@@ -37,20 +37,8 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
addKeyboardShortcuts() {
return {
...this.parent?.(),
Tab: () => {
if (this.editor.isActive("codeBlock")) {
this.editor
.chain()
.command(({ tr }) => {
tr.insertText(TAB_CHAR);
return true;
})
.run();
return true;
}
},
"Mod-a": () => {
if (this.editor.isActive("codeBlock")) {
'Mod-a': () => {
if (this.editor.isActive('codeBlock')) {
const { state } = this.editor;
const { $from } = state.selection;
@@ -60,7 +48,7 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
for (depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type.name === "codeBlock") {
if (node.type.name === 'codeBlock') {
codeBlockNode = node;
codeBlockPos = $from.start(depth) - 1;
break;
+223
View File
@@ -0,0 +1,223 @@
import { Extension } from '@tiptap/core';
import {
Plugin,
PluginKey,
type EditorState,
type Transaction,
} from '@tiptap/pm/state';
export type IndentOptions = {
types: string[];
min: number;
max: number;
};
declare module '@tiptap/core' {
interface Commands<ReturnType> {
indent: {
indent: () => ReturnType;
outdent: () => ReturnType;
};
}
}
// Containers whose descendants must never carry an `indent` attribute. These
// nodes own their own Tab semantics (list nesting, cell navigation, literal
// tab) and visually conflict with our indent padding, so paragraphs and
// headings inside them stay flat
const NON_INDENTABLE_ANCESTORS = new Set([
'listItem',
'taskItem',
'tableCell',
'tableHeader',
'codeBlock',
]);
const clampIndent = (value: number, min: number, max: number): number => {
if (!Number.isFinite(value)) return min;
return Math.max(min, Math.min(max, Math.trunc(value)));
};
const hasNonIndentableAncestor = (
doc: EditorState['doc'],
pos: number,
): boolean => {
const $pos = doc.resolve(pos);
for (let depth = $pos.depth; depth >= 0; depth--) {
if (NON_INDENTABLE_ANCESTORS.has($pos.node(depth).type.name)) {
return true;
}
}
return false;
};
export const Indent = Extension.create<IndentOptions>({
name: 'indent',
priority: 1000,
addOptions() {
return {
types: ['paragraph', 'heading'],
min: 0,
max: 8,
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
indent: {
default: this.options.min,
keepOnSplit: true,
parseHTML: (element) => {
const raw = element.getAttribute('data-indent');
if (raw === null) return this.options.min;
return clampIndent(
parseInt(raw, 10),
this.options.min,
this.options.max,
);
},
renderHTML: (attributes) => {
const value = attributes.indent;
if (value <= this.options.min) return {};
return { 'data-indent': String(value) };
},
},
},
},
];
},
addCommands() {
return {
indent:
() =>
({ state, tr, dispatch }) => {
return updateIndent(state, tr, dispatch, this.options, +1);
},
outdent:
() =>
({ state, tr, dispatch }) => {
return updateIndent(state, tr, dispatch, this.options, -1);
},
};
},
addKeyboardShortcuts() {
const isInIndentableBlock = (): boolean => {
const { $from } = this.editor.state.selection;
if (!this.options.types.includes($from.parent.type.name)) return false;
for (let depth = $from.depth - 1; depth >= 0; depth--) {
if (NON_INDENTABLE_ANCESTORS.has($from.node(depth).type.name)) {
return false;
}
}
return true;
};
return {
Tab: () => {
if (!isInIndentableBlock()) return false;
this.editor.commands.indent();
return true;
},
'Shift-Tab': () => {
if (!isInIndentableBlock()) return false;
this.editor.commands.outdent();
return true;
},
Backspace: () => {
const { $from, empty } = this.editor.state.selection;
if (!empty) return false;
if ($from.parentOffset !== 0) return false;
if (!isInIndentableBlock()) return false;
if ($from.parent.attrs.indent <= this.options.min) return false;
this.editor.commands.outdent();
return true;
},
};
},
addProseMirrorPlugins() {
const types = new Set(this.options.types);
const min = this.options.min;
return [
new Plugin({
key: new PluginKey('indentNormalizer'),
appendTransaction: (transactions, _oldState, newState) => {
if (!transactions.some((tr) => tr.docChanged)) return null;
const tr = newState.tr;
let modified = false;
newState.doc.descendants((node, pos) => {
// Containers: descend so we can find paragraph/heading children.
if (!types.has(node.type.name)) return true;
if (node.attrs.indent <= min) return false;
if (hasNonIndentableAncestor(newState.doc, pos)) {
tr.setNodeMarkup(
pos,
undefined,
{ ...node.attrs, indent: min },
node.marks,
);
modified = true;
}
// paragraph/heading don't contain other paragraphs/headings —
// never descend into their inline content.
return false;
});
if (!modified) return null;
// Normalisation must not show up as a separate undo step;
// otherwise undo would re-introduce the illegal indent.
return tr.setMeta('addToHistory', false);
},
}),
];
},
});
function updateIndent(
state: EditorState,
tr: Transaction,
dispatch: ((tr: Transaction) => void) | undefined,
options: IndentOptions,
delta: number,
): boolean {
const { selection } = state;
const { from, to } = selection;
const types = new Set(options.types);
let updated = false;
state.doc.nodesBetween(from, to, (node, pos) => {
// Skip non-block nodes (text, inline atoms) up front.
if (!node.type.isBlock) return false;
// Don't descend into containers whose children must stay flat — handles
// multi-block selections that span across e.g. a list-item or table-cell.
if (NON_INDENTABLE_ANCESTORS.has(node.type.name)) return false;
if (!types.has(node.type.name)) return true;
const current = node.attrs.indent as number;
const next = clampIndent(current + delta, options.min, options.max);
if (next === current) return false;
tr.setNodeMarkup(pos, undefined, { ...node.attrs, indent: next });
updated = true;
return false;
});
if (!updated) return false;
if (dispatch) dispatch(tr);
return true;
}
+9 -9
View File
@@ -37,7 +37,7 @@ overrides:
brace-expansion@^5: 5.0.5
'@xmldom/xmldom': 0.8.13
handlebars: 4.7.9
axios: 1.15.0
axios: 1.16.0
langsmith: 0.5.19
follow-redirects: 1.16.0
protobufjs: 7.5.5
@@ -296,8 +296,8 @@ importers:
specifier: ^1.1.0
version: 1.1.0
axios:
specifier: 1.15.0
version: 1.15.0
specifier: 1.16.0
version: 1.16.0
blueimp-load-image:
specifier: ^5.16.0
version: 5.16.0
@@ -5449,8 +5449,8 @@ packages:
avvio@9.1.0:
resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==}
axios@1.15.0:
resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==}
axios@1.16.0:
resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==}
babel-jest@30.3.0:
resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==}
@@ -15853,7 +15853,7 @@ snapshots:
'@fastify/error': 4.0.0
fastq: 1.17.1
axios@1.15.0:
axios@1.16.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.5
@@ -19187,7 +19187,7 @@ snapshots:
'@yarnpkg/lockfile': 1.1.0
'@yarnpkg/parsers': 3.0.2
'@zkochan/js-yaml': 0.0.7
axios: 1.15.0
axios: 1.16.0
cli-cursor: 3.1.0
cli-spinners: 2.6.1
cliui: 8.0.1
@@ -19728,7 +19728,7 @@ snapshots:
postmark@4.0.7:
dependencies:
axios: 1.15.0
axios: 1.16.0
transitivePeerDependencies:
- debug
@@ -21105,7 +21105,7 @@ snapshots:
typesense@3.0.5(@babel/runtime@7.29.2):
dependencies:
'@babel/runtime': 7.29.2
axios: 1.15.0
axios: 1.16.0
loglevel: 1.9.2
tslib: 2.8.1
transitivePeerDependencies: