@@ -331,6 +331,33 @@ export interface RichtextEditorComponentProps {
331331 onValueChange : ( valueAsString : string ) => void ;
332332}
333333
334+ function titleForFontFamilyFormat ( formats : string , format : string ) : string {
335+ for ( const entry of formats . split ( ";" ) ) {
336+ const separator = entry . indexOf ( "=" ) ;
337+
338+ if ( separator === - 1 ) {
339+ continue ;
340+ }
341+
342+ if ( entry . slice ( separator + 1 ) . trim ( ) === format ) {
343+ return entry . slice ( 0 , separator ) . trim ( ) ;
344+ }
345+ }
346+
347+ return format . split ( "," ) [ 0 ] ?. trim ( ) ?? format ;
348+ }
349+
350+ const FONT_CARET_NAVIGATION_KEYS = new Set ( [
351+ "ArrowLeft" ,
352+ "ArrowRight" ,
353+ "ArrowUp" ,
354+ "ArrowDown" ,
355+ "Home" ,
356+ "End" ,
357+ "PageUp" ,
358+ "PageDown" ,
359+ ] ) ;
360+
334361function RichtextEditorComponent ( props : RichtextEditorComponentProps ) {
335362 const {
336363 compactMode,
@@ -350,7 +377,27 @@ function RichtextEditorComponent(props: RichtextEditorComponentProps) {
350377 const initialRender = useRef ( true ) ;
351378
352379 const toolbarConfig =
353- "insertfile undo redo | blocks | bold italic underline backcolor forecolor | lineheight | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image | removeformat | table | preview media | emoticons | code | help" ;
380+ "insertfile undo redo | blocks | fontfamily | bold italic underline backcolor forecolor | lineheight | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image | removeformat | table | preview media | emoticons | code | help" ;
381+
382+ // TinyMCE 7.9.3 default minus Symbol/Webdings/Wingdings, plus Default
383+ // mapped to the iframe UA serif (Times) so existing apps keep the same
384+ // look and the dropdown has a real selected option.
385+ const fontFamilyFormats =
386+ "Default=times,times new roman,serif;" +
387+ "Andale Mono=andale mono,monospace;" +
388+ "Arial=arial,helvetica,sans-serif;" +
389+ "Arial Black=arial black,sans-serif;" +
390+ "Book Antiqua=book antiqua,palatino,serif;" +
391+ "Comic Sans MS=comic sans ms,sans-serif;" +
392+ "Courier New=courier new,courier,monospace;" +
393+ "Georgia=georgia,palatino,serif;" +
394+ "Helvetica=helvetica,arial,sans-serif;" +
395+ "Impact=impact,sans-serif;" +
396+ "Tahoma=tahoma,arial,helvetica,sans-serif;" +
397+ "Terminal=terminal,monaco,monospace;" +
398+ "Times New Roman=times new roman,times,serif;" +
399+ "Trebuchet MS=trebuchet ms,geneva,sans-serif;" +
400+ "Verdana=verdana,geneva,sans-serif" ;
354401
355402 const handleEditorChange = useCallback (
356403 // TODO: Fix this the next time the file is edited
@@ -429,6 +476,7 @@ function RichtextEditorComponent(props: RichtextEditorComponentProps) {
429476 forced_root_block : "p" ,
430477 branding : false ,
431478 resize : false ,
479+ font_family_formats : fontFamilyFormats ,
432480 browser_spellcheck : true ,
433481 convert_unsafe_embeds : true ,
434482 sandbox_iframes : true ,
@@ -493,11 +541,140 @@ function RichtextEditorComponent(props: RichtextEditorComponentProps) {
493541 : [ ] ;
494542 } ,
495543 } ) ;
544+ // Collapsed FontName is a caret format. Closing the toolbar menu
545+ // restores the pre-menu bookmark (Times) and NodeChange then
546+ // overwrites the dropdown. Keep the pick as pending only while
547+ // the caret stays at that same text offset — a click, arrow
548+ // key, or programmatic move must drop it so we do not restyle
549+ // an unrelated location.
550+ let pendingFontFamily : string | null = null ;
551+ let pendingFontTitle : string | null = null ;
552+ let pendingCaretOffset : number | null = null ;
553+ let applyingPendingFont = false ;
554+
555+ const firstFamily = ( font : string ) =>
556+ font . split ( "," ) [ 0 ] . trim ( ) . replace ( / [ ' " ] / g, "" ) . toLowerCase ( ) ;
557+
558+ const collapsedTextOffset = ( ) => {
559+ const rng = editor . selection . getRng ( ) ;
560+ const body = editor . getBody ( ) ;
561+
562+ if ( ! body ) {
563+ return 0 ;
564+ }
565+
566+ try {
567+ const probe = rng . cloneRange ( ) ;
568+
569+ probe . selectNodeContents ( body ) ;
570+ probe . setEnd ( rng . startContainer , rng . startOffset ) ;
571+
572+ return probe . toString ( ) . replace ( / [ \uFEFF \u200B ] / g, "" ) . length ;
573+ } catch {
574+ return pendingCaretOffset ?? 0 ;
575+ }
576+ } ;
577+
578+ const clearPendingFont = ( ) => {
579+ pendingFontFamily = null ;
580+ pendingFontTitle = null ;
581+ pendingCaretOffset = null ;
582+ } ;
583+
584+ const pendingFontIsActive = ( ) => {
585+ if ( ! pendingFontFamily ) {
586+ return true ;
587+ }
588+
589+ const current = (
590+ editor . queryCommandValue ( "FontName" ) || ""
591+ ) . toLowerCase ( ) ;
592+
593+ return current . includes ( firstFamily ( pendingFontFamily ) ) ;
594+ } ;
595+
596+ const paintPendingFontLabel = ( ) => {
597+ if ( ! pendingFontTitle ) {
598+ return ;
599+ }
600+
601+ const button = editor
602+ . getContainer ( )
603+ ?. querySelector ( "[data-mce-name='fontfamily']" ) ;
604+ const label = button ?. querySelector ( ".tox-tbtn__select-label" ) ;
605+
606+ if ( label ) {
607+ label . textContent = pendingFontTitle ;
608+ }
609+
610+ button ?. setAttribute ( "aria-label" , `Font ${ pendingFontTitle } ` ) ;
611+ } ;
612+
613+ const ensurePendingFont = ( ) => {
614+ if (
615+ applyingPendingFont ||
616+ editor . removed ||
617+ ! pendingFontFamily
618+ ) {
619+ return ;
620+ }
621+
622+ if ( ! editor . selection . isCollapsed ( ) ) {
623+ clearPendingFont ( ) ;
624+
625+ return ;
626+ }
627+
628+ if (
629+ pendingCaretOffset !== null &&
630+ collapsedTextOffset ( ) !== pendingCaretOffset
631+ ) {
632+ clearPendingFont ( ) ;
633+
634+ return ;
635+ }
636+
637+ if ( ! pendingFontIsActive ( ) ) {
638+ applyingPendingFont = true ;
639+ editor . formatter . apply ( "fontname" , {
640+ value : pendingFontFamily ,
641+ } ) ;
642+ applyingPendingFont = false ;
643+ }
644+
645+ paintPendingFontLabel ( ) ;
646+ } ;
647+
648+ editor . on ( "BeforeExecCommand" , ( event ) => {
649+ if ( event . command !== "FontName" || ! event . value ) {
650+ return ;
651+ }
652+
653+ pendingFontFamily = String ( event . value ) ;
654+ pendingFontTitle = titleForFontFamilyFormat (
655+ fontFamilyFormats ,
656+ pendingFontFamily ,
657+ ) ;
658+ pendingCaretOffset = collapsedTextOffset ( ) ;
659+ } ) ;
660+ editor . on ( "NodeChange" , ( ) => {
661+ setTimeout ( ensurePendingFont , 0 ) ;
662+ } ) ;
663+ editor . on ( "mousedown" , clearPendingFont ) ;
664+ editor . on ( "keydown" , ( event ) => {
665+ if ( FONT_CARET_NAVIGATION_KEYS . has ( event . key ) ) {
666+ clearPendingFont ( ) ;
667+ }
668+ } ) ;
496669 } ,
497670 } }
498671 key = { `editor_${ props . isToolbarHidden } _${ props . isDisabled } ` }
499672 licenseKey = "gpl"
500673 onEditorChange = { handleEditorChange }
674+ // Local `value` is not a veto. tinymce-react's rollback would
675+ // setContent() 200ms later and wipe caret formats; WDS RTE
676+ // disables it for the same contract.
677+ rollback = { false }
501678 toolbar = { props . isToolbarHidden ? false : toolbarConfig }
502679 value = { editorValue }
503680 />
0 commit comments