feat(editor): fixed toolbar preference (#2185)
* feat(editor): fixed toolbar preference * remove key * cleanup translation strings * update axios
This commit is contained in:
@@ -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"),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user