diff --git a/apps/client/package.json b/apps/client/package.json index d21e178b..f85c008e 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -25,7 +25,7 @@ "@tabler/icons-react": "^3.40.0", "@tanstack/react-query": "5.90.17", "alfaaz": "^1.1.0", - "axios": "1.15.0", + "axios": "1.16.0", "blueimp-load-image": "^5.16.0", "clsx": "^2.1.1", "emoji-mart": "^5.6.0", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 443a4a76..f831289b 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -932,7 +932,6 @@ "Settings navigation": "Settings navigation", "AI navigation": "AI navigation", "Breadcrumb": "Breadcrumb", - "Skip to main content": "Skip to main content" "Synced block": "Synced block", "Create a block that stays in sync across pages.": "Create a block that stays in sync across pages.", "Editing original": "Editing original", @@ -946,5 +945,30 @@ "No pages": "No pages", "The original synced block no longer exists": "The original synced block no longer exists", "You don't have access to this synced block": "You don't have access to this synced block", - "Failed to load this synced block": "Failed to load this synced block" + "Failed to load this synced block": "Failed to load this synced block", + "Fixed editor toolbar": "Fixed editor toolbar", + "Show a formatting toolbar above the editor with quick access to common actions.": "Show a formatting toolbar above the editor with quick access to common actions.", + "Toggle fixed editor toolbar": "Toggle fixed editor toolbar", + "Normal text": "Normal text", + "More inline formatting": "More inline formatting", + "Subscript": "Subscript", + "Superscript": "Superscript", + "Inline code": "Inline code", + "Insert media": "Insert media", + "Mention": "Mention", + "Emoji": "Emoji", + "Columns": "Columns", + "More inserts": "More inserts", + "Embeds": "Embeds", + "Diagrams": "Diagrams", + "Advanced": "Advanced", + "Utility": "Utility", + "Decrease indent": "Decrease indent", + "Increase indent": "Increase indent", + "Clear formatting": "Clear formatting", + "Code block": "Code block", + "Experimental": "Experimental", + "Strikethrough": "Strikethrough", + "Undo": "Undo", + "Redo": "Redo" } 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/components/settings/app-version.tsx b/apps/client/src/components/settings/app-version.tsx index 44e0b8c4..5ba9ce2a 100644 --- a/apps/client/src/components/settings/app-version.tsx +++ b/apps/client/src/components/settings/app-version.tsx @@ -29,7 +29,7 @@ export default function AppVersion() { > = (props) => { const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const workspace = useAtomValue(workspaceAtom); const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true; + const user = useAtomValue(userAtom); + const editorToolbarEnabled = + user?.settings?.preferences?.editorToolbar ?? false; const [, setDraftCommentId] = useAtom(draftCommentIdAtom); const showCommentPopupRef = useRef(showCommentPopup); const showAiMenuRef = useRef(showAiMenu); @@ -149,7 +152,7 @@ export const EditorBubbleMenu: FC = (props) => { return isTextSelected(editor); }, options: { - placement: "top", + placement: editorToolbarEnabled ? "bottom" : "top", offset: 8, onHide: () => { setIsNodeSelectorOpen(false); @@ -188,56 +191,60 @@ export const EditorBubbleMenu: FC = (props) => { > )} - { - setIsNodeSelectorOpen(!isNodeSelectorOpen); - setIsTextAlignmentOpen(false); - setIsColorSelectorOpen(false); - }} - /> + {!editorToolbarEnabled && ( + <> + { + 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 ( - + } + 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 = ({ - - - + e.preventDefault()}> + {t("Text color")} @@ -207,6 +269,10 @@ export const ColorSelector: FC = ({ = ({ 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 = ({ = ({ 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 = ({ { 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")} - ); 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 ( - - - + + + } + onMouseDown={(e) => e.preventDefault()} onClick={() => setIsOpen(!isOpen)} aria-label={t("Text align")} aria-haspopup="menu" @@ -99,33 +106,25 @@ export const TextAlignmentSelector: FC = ({ - + - - - - {items.map((item, index) => ( - } - rightSection={ - activeItem.name === item.name && - } - justify="left" - fullWidth - onClick={() => { - item.command(); - setIsOpen(false); - }} - style={{ border: "none" }} - > - {t(item.name)} - - ))} - - - - + + {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..2a2135e8 --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx @@ -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 ( + <> + e.preventDefault()} + > + + {/* {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..b6f94b41 --- /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 ( + + ); +}; 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 ( + } + onClick={() => setShowAiMenu(true)} + > + {t("Ask AI")} + + ); +}; 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 ( + + + } + > + {label} + + + + } + 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..e08726e0 --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/color-group.tsx @@ -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 = ({ 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 ( + + ); +}; 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..f62775bf --- /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().indent().run()} + > + {t("Increase indent")} + + } + onClick={() => editor.chain().focus().outdent().run()} + > + {t("Decrease 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..8eca1daa --- /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..7740204e --- /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) + } + > + 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..86a45220 --- /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()} + > + Draw.io + + } + onClick={() => editor.chain().focus().setExcalidraw().run()} + > + Excalidraw + + + + {t("Embeds")} + } + onClick={() => setEmbed("iframe")} + > + Iframe + + } + onClick={() => setEmbed("youtube")} + > + YouTube + + } + onClick={() => setEmbed("vimeo")} + > + Vimeo + + } onClick={() => setEmbed("loom")}> + Loom + + } + onClick={() => setEmbed("figma")} + > + Figma + + } + onClick={() => setEmbed("airtable")} + > + Airtable + + } + onClick={() => setEmbed("typeform")} + > + Typeform + + } onClick={() => setEmbed("miro")}> + Miro + + } + onClick={() => setEmbed("framer")} + > + Framer + + } + onClick={() => setEmbed("gdrive")} + > + Google Drive + + } + onClick={() => setEmbed("gsheets")} + > + 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..aa44fbf8 --- /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("{{count}} Columns", { count: 2 })} + + } + onClick={() => + editor + .chain() + .focus() + .insertColumns({ layout: "three_equal" }) + .run() + } + > + {t("{{count}} Columns", { count: 3 })} + + } + onClick={() => + editor.chain().focus().insertColumns({ layout: "four_equal" }).run() + } + > + {t("{{count}} Columns", { count: 4 })} + + } + onClick={() => + editor.chain().focus().insertColumns({ layout: "five_equal" }).run() + } + > + {t("{{count}} Columns", { count: 5 })} + + + + + + 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/components/table-of-contents/table-of-contents.tsx b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx index 123889f3..804a274a 100644 --- a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx +++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx @@ -25,7 +25,7 @@ const recalculateLinks = (nodePos: NodePos[]) => { (acc, item) => { const label = item.node.textContent; const level = Number(item.node.attrs.level); - if (label.length && level <= 3) { + if (label.length && level <= 4) { acc.push({ label, level, diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index 6ebb8669..fb469899 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 editorToolbarEnabled = + user.settings?.preferences?.editorToolbar ?? false; + const userPageEditMode = + user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; + const isEditMode = userPageEditMode === PageEditMode.Edit; return ( + {editorToolbarEnabled && editable && isEditMode && } ) => { + const value = event.currentTarget.checked; + setChecked(value); + try { + const updatedUser = await updateUser({ editorToolbar: value }); + setUser(updatedUser); + } catch { + setChecked(!value); + } + }; + + return ( + + + + {t("Fixed editor toolbar")} + + {t("Experimental")} + + + + {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..f5154636 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 + editorToolbar: boolean; // used for update notificationPageUpdates: boolean; // used for update notificationPageUserMention: boolean; // used for update notificationCommentUserMention: boolean; // used for update @@ -37,6 +38,7 @@ export interface IUserSettings { preferences: { fullPageWidth: boolean; pageEditMode: string; + editorToolbar: boolean; }; notifications?: { "page.updated"?: boolean; 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..05557887 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() + editorToolbar: 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..ece40dff 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.editorToolbar !== 'undefined') { + return this.userRepo.updatePreference( + userId, + 'editorToolbar', + updateUserDto.editorToolbar, + ); + } + const notificationSettings: Record = { notificationPageUpdates: 'page.updated', notificationPageUserMention: 'page.userMention', diff --git a/package.json b/package.json index cd2fb127..1c84e466 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,7 @@ "brace-expansion@^5": "5.0.5", "@xmldom/xmldom": "0.8.13", "handlebars": "4.7.9", - "axios": "1.15.0", + "axios": "1.16.0", "langsmith": "0.5.19", "follow-redirects": "1.16.0", "protobufjs": "7.5.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab6123ac..f5498507 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ overrides: brace-expansion@^5: 5.0.5 '@xmldom/xmldom': 0.8.13 handlebars: 4.7.9 - axios: 1.15.0 + axios: 1.16.0 langsmith: 0.5.19 follow-redirects: 1.16.0 protobufjs: 7.5.5 @@ -296,8 +296,8 @@ importers: specifier: ^1.1.0 version: 1.1.0 axios: - specifier: 1.15.0 - version: 1.15.0 + specifier: 1.16.0 + version: 1.16.0 blueimp-load-image: specifier: ^5.16.0 version: 5.16.0 @@ -5449,8 +5449,8 @@ packages: avvio@9.1.0: resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} - axios@1.15.0: - resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} babel-jest@30.3.0: resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==} @@ -15853,7 +15853,7 @@ snapshots: '@fastify/error': 4.0.0 fastq: 1.17.1 - axios@1.15.0: + axios@1.16.0: dependencies: follow-redirects: 1.16.0 form-data: 4.0.5 @@ -19187,7 +19187,7 @@ snapshots: '@yarnpkg/lockfile': 1.1.0 '@yarnpkg/parsers': 3.0.2 '@zkochan/js-yaml': 0.0.7 - axios: 1.15.0 + axios: 1.16.0 cli-cursor: 3.1.0 cli-spinners: 2.6.1 cliui: 8.0.1 @@ -19728,7 +19728,7 @@ snapshots: postmark@4.0.7: dependencies: - axios: 1.15.0 + axios: 1.16.0 transitivePeerDependencies: - debug @@ -21105,7 +21105,7 @@ snapshots: typesense@3.0.5(@babel/runtime@7.29.2): dependencies: '@babel/runtime': 7.29.2 - axios: 1.15.0 + axios: 1.16.0 loglevel: 1.9.2 tslib: 2.8.1 transitivePeerDependencies: