Add phosphor icons and improve menus UI

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-23 18:06:53 +04:00
parent 34d64ecb75
commit 9ce510caa7
8 changed files with 139 additions and 129 deletions

View File

@@ -23,6 +23,8 @@ import { useDebounceCallback } from "usehooks-ts";
import type { DocumentDescriptionPage_updateContentMutation } from "#/__generated__/core/DocumentDescriptionPage_updateContentMutation.graphql"; import type { DocumentDescriptionPage_updateContentMutation } from "#/__generated__/core/DocumentDescriptionPage_updateContentMutation.graphql";
import type { DocumentDescriptionPageQuery } from "#/__generated__/core/DocumentDescriptionPageQuery.graphql"; import type { DocumentDescriptionPageQuery } from "#/__generated__/core/DocumentDescriptionPageQuery.graphql";
const autoSaveIntervalMs = 1000;
export const documentDescriptionPageQuery = graphql` export const documentDescriptionPageQuery = graphql`
query DocumentDescriptionPageQuery($documentId: ID! $versionId: ID! $versionSpecified: Boolean!) { query DocumentDescriptionPageQuery($documentId: ID! $versionId: ID! $versionSpecified: Boolean!) {
# We use this on /documents/:documentId/versions/:versionId/description # We use this on /documents/:documentId/versions/:versionId/description
@@ -113,7 +115,7 @@ export function DocumentDescriptionPage(props: { queryRef: PreloadedQuery<Docume
}, },
}); });
}, [currentVersion.id, updateContent, toast, __]), }, [currentVersion.id, updateContent, toast, __]),
500, autoSaveIntervalMs,
); );
return ( return (

14
package-lock.json generated
View File

@@ -3196,6 +3196,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@phosphor-icons/react": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
"integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">= 16.8",
"react-dom": ">= 16.8"
}
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.58.2", "version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
@@ -19702,6 +19715,7 @@
"dependencies": { "dependencies": {
"@ariakit/react": "^0.4.17", "@ariakit/react": "^0.4.17",
"@floating-ui/react": "^0.27.19", "@floating-ui/react": "^0.27.19",
"@phosphor-icons/react": "^2.1.10",
"@probo/helpers": "1.0.0", "@probo/helpers": "1.0.0",
"@probo/i18n": "1.0.0", "@probo/i18n": "1.0.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",

View File

@@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@ariakit/react": "^0.4.17", "@ariakit/react": "^0.4.17",
"@floating-ui/react": "^0.27.19", "@floating-ui/react": "^0.27.19",
"@phosphor-icons/react": "^2.1.10",
"@probo/helpers": "1.0.0", "@probo/helpers": "1.0.0",
"@probo/i18n": "1.0.0", "@probo/i18n": "1.0.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",

View File

@@ -9,21 +9,20 @@ import {
useFloatingRootContext, useFloatingRootContext,
useInteractions, useInteractions,
} from "@floating-ui/react"; } from "@floating-ui/react";
import { CodeBlockIcon, ListBulletsIcon, ListNumbersIcon, MinusIcon, PlusIcon, QuotesIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react";
import { type useEditor } from "@tiptap/react"; import { type useEditor } from "@tiptap/react";
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { tv } from "tailwind-variants"; import { tv } from "tailwind-variants";
import { IconPlusSmall } from "../Atoms/Icons";
import { MenuButton } from "./MenuButton"; import { MenuButton } from "./MenuButton";
const blockMenuVariants = tv({ const blockMenuVariants = tv({
slots: { slots: {
trigger: [ trigger: [
"z-50 flex size-6 items-center justify-center", "z-10 flex size-6 items-center justify-center",
"rounded text-txt-tertiary hover:bg-subtle hover:text-txt-primary text-xl font-light cursor-pointer", "rounded text-txt-tertiary hover:bg-subtle hover:text-txt-primary text-xl font-light cursor-pointer",
], ],
menu: ["flex items-center gap-1 rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-50"], menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"],
}, },
}); });
@@ -170,7 +169,7 @@ export function BlockMenu({ editor }: BlockMenuProps) {
}} }}
className={trigger()} className={trigger()}
> >
<IconPlusSmall size={16} /> <PlusIcon size={16} weight="bold" />
</button> </button>
{menuOpen && ( {menuOpen && (
<div <div
@@ -183,50 +182,69 @@ export function BlockMenu({ editor }: BlockMenuProps) {
onMouseDown={e => e.preventDefault()} onMouseDown={e => e.preventDefault()}
className={menu()} className={menu()}
> >
<div className="p-1 font-semibold text-sm">Style</div>
<MenuButton <MenuButton
label="H1" active={false}
active={editor.isActive("heading", { level: 1 })} onClick={() => handleAction(chain => chain.setParagraph())}
>
<TextTIcon size={16} weight="bold" />
Text
</MenuButton>
<MenuButton
active={false}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 1 }))} onClick={() => handleAction(chain => chain.toggleHeading({ level: 1 }))}
/> >
<TextHOneIcon size={16} weight="bold" />
Heading 1
</MenuButton>
<MenuButton <MenuButton
label="H2" active={false}
active={editor.isActive("heading", { level: 2 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 2 }))} onClick={() => handleAction(chain => chain.toggleHeading({ level: 2 }))}
/> >
<TextHTwoIcon size={16} weight="bold" />
Heading 2
</MenuButton>
<MenuButton <MenuButton
label="H3" active={false}
active={editor.isActive("heading", { level: 3 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 3 }))} onClick={() => handleAction(chain => chain.toggleHeading({ level: 3 }))}
/> >
<TextHThreeIcon size={16} weight="bold" />
Heading 3
</MenuButton>
<MenuButton <MenuButton
label="Bullet List" active={false}
active={editor.isActive("bulletList")}
onClick={() => handleAction(chain => chain.toggleBulletList())} onClick={() => handleAction(chain => chain.toggleBulletList())}
/> >
<ListBulletsIcon size={16} weight="bold" />
Bullet List
</MenuButton>
<MenuButton <MenuButton
label="Ordered List" active={false}
active={editor.isActive("orderedList")}
onClick={() => handleAction(chain => chain.toggleOrderedList())} onClick={() => handleAction(chain => chain.toggleOrderedList())}
/> >
<ListNumbersIcon size={16} weight="bold" />
Ordered List
</MenuButton>
<MenuButton <MenuButton
label="Code" active={false}
active={editor.isActive("code")}
onClick={() => handleAction(chain => chain.toggleCode())}
/>
<MenuButton
label="Code Block"
active={editor.isActive("codeBlock")}
onClick={() => handleAction(chain => chain.toggleCodeBlock())} onClick={() => handleAction(chain => chain.toggleCodeBlock())}
/> >
<CodeBlockIcon size={16} weight="bold" />
Code Block
</MenuButton>
<MenuButton <MenuButton
label="Blockquote" active={false}
active={editor.isActive("blockquote")}
onClick={() => handleAction(chain => chain.toggleBlockquote())} onClick={() => handleAction(chain => chain.toggleBlockquote())}
/> >
<QuotesIcon size={16} weight="bold" />
Blockquote
</MenuButton>
<MenuButton <MenuButton
label="Divider"
onClick={() => handleAction(chain => chain.setHorizontalRule())} onClick={() => handleAction(chain => chain.setHorizontalRule())}
/> >
<MinusIcon size={16} weight="bold" />
Divider
</MenuButton>
</div> </div>
)} )}
</> </>

