@ -1,5 +1,5 @@
import { Heading Icon, type LucideIcon , Minimize2Icon , MoreHorizontal Icon } from "lucide-react" ;
import { type RefObject, useRef } from "react" ;
import { Heading 1Icon, Heading2Icon , Heading3 Icon, type LucideIcon , Minimize2Icon , MoreHorizontal Icon, Pilcrow Icon } from "lucide-react" ;
import { type ComponentPropsWithoutRef, forwardRef , type MouseEventHandler , type RefObject, useRef } from "react" ;
import { Button } from "@/components/ui/button" ;
import {
DropdownMenu ,
@ -10,21 +10,31 @@ import {
} from "@/components/ui/dropdown-menu" ;
import { cn } from "@/lib/utils" ;
import { useTranslate } from "@/utils/i18n" ;
import { EDITOR_COMMANDS , EDITOR_COMMANDS_BY_ID , type EditorCommand , type EditorCommandId , isCommandActive } from "../formatting/commands" ;
import {
EDITOR_COMMANDS ,
EDITOR_COMMANDS_BY_ID ,
type EditorCommand ,
type EditorCommandId ,
isCommandActive ,
type ToolbarHeadingLevel ,
} from "../formatting/commands" ;
import { isCompactWidth , useEditorActiveState , useElementWidth } from "../hooks" ;
import type { EditorController } from "../types" ;
interface FormattingToolbarProps {
controllerRef : RefObject < EditorController | null > ;
onExit : ( ) = > void ;
/** Extra classes for the host to frame the toolbar (e.g. the focus-mode header band). */
/** Called by the exit button; when omitted (normal-mode toolbar) the button is hidden. */
onExit ? : ( ) = > void ;
/** Extra classes for the host to frame the toolbar row. */
className? : string ;
}
const MARK_COMMANDS = EDITOR_COMMANDS . filter ( ( command ) = > command . group === "mark" ) ;
const LIST_COMMANDS = EDITOR_COMMANDS . filter ( ( command ) = > command . group === "list" ) ;
// Paragraph + headings render as a single label-only dropdown (a closed set).
// Paragraph + headings render as a single icon dropdown (a closed set); the
// trigger glyph reflects the current block level.
const HEADING_COMMANDS = EDITOR_COMMANDS . filter ( ( command ) = > command . group === "heading" ) ;
const HEADING_LEVEL_ICONS : Record < ToolbarHeadingLevel , LucideIcon > = { 1 : Heading1Icon , 2 : Heading2Icon , 3 : Heading3Icon } ;
interface ToolbarButton {
Icon? : LucideIcon ;
@ -33,12 +43,29 @@ interface ToolbarButton {
onClick : ( ) = > void ;
}
// Button styling: quiet ghost controls that sit directly on the editor surface
// (no filled track or border — that container read as a heavy slab). The active
// verb is the only filled element, so the toolbar recedes and the current state
// carries the weight. Kept as raw buttons (not the Button kit) because the idle
// hover + active treatment don't map to a single kit variant, and per policy a
// custom look is raw HTML rather than className overrides on the kit.
const SEGMENT_BASE =
"inline-flex items-center justify-center h-7 min-w-7 px-1.5 rounded-md text-sm transition-colors outline-none touch-manipulation focus-visible:ring-2 focus-visible:ring-ring" ;
const SEGMENT_IDLE = "text-muted-foreground hover:text-foreground hover:bg-foreground/5" ;
const SEGMENT_ACTIVE = "bg-accent text-accent-foreground" ;
// Command buttons must not take focus on mousedown — that blurs the editor and
// drops the selection the command targets. The click still fires and applies the
// format to the live selection.
const preventFocusSteal : MouseEventHandler < HTMLButtonElement > = ( event ) = > event . preventDefault ( ) ;
/ * *
* Focus - mode header : a rich - text formatting toolbar driven entirely through the
* editor controller ' s formatting capability . Every button is derived from the
* shared command catalog ( formatting / commands . ts ) , so adding a verb there
* surfaces it here automatically . Responsive : below COMPACT_TOOLBAR_WIDTH the
* list and link controls fold into a "more" menu while marks stay inline .
* Formatting toolbar : a lean inline row of the heading picker plus mark / list / link
* controls , every one derived from the shared command catalog
* ( formatting / commands . ts ) , so adding a verb there surfaces it here automatically .
* Groups are separated by thin vertical dividers . Responsive : below
* COMPACT_TOOLBAR_WIDTH the list and link controls fold into a "more" menu while
* marks stay inline . In focus mode an exit button is pushed to the far edge .
* /
export function FormattingToolbar ( { controllerRef , onExit , className } : FormattingToolbarProps ) {
const t = useTranslate ( ) ;
@ -49,6 +76,13 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
const run = ( id : EditorCommandId ) = > controllerRef . current ? . formatting ? . run ( id ) ;
// Menus grab focus while open and hand it back to their trigger on close; send
// it to the editor instead so the user can keep typing after a pick.
const returnFocusToEditor = ( event : Event ) = > {
event . preventDefault ( ) ;
controllerRef . current ? . focus ( ) ;
} ;
const handleLink = ( ) = > {
const formatting = controllerRef . current ? . formatting ;
if ( ! formatting ) {
@ -56,18 +90,19 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
}
if ( formatting . getActiveFormats ( ) . link ) {
formatting . run ( "link" ) ;
return ;
}
const url = window . prompt ( t ( "editor.format.link-prompt" ) ) ;
if ( ! url ) {
return ;
}
const selected = formatting . getSelectedText ( ) ;
if ( selected ) {
formatting . run ( "link" , { url } ) ;
} else {
controllerRef . current ? . insertMarkdown ( ` [ ${ url } ]( ${ url } ) ` ) ;
// window.prompt blurs the editor, so refocus below regardless of outcome.
const url = window . prompt ( t ( "editor.format.link-prompt" ) ) ;
if ( url ) {
const selected = formatting . getSelectedText ( ) ;
if ( selected ) {
formatting . run ( "link" , { url } ) ;
} else {
controllerRef . current ? . insertMarkdown ( ` [ ${ url } ]( ${ url } ) ` ) ;
}
}
}
controllerRef . current ? . focus ( ) ;
} ;
// Map a catalog command to a toolbar button. `link` keeps its bespoke
@ -79,7 +114,9 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
onClick : command.id === "link" ? handleLink : ( ) = > run ( command . id ) ,
} ) ;
const headingLabel = active . headingLevel === null ? t ( "editor.format.paragraph" ) : t ( ` editor.format.heading- ${ active . headingLevel } ` ) ;
// Pilcrow for paragraph, else the matching Hn glyph. Deeper levels (H4– H6)
// aren't toolbar-addressable and report as null, i.e. the pilcrow.
const HeadingGlyph = active . headingLevel === null ? PilcrowIcon : HEADING_LEVEL_ICONS [ active . headingLevel ] ;
const markButtons = MARK_COMMANDS . map ( toButton ) ;
const listButtons = LIST_COMMANDS . map ( toButton ) ;
const linkButton = toButton ( EDITOR_COMMANDS_BY_ID . link ) ;
@ -87,18 +124,15 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
return (
< div
ref = { rootRef }
className = { cn ( "w-full flex flex-row items-center gap- 1 flex-nowrap ", className ) }
className = { cn ( "w-full flex flex-row items-center gap- 0.5 ", className ) }
role = "toolbar"
aria - label = { t ( "editor.format.heading" ) }
>
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button variant = "ghost" size = "sm" aria - label = { t ( "editor.format.heading" ) } >
< HeadingIcon className = "w-4 h-4" / >
{ ! compact && < span className = "ml-1 text-xs" > { headingLabel } < / span > }
< / Button >
< SegmentButton Icon = { HeadingGlyph } label = { t ( "editor.format.heading" ) } / >
< / DropdownMenuTrigger >
< DropdownMenuContent align = "start" >
< DropdownMenuContent align = "start" onCloseAutoFocus = { returnFocusToEditor } >
{ HEADING_COMMANDS . map ( ( command ) = > (
< DropdownMenuItem key = { command . id } onClick = { ( ) = > run ( command . id ) } >
{ t ( command . labelKey ) }
@ -110,17 +144,17 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
< Divider / >
{ markButtons . map ( ( button ) = > (
< Fm tButton key = { button . label } { ...button } / >
< Segmen tButton key = { button . label } { ...button } onMouseDown = { preventFocusSteal } / >
) ) }
< Divider / >
{ compact ? (
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button variant = "ghost" size = "icon" aria - label = { t ( "editor.format.more" ) } >
< MoreHorizontalIcon className = "w-4 h-4" / >
< / Button >
< SegmentButton Icon = { MoreHorizontalIcon } label = { t ( "editor.format.more" ) } / >
< / DropdownMenuTrigger >
< DropdownMenuContent align = "start" >
< DropdownMenuContent align = "start" onCloseAutoFocus = { returnFocusToEditor } >
{ listButtons . map ( ( button ) = > (
< DropdownMenuItem key = { button . label } onClick = { button . onClick } >
{ button . label }
@ -132,34 +166,52 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
< / DropdownMenu >
) : (
< >
< Divider / >
{ listButtons . map ( ( button ) = > (
< Fm tButton key = { button . label } { ...button } / >
< Segmen tButton key = { button . label } { ...button } onMouseDown = { preventFocusSteal } / >
) ) }
< Divider / >
< FmtButton { ...linkButton } / >
< SegmentButton { ...linkButton } onMouseDown = { preventFocusSteal } / >
< / >
) }
< div className = "flex-1" / >
< Button variant = "ghost" size = "icon" aria - label = { t ( "editor.exit-focus-mode" ) } title = { t ( "editor.exit-focus-mode" ) } onClick = { onExit } >
< Minimize2Icon className = "w-4 h-4" / >
< / Button >
{ onExit && (
< >
< div className = "flex-1" / >
< Button variant = "ghost" size = "icon" aria - label = { t ( "editor.exit-focus-mode" ) } title = { t ( "editor.exit-focus-mode" ) } onClick = { onExit } >
< Minimize2Icon className = "w-4 h-4" / >
< / Button >
< / >
) }
< / div >
) ;
}
// Thin vertical rule between command groups (heading · marks · lists · link).
function Divider() {
return < span aria - hidden = "true" className = "w-px h-5 bg-border mx-1 shrink-0" / > ;
return < span aria - hidden = "true" className = "w-px h-5 bg-border mx-1 .5 shrink-0" / > ;
}
function FmtButton ( { Icon , active , label , onClick } : ToolbarButton ) {
// Active state shown by switching the kit variant (prop-only, per the shadcn
// kit usage policy) rather than overriding className.
return (
< Button variant = { active ? "secondary" : "ghost" } size = "icon" aria - label = { label } aria - pressed = { active } title = { label } onClick = { onClick } >
{ Icon && < Icon className = "w-4 h-4" / > }
< / Button >
) ;
interface SegmentButtonProps extends ComponentPropsWithoutRef < "button" > {
Icon? : LucideIcon ;
label : string ;
/** Toggle state; when set the segment gets aria-pressed and the active fill. */
active? : boolean ;
}
// The one segment element, shared by command toggles and dropdown triggers.
// Forwards ref + rest props so it also works as a Radix `asChild` trigger
// (which injects its own onClick/aria attributes).
const SegmentButton = forwardRef < HTMLButtonElement , SegmentButtonProps > ( ( { Icon , label , active , className , . . . rest } , ref ) = > (
< button
ref = { ref }
type = "button"
aria - label = { label }
aria - pressed = { active }
title = { label }
className = { cn ( SEGMENT_BASE , active ? SEGMENT_ACTIVE : SEGMENT_IDLE , className ) }
{ . . . rest }
>
{ Icon && < Icon className = "w-4 h-4" / > }
< / button >
) ) ;
SegmentButton . displayName = "SegmentButton" ;