diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 443a4a76..858e11f2 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -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" } diff --git a/apps/client/src/components/layouts/global/app-shell.module.css b/apps/client/src/components/layouts/global/app-shell.module.css index 7ed93772..dd3b72e4 100644 --- a/apps/client/src/components/layouts/global/app-shell.module.css +++ b/apps/client/src/components/layouts/global/app-shell.module.css @@ -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); - } -} - - diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx index 99373814..d3d9ebcd 100644 --- a/apps/client/src/components/layouts/global/global-app-shell.tsx +++ b/apps/client/src/components/layouts/global/global-app-shell.tsx @@ -81,11 +81,7 @@ export default function GlobalAppShell({ const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute; return ( - <> - - {t("Skip to main content")} - - )} - ); } diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css index facaf7ff..00e019f2 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css @@ -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); diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index ec7e7baa..04570ec2 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -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 = (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 = (props) => { return isTextSelected(editor); }, options: { - placement: "top", + placement: fixedToolbarEnabled ? "bottom" : "top", offset: 8, onHide: () => { setIsNodeSelectorOpen(false); @@ -188,56 +191,60 @@ export const EditorBubbleMenu: FC = (props) => {
)} - { - setIsNodeSelectorOpen(!isNodeSelectorOpen); - setIsTextAlignmentOpen(false); - setIsColorSelectorOpen(false); - }} - /> + {!fixedToolbarEnabled && ( + <> + { + setIsNodeSelectorOpen(!isNodeSelectorOpen); + setIsTextAlignmentOpen(false); + setIsColorSelectorOpen(false); + }} + /> - { - setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen); - setIsNodeSelectorOpen(false); - setIsColorSelectorOpen(false); - }} - /> + { + setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen); + setIsNodeSelectorOpen(false); + setIsColorSelectorOpen(false); + }} + /> - - {items.map((item, index) => ( - - - - - - ))} - + + {items.map((item, index) => ( + + + + + + ))} + - + - { - setIsColorSelectorOpen(!isColorSelectorOpen); - setIsNodeSelectorOpen(false); - setIsTextAlignmentOpen(false); - }} - /> + { + setIsColorSelectorOpen(!isColorSelectorOpen); + setIsNodeSelectorOpen(false); + setIsTextAlignmentOpen(false); + }} + /> + + )} ( + `[data-color-grid="${grid}"][data-color-index="${index}"]`, + ); + el?.focus(); +} + +function handleColorKeyNav( + e: React.KeyboardEvent, + 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('[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 = ({ editor, isOpen, @@ -157,13 +213,20 @@ export const ColorSelector: FC = ({ ); return ( - + - ); diff --git a/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx index 9df09f93..73fe722a 100644 --- a/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx @@ -155,7 +155,7 @@ export const NodeSelector: FC = ({ }; return ( - + = ({ const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0]; return ( - - - + + + - + - - - - {items.map((item, index) => ( - - ))} - - - - + + {items.map((item, index) => ( + } + rightSection={ + activeItem.name === item.name ? : null + } + onClick={() => { + item.command(); + setIsOpen(false); + }} + > + {t(item.name)} + + ))} + + ); }; diff --git a/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.module.css b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.module.css new file mode 100644 index 00000000..f5cf09cb --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.module.css @@ -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; + } +} diff --git a/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx new file mode 100644 index 00000000..45080e8c --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx @@ -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 ( + <> +
e.preventDefault()} + > +
+ {/* Ask AI is shown in the slim bubble menu when the toolbar is enabled. + Hidden here for now; uncomment to bring it back. */} + {/* {isGenerativeAiEnabled && ( + <> + +
+ + )} */} + +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+
+
+ + ); +}; diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/alignment-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/alignment-group.tsx new file mode 100644 index 00000000..29ed521a --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/alignment-group.tsx @@ -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 = ({ 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 ( + setIsOpen((prev) => !prev)} + /> + ); +}; diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/ask-ai-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/ask-ai-group.tsx new file mode 100644 index 00000000..48b95cd2 --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/ask-ai-group.tsx @@ -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 ( + + ); +}; diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/block-type-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/block-type-group.tsx new file mode 100644 index 00000000..3edb28ed --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/block-type-group.tsx @@ -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 = ({ 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 ( + + + + + + } + onClick={() => + editor.chain().focus().toggleNode("paragraph", "paragraph").run() + } + > + {t("Text")} + + } + onClick={() => + editor.chain().focus().toggleHeading({ level: 1 }).run() + } + > + {t("Heading 1")} + + } + onClick={() => + editor.chain().focus().toggleHeading({ level: 2 }).run() + } + > + {t("Heading 2")} + + } + onClick={() => + editor.chain().focus().toggleHeading({ level: 3 }).run() + } + > + {t("Heading 3")} + + } + onClick={() => editor.chain().focus().toggleBlockquote().run()} + > + {t("Quote")} + + } + onClick={() => editor.chain().focus().toggleCodeBlock().run()} + > + {t("Code block")} + + } + onClick={() => editor.chain().focus().setHorizontalRule().run()} + > + {t("Divider")} + + + + ); +}; diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/color-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/color-group.tsx new file mode 100644 index 00000000..ff8f260c --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/color-group.tsx @@ -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 = ({ 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 ( + setIsOpen((prev) => !prev)} + /> + ); +}; diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/history-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/history-group.tsx new file mode 100644 index 00000000..6d8fe5bc --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/history-group.tsx @@ -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 = ({ editor, state }) => { + const { t } = useTranslation(); + + return ( + + + editor.chain().focus().undo().run()} + > + + + + + editor.chain().focus().redo().run()} + > + + + + + ); +}; diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/inline-marks-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/inline-marks-group.tsx new file mode 100644 index 00000000..8ee4eca8 --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/inline-marks-group.tsx @@ -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 = ({ editor, state }) => { + const { t } = useTranslation(); + + return ( + + + editor.chain().focus().toggleBold().run()} + > + + + + + editor.chain().focus().toggleUnderline().run()} + > + + + + + editor.chain().focus().toggleItalic().run()} + > + + + + + + + + + + + } + onClick={() => editor.chain().focus().toggleStrike().run()} + > + {t("Strikethrough")} + + } + onClick={() => editor.chain().focus().toggleCode().run()} + > + {t("Inline code")} + + } + onClick={() => editor.chain().focus().toggleSubscript().run()} + > + {t("Subscript")} + + } + onClick={() => editor.chain().focus().toggleSuperscript().run()} + > + {t("Superscript")} + + + } + onClick={() => editor.chain().focus().outdent().run()} + > + {t("Decrease indent")} + + } + onClick={() => editor.chain().focus().indent().run()} + > + {t("Increase indent")} + + + } + onClick={() => editor.chain().focus().unsetAllMarks().run()} + > + {t("Clear formatting")} + + + + + ); +}; diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/link-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/link-group.tsx new file mode 100644 index 00000000..33476592 --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/link-group.tsx @@ -0,0 +1,6 @@ +import { FC } from "react"; +import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector"; + +export const LinkGroup: FC = () => { + return ; +}; diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/lists-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/lists-group.tsx new file mode 100644 index 00000000..5a39d87e --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/lists-group.tsx @@ -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 = ({ editor, state }) => { + const { t } = useTranslation(); + + return ( + + + editor.chain().focus().toggleBulletList().run()} + > + + + + + editor.chain().focus().toggleOrderedList().run()} + > + + + + + editor.chain().focus().toggleTaskList().run()} + > + + + + + ); +}; diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/media-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/media-group.tsx new file mode 100644 index 00000000..8cb23984 --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/media-group.tsx @@ -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 = ({ editor }) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + } + onClick={() => pickFile(editor, "image/*", true, uploadImageAction)} + > + {t("Image")} + + } + onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)} + > + {t("Video")} + + } + onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)} + > + {t("Audio")} + + } + onClick={() => + pickFile(editor, "application/pdf", false, uploadPdfAction) + } + > + {t("PDF")} + + } + onClick={() => + pickFile(editor, "", true, uploadAttachmentAction, true) + } + > + {t("File attachment")} + + + + ); +}; diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx new file mode 100644 index 00000000..ab22840e --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx @@ -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 = ({ 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 ( + + + + + + + + + + {t("Advanced")} + } + onClick={() => editor.chain().focus().toggleCallout().run()} + > + {t("Callout")} + + } + onClick={() => editor.chain().focus().setDetails().run()} + > + {t("Toggle block")} + + } + onClick={() => + editor.chain().focus().setStatus({ text: "", color: "gray" }).run() + } + > + {t("Status")} + + } + onClick={() => editor.chain().focus().insertSubpages().run()} + > + {t("Subpages")} + + } + onClick={() => + editor.chain().focus().insertTransclusionSource().run() + } + > + {t("Synced block")} + + + + {t("Diagrams")} + } + onClick={() => + editor + .chain() + .focus() + .setCodeBlock({ language: "mermaid" }) + .insertContent("flowchart LR\n A --> B") + .run() + } + > + {t("Mermaid diagram")} + + } + onClick={() => editor.chain().focus().setDrawio().run()} + > + {t("Draw.io")} + + } + onClick={() => editor.chain().focus().setExcalidraw().run()} + > + {t("Excalidraw")} + + + + {t("Embeds")} + } + onClick={() => setEmbed("iframe")} + > + {t("Iframe")} + + } + onClick={() => setEmbed("youtube")} + > + {t("YouTube")} + + } + onClick={() => setEmbed("vimeo")} + > + {t("Vimeo")} + + } onClick={() => setEmbed("loom")}> + {t("Loom")} + + } + onClick={() => setEmbed("figma")} + > + {t("Figma")} + + } + onClick={() => setEmbed("airtable")} + > + {t("Airtable")} + + } + onClick={() => setEmbed("typeform")} + > + {t("Typeform")} + + } onClick={() => setEmbed("miro")}> + {t("Miro")} + + } + onClick={() => setEmbed("framer")} + > + {t("Framer")} + + } + onClick={() => setEmbed("gdrive")} + > + {t("Google Drive")} + + } + onClick={() => setEmbed("gsheets")} + > + {t("Google Sheets")} + + + + {t("Utility")} + } + onClick={insertDate} + > + {t("Date")} + + } + onClick={() => editor.chain().focus().setMathInline().run()} + > + {t("Math inline")} + + } + onClick={() => editor.chain().focus().setMathBlock().run()} + > + {t("Math block")} + + + + ); +}; diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/quick-inserts-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/quick-inserts-group.tsx new file mode 100644 index 00000000..0accdc54 --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/quick-inserts-group.tsx @@ -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 = ({ editor }) => { + const { t } = useTranslation(); + + return ( + + + editor.chain().focus().insertContent("@").run()} + > + + + + + editor.chain().focus().insertContent(":").run()} + > + + + + + + + + + + + + + } + onClick={() => + editor.chain().focus().insertColumns({ layout: "two_equal" }).run() + } + > + {t("2 columns")} + + } + onClick={() => + editor + .chain() + .focus() + .insertColumns({ layout: "three_equal" }) + .run() + } + > + {t("3 columns")} + + } + onClick={() => + editor.chain().focus().insertColumns({ layout: "four_equal" }).run() + } + > + {t("4 columns")} + + } + onClick={() => + editor.chain().focus().insertColumns({ layout: "five_equal" }).run() + } + > + {t("5 columns")} + + + + + + editor + .chain() + .focus() + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + .run() + } + > + + + + + ); +}; diff --git a/apps/client/src/features/editor/components/fixed-toolbar/use-toolbar-state.ts b/apps/client/src/features/editor/components/fixed-toolbar/use-toolbar-state.ts new file mode 100644 index 00000000..589dbeea --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/use-toolbar-state.ts @@ -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; + 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"), + }; + }, + }); +} diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index 6ebb8669..2fd9ac3c 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -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 ( + {fixedToolbarEnabled && editable && isEditMode && } ) => { + const value = event.currentTarget.checked; + setChecked(value); + try { + const updatedUser = await updateUser({ fixedToolbar: value }); + setUser(updatedUser); + } catch { + setChecked(!value); + } + }; + + return ( + + + {t("Fixed editor toolbar")} + + {t( + "Show a formatting toolbar above the editor with quick access to common actions.", + )} + + + + + + + + ); +} diff --git a/apps/client/src/features/user/types/user.types.ts b/apps/client/src/features/user/types/user.types.ts index 75d45bfd..0412e7d5 100644 --- a/apps/client/src/features/user/types/user.types.ts +++ b/apps/client/src/features/user/types/user.types.ts @@ -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; diff --git a/apps/client/src/pages/settings/account/account-preferences.tsx b/apps/client/src/pages/settings/account/account-preferences.tsx index caedc1b0..bead4bfb 100644 --- a/apps/client/src/pages/settings/account/account-preferences.tsx +++ b/apps/client/src/pages/settings/account/account-preferences.tsx @@ -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() { + + + + ); diff --git a/apps/server/src/core/user/dto/update-user.dto.ts b/apps/server/src/core/user/dto/update-user.dto.ts index 9d8ef8ef..2db29609 100644 --- a/apps/server/src/core/user/dto/update-user.dto.ts +++ b/apps/server/src/core/user/dto/update-user.dto.ts @@ -22,6 +22,10 @@ export class UpdateUserDto extends PartialType( @IsIn(['read', 'edit']) pageEditMode: string; + @IsOptional() + @IsBoolean() + fixedToolbar: boolean; + @IsOptional() @IsString() locale: string; diff --git a/apps/server/src/core/user/user.service.ts b/apps/server/src/core/user/user.service.ts index 7a143976..aa1b14c0 100644 --- a/apps/server/src/core/user/user.service.ts +++ b/apps/server/src/core/user/user.service.ts @@ -61,6 +61,14 @@ export class UserService { ); } + if (typeof updateUserDto.fixedToolbar !== 'undefined') { + return this.userRepo.updatePreference( + userId, + 'fixedToolbar', + updateUserDto.fixedToolbar, + ); + } + const notificationSettings: Record = { notificationPageUpdates: 'page.updated', notificationPageUserMention: 'page.userMention',