View File

@@ -1,3 +1,4 @@
import { CodeIcon, LinkIcon, TextBIcon, TextItalicIcon, TextStrikethroughIcon, TextUnderlineIcon } from "@phosphor-icons/react";
import type { useEditor } from "@tiptap/react"; import type { useEditor } from "@tiptap/react";
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { tv } from "tailwind-variants"; import { tv } from "tailwind-variants";
@@ -21,32 +22,36 @@ export function BubbleMenu(props: BubbleMenuProps) {
className={bubbleMenuVariants()} className={bubbleMenuVariants()}
> >
<MenuButton <MenuButton
label="Bold"
active={editor.isActive("bold")} active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
/> >
<TextBIcon size={16} weight="bold" />
</MenuButton>
<MenuButton <MenuButton
label="Italic"
active={editor.isActive("italic")} active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleItalic().run()}
/> >
<TextItalicIcon size={16} weight="bold" />
</MenuButton>
<MenuButton <MenuButton
label="Underline"
active={editor.isActive("underline")} active={editor.isActive("underline")}
onClick={() => editor.chain().focus().toggleUnderline().run()} onClick={() => editor.chain().focus().toggleUnderline().run()}
/> >
<TextUnderlineIcon size={16} weight="bold" />
</MenuButton>
<MenuButton <MenuButton
label="Strike"
active={editor.isActive("strike")} active={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()} onClick={() => editor.chain().focus().toggleStrike().run()}
/> >
<TextStrikethroughIcon size={16} weight="bold" />
</MenuButton>
<MenuButton <MenuButton
label="Code"
active={editor.isActive("code")} active={editor.isActive("code")}
onClick={() => editor.chain().focus().toggleCode().run()} onClick={() => editor.chain().focus().toggleCode().run()}
/> >
<CodeIcon size={16} weight="bold" />
</MenuButton>
<MenuButton <MenuButton
label="Link"
active={editor.isActive("link")} active={editor.isActive("link")}
onClick={() => { onClick={() => {
if (editor.isActive("link")) { if (editor.isActive("link")) {
@@ -58,7 +63,9 @@ export function BubbleMenu(props: BubbleMenuProps) {
editor.chain().focus().setLink({ href: url }).run(); editor.chain().focus().setLink({ href: url }).run();
} }
}} }}
/> >
<LinkIcon size={16} weight="bold" />
</MenuButton>
</BaseBubbleMenu> </BaseBubbleMenu>
); );
} }

