mirror of
https://github.com/docmost/docmost.git
synced 2026-05-09 15:53:10 +08:00
feat(editor): fixed toolbar preference
This commit is contained in:
@@ -932,7 +932,7 @@
|
||||
"Settings navigation": "Settings navigation",
|
||||
"AI navigation": "AI navigation",
|
||||
"Breadcrumb": "Breadcrumb",
|
||||
"Skip to main content": "Skip to main content"
|
||||
"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 +946,43 @@
|
||||
"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",
|
||||
"2 columns": "2 columns",
|
||||
"3 columns": "3 columns",
|
||||
"4 columns": "4 columns",
|
||||
"5 columns": "5 columns",
|
||||
"More inserts": "More inserts",
|
||||
"Draw.io": "Draw.io",
|
||||
"Excalidraw": "Excalidraw",
|
||||
"Iframe": "Iframe",
|
||||
"YouTube": "YouTube",
|
||||
"Vimeo": "Vimeo",
|
||||
"Loom": "Loom",
|
||||
"Figma": "Figma",
|
||||
"Airtable": "Airtable",
|
||||
"Typeform": "Typeform",
|
||||
"Miro": "Miro",
|
||||
"Framer": "Framer",
|
||||
"Google Drive": "Google Drive",
|
||||
"Google Sheets": "Google Sheets",
|
||||
"Embeds": "Embeds",
|
||||
"Diagrams": "Diagrams",
|
||||
"Advanced": "Advanced",
|
||||
"Utility": "Utility",
|
||||
"Decrease indent": "Decrease indent",
|
||||
"Increase indent": "Increase indent",
|
||||
"Clear formatting": "Clear formatting",
|
||||
"Code block": "Code block"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 fixedToolbarEnabled =
|
||||
user?.settings?.preferences?.fixedToolbar ?? 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: fixedToolbarEnabled ? "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);
|
||||
}}
|
||||
/>
|
||||
{!fixedToolbarEnabled && (
|
||||
<>
|
||||
<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")}
|
||||
|
||||
+30
-31
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,67 @@
|
||||
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}>
|
||||
{/* Ask AI is shown in the slim bubble menu when the toolbar is enabled.
|
||||
Hidden here for now; uncomment to bring it back. */}
|
||||
{/* {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((prev) => !prev)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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,28 @@
|
||||
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((prev) => !prev)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+131
@@ -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={<IconIndentDecrease size={16} />}
|
||||
onClick={() => editor.chain().focus().outdent().run()}
|
||||
>
|
||||
{t("Decrease indent")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconIndentIncrease size={16} />}
|
||||
onClick={() => editor.chain().focus().indent().run()}
|
||||
>
|
||||
{t("Increase 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)
|
||||
}
|
||||
>
|
||||
{t("PDF")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconPaperclip size={16} />}
|
||||
onClick={() =>
|
||||
pickFile(editor, "", true, uploadAttachmentAction, true)
|
||||
}
|
||||
>
|
||||
{t("File attachment")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
+217
@@ -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()}
|
||||
>
|
||||
{t("Draw.io")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconExcalidraw size={16} />}
|
||||
onClick={() => editor.chain().focus().setExcalidraw().run()}
|
||||
>
|
||||
{t("Excalidraw")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t("Embeds")}</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconAppWindow size={16} />}
|
||||
onClick={() => setEmbed("iframe")}
|
||||
>
|
||||
{t("Iframe")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<YoutubeIcon size={16} />}
|
||||
onClick={() => setEmbed("youtube")}
|
||||
>
|
||||
{t("YouTube")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<VimeoIcon size={16} />}
|
||||
onClick={() => setEmbed("vimeo")}
|
||||
>
|
||||
{t("Vimeo")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<LoomIcon size={16} />} onClick={() => setEmbed("loom")}>
|
||||
{t("Loom")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<FigmaIcon size={16} />}
|
||||
onClick={() => setEmbed("figma")}
|
||||
>
|
||||
{t("Figma")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<AirtableIcon size={16} />}
|
||||
onClick={() => setEmbed("airtable")}
|
||||
>
|
||||
{t("Airtable")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<TypeformIcon size={16} />}
|
||||
onClick={() => setEmbed("typeform")}
|
||||
>
|
||||
{t("Typeform")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<MiroIcon size={16} />} onClick={() => setEmbed("miro")}>
|
||||
{t("Miro")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<FramerIcon size={16} />}
|
||||
onClick={() => setEmbed("framer")}
|
||||
>
|
||||
{t("Framer")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<GoogleDriveIcon size={16} />}
|
||||
onClick={() => setEmbed("gdrive")}
|
||||
>
|
||||
{t("Google Drive")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<GoogleSheetsIcon size={16} />}
|
||||
onClick={() => setEmbed("gsheets")}
|
||||
>
|
||||
{t("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>
|
||||
);
|
||||
};
|
||||
+117
@@ -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("2 columns")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconColumns3 size={16} />}
|
||||
onClick={() =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertColumns({ layout: "three_equal" })
|
||||
.run()
|
||||
}
|
||||
>
|
||||
{t("3 columns")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconColumns4 size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().insertColumns({ layout: "four_equal" }).run()
|
||||
}
|
||||
>
|
||||
{t("4 columns")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconColumns5 size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().insertColumns({ layout: "five_equal" }).run()
|
||||
}
|
||||
>
|
||||
{t("5 columns")}
|
||||
</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"),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 fixedToolbarEnabled =
|
||||
user.settings?.preferences?.fixedToolbar ?? 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}
|
||||
>
|
||||
{fixedToolbarEnabled && editable && isEditMode && <FixedToolbar />}
|
||||
<MemoizedTitleEditor
|
||||
pageId={pageId}
|
||||
slugId={slugId}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { updateUser } from "@/features/user/services/user-service";
|
||||
import { 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?.fixedToolbar ?? false,
|
||||
);
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
setChecked(value);
|
||||
try {
|
||||
const updatedUser = await updateUser({ fixedToolbar: value });
|
||||
setUser(updatedUser);
|
||||
} catch {
|
||||
setChecked(!value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveSettingsRow>
|
||||
<ResponsiveSettingsContent>
|
||||
<Text size="md">{t("Fixed editor toolbar")}</Text>
|
||||
<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
|
||||
fixedToolbar: 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;
|
||||
fixedToolbar: boolean;
|
||||
};
|
||||
notifications?: {
|
||||
"page.updated"?: boolean;
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,10 @@ export class UpdateUserDto extends PartialType(
|
||||
@IsIn(['read', 'edit'])
|
||||
pageEditMode: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
fixedToolbar: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale: string;
|
||||
|
||||
@@ -61,6 +61,14 @@ export class UserService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateUserDto.fixedToolbar !== 'undefined') {
|
||||
return this.userRepo.updatePreference(
|
||||
userId,
|
||||
'fixedToolbar',
|
||||
updateUserDto.fixedToolbar,
|
||||
);
|
||||
}
|
||||
|
||||
const notificationSettings: Record<string, NotificationSettingKey> = {
|
||||
notificationPageUpdates: 'page.updated',
|
||||
notificationPageUserMention: 'page.userMention',
|
||||
|
||||
Reference in New Issue
Block a user