View File

@@ -1,7 +1,11 @@
import type { PropsWithChildren } from "react";
import { tv } from "tailwind-variants"; import { tv } from "tailwind-variants";
const menuButtonVariants = tv({ const menuButtonVariants = tv({
base: ["px-2 py-1 text-sm rounded-sm font-semibold bg-level-0 hover:bg-subtle"], base: [
"flex items-center gap-2 w-full",
"px-2 py-1.5 text-sm rounded-sm bg-level-0 hover:bg-subtle cursor-pointer",
],
variants: { variants: {
active: { active: {
true: ["bg-active"], true: ["bg-active"],
@@ -10,19 +14,18 @@ const menuButtonVariants = tv({
}); });
type MenuButtonProps = { type MenuButtonProps = {
label: string;
active?: boolean; active?: boolean;
onClick: () => void; onClick: () => void;
}; };
export function MenuButton({ label, active, onClick }: MenuButtonProps) { export function MenuButton({ children, active, onClick }: PropsWithChildren<MenuButtonProps>) {
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={menuButtonVariants({ active })} className={menuButtonVariants({ active })}
> >
{label} {children}
</button> </button>
); );
} }

View File

@@ -9,23 +9,22 @@ import {
useFloatingRootContext, useFloatingRootContext,
useInteractions, useInteractions,
} from "@floating-ui/react"; } from "@floating-ui/react";
import { CodeBlockIcon, DotsSixVerticalIcon, ListBulletsIcon, ListNumbersIcon, QuotesIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react";
import { NodeSelection, TextSelection } from "@tiptap/pm/state"; import { NodeSelection, TextSelection } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view"; import type { EditorView } from "@tiptap/pm/view";
import { type useEditor } from "@tiptap/react"; import { type useEditor } from "@tiptap/react";
import { type DragEvent, useEffect, useLayoutEffect, useRef, useState } from "react"; import { type DragEvent, useEffect, useLayoutEffect, useRef, useState } from "react";
import { tv } from "tailwind-variants"; import { tv } from "tailwind-variants";
import { IconChevronGrabberVertical } from "../Atoms/Icons";
import { MenuButton } from "./MenuButton"; import { MenuButton } from "./MenuButton";
const optionsMenuVariants = tv({ const optionsMenuVariants = tv({
slots: { slots: {
trigger: [ trigger: [
"z-50 flex size-6 items-center justify-center", "z-10 flex size-6 items-center justify-center",
"rounded text-txt-tertiary hover:bg-subtle hover:text-txt-primary cursor-grab", "rounded text-txt-tertiary hover:bg-subtle hover:text-txt-primary cursor-grab",
], ],
menu: ["flex items-center gap-1 rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-50"], menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"],
}, },
}); });
@@ -265,7 +264,7 @@ export function OptionsMenu({ editor }: OptionsMenuProps) {
}} }}
className={trigger()} className={trigger()}
> >
<IconChevronGrabberVertical size={16} /> <DotsSixVerticalIcon size={20} weight="bold" />
</button> </button>
{menuOpen && ( {menuOpen && (
<div <div
@@ -278,46 +277,63 @@ export function OptionsMenu({ editor }: OptionsMenuProps) {
onMouseDown={e => e.preventDefault()} onMouseDown={e => e.preventDefault()}
className={menu()} className={menu()}
> >
<div className="p-1 font-semibold text-sm">Turn into</div>
<MenuButton
active={isNodeType("paragraph")}
onClick={() => handleAction(chain => chain.setParagraph())}
>
<TextTIcon size={16} weight="bold" />
Text
</MenuButton>
<MenuButton <MenuButton
label="H1"
active={isNodeType("heading", { level: 1 })} active={isNodeType("heading", { level: 1 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 1 }))} onClick={() => handleAction(chain => chain.toggleHeading({ level: 1 }))}
/> >
<TextHOneIcon size={16} weight="bold" />
Heading 1
</MenuButton>
<MenuButton <MenuButton
label="H2"
active={isNodeType("heading", { level: 2 })} active={isNodeType("heading", { level: 2 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 2 }))} onClick={() => handleAction(chain => chain.toggleHeading({ level: 2 }))}
/> >
<TextHTwoIcon size={16} weight="bold" />
Heading 2
</MenuButton>
<MenuButton <MenuButton
label="H3"
active={isNodeType("heading", { level: 3 })} active={isNodeType("heading", { level: 3 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 3 }))} onClick={() => handleAction(chain => chain.toggleHeading({ level: 3 }))}
/> >
<TextHThreeIcon size={16} weight="bold" />
Heading 3
</MenuButton>
<MenuButton <MenuButton
label="Bullet List"
active={isNodeType("bulletList")} active={isNodeType("bulletList")}
onClick={() => handleAction(chain => chain.toggleBulletList())} onClick={() => handleAction(chain => chain.toggleBulletList())}
/> >
<ListBulletsIcon size={16} weight="bold" />
Bullet List
</MenuButton>
<MenuButton <MenuButton
label="Ordered List"
active={isNodeType("orderedList")} active={isNodeType("orderedList")}
onClick={() => handleAction(chain => chain.toggleOrderedList())} onClick={() => handleAction(chain => chain.toggleOrderedList())}
/> >
<ListNumbersIcon size={16} weight="bold" />
Ordered List
</MenuButton>
<MenuButton <MenuButton
label="Code"
active={isNodeType("code")}
onClick={() => handleAction(chain => chain.toggleCode())}
/>
<MenuButton
label="Code Block"
active={isNodeType("codeBlock")} active={isNodeType("codeBlock")}
onClick={() => handleAction(chain => chain.toggleCodeBlock())} onClick={() => handleAction(chain => chain.toggleCodeBlock())}
/> >
<CodeBlockIcon size={16} weight="bold" />
Code Block
</MenuButton>
<MenuButton <MenuButton
label="Blockquote"
active={isNodeType("blockquote")} active={isNodeType("blockquote")}
onClick={() => handleAction(chain => chain.toggleBlockquote())} onClick={() => handleAction(chain => chain.toggleBlockquote())}
/> >
<QuotesIcon size={16} weight="bold" />
Blockquote
</MenuButton>
</div> </div>
)} )}
</> </>

View File

@@ -14,13 +14,12 @@ import { Text } from "@tiptap/extension-text";
import { Underline } from "@tiptap/extension-underline"; import { Underline } from "@tiptap/extension-underline";
import { Dropcursor, Gapcursor, UndoRedo } from "@tiptap/extensions"; import { Dropcursor, Gapcursor, UndoRedo } from "@tiptap/extensions";
import { type Content, EditorContent, useEditor, useEditorState } from "@tiptap/react"; import { type Content, EditorContent, useEditor, useEditorState } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import { type ComponentProps, useEffect, useRef } from "react"; import { type ComponentProps, useEffect, useRef } from "react";
import { tv } from "tailwind-variants"; import { tv } from "tailwind-variants";
import { BlockMenu } from "./BlockMenu"; import { BlockMenu } from "./BlockMenu";
import { BubbleMenu } from "./BubbleMenu";
import { LinkExtension } from "./LinkExtension"; import { LinkExtension } from "./LinkExtension";
import { MenuButton } from "./MenuButton";
import { OptionsMenu } from "./OptionsMenu"; import { OptionsMenu } from "./OptionsMenu";
const extensions = [ const extensions = [
@@ -49,14 +48,9 @@ const extensions = [
]; ];
const richEditorVariants = tv({ const richEditorVariants = tv({
slots: { base: ["h-full px-12"],
bubbleMenu: ["flex items-center gap-1 rounded-lg border border-border-mid bg-level-0 p-1 shadow-md"],
editor: ["h-full px-12"],
},
}); });
const { bubbleMenu, editor: editorVariants } = richEditorVariants();
type RichEditorProps = ComponentProps<"div"> & { type RichEditorProps = ComponentProps<"div"> & {
content: string; content: string;
disabled?: boolean; disabled?: boolean;
@@ -94,55 +88,10 @@ export function RichEditor(props: RichEditorProps) {
}, [content, watchedContent, onChangeContent]); }, [content, watchedContent, onChangeContent]);
return ( return (
<div className={editorVariants({ className })}> <div className={richEditorVariants({ className })}>
<BubbleMenu <BubbleMenu editor={editor} />
editor={editor}
className={bubbleMenu()}
>
<MenuButton
label="Bold"
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
/>
<MenuButton
label="Italic"
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
/>
<MenuButton
label="Underline"
active={editor.isActive("underline")}
onClick={() => editor.chain().focus().toggleUnderline().run()}
/>
<MenuButton
label="Strike"
active={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
/>
<MenuButton
label="Code"
active={editor.isActive("code")}
onClick={() => editor.chain().focus().toggleCode().run()}
/>
<MenuButton
label="Link"
active={editor.isActive("link")}
onClick={() => {
if (editor.isActive("link")) {
editor.chain().focus().unsetLink().run();
return;
}
const url = window.prompt("URL");
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
}}
/>
</BubbleMenu>
<BlockMenu editor={editor} /> <BlockMenu editor={editor} />
<OptionsMenu editor={editor} /> <OptionsMenu editor={editor} />
<EditorContent className="h-full" editor={editor} /> <EditorContent className="h-full" editor={editor} />
</div> </div>
); );