Skip to content

Commit 57c8986

Browse files
committed
feat: add font family control to the RTE toolbar
1 parent cda4e09 commit 57c8986

4 files changed

Lines changed: 278 additions & 2 deletions

File tree

app/client/cypress/e2e/Regression/ClientSide/Widgets/RTE/RichTextEditor3_spec.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,5 +84,100 @@ describe(
8484
);
8585
});
8686
});
87+
88+
it("4. Verify applying a font family from the toolbar writes font-family into the editor HTML", function () {
89+
let htmlBefore = "";
90+
91+
cy.window().then((win) => {
92+
htmlBefore = win.tinymce.activeEditor.getContent().toLowerCase();
93+
expect(htmlBefore).to.not.contain("font-family");
94+
});
95+
agHelper.GetNClick(locators._richText_FontFamily);
96+
agHelper.GetNClick(locators._richText_FontFamilyOption("Arial"));
97+
agHelper
98+
.GetElement(
99+
locators._widgetInDeployed("richtexteditorwidget") + " iframe",
100+
)
101+
.then(($iframe) => {
102+
const $body = $iframe.contents().find("body");
103+
104+
return agHelper.TypeText($body, "ArialText");
105+
});
106+
cy.window().then((win) => {
107+
const htmlAfter = win.tinymce.activeEditor.getContent().toLowerCase();
108+
109+
expect(htmlAfter).to.not.equal(htmlBefore);
110+
expect(htmlAfter).to.contain("font-family");
111+
expect(htmlAfter).to.contain("arial");
112+
});
113+
});
114+
115+
it("5. Verify choosing a font with a collapsed caret applies to the next typed text", function () {
116+
cy.window().then((win) => {
117+
const editor = win.tinymce.activeEditor;
118+
119+
editor.focus();
120+
editor.selection.select(editor.getBody(), true);
121+
editor.selection.collapse(false);
122+
expect(editor.getContent().toLowerCase()).to.not.contain("georgia");
123+
});
124+
agHelper.GetNClick(locators._richText_FontFamily);
125+
agHelper.GetNClick(locators._richText_FontFamilyOption("Georgia"));
126+
cy.get(locators._richText_FontFamily).should(
127+
"have.attr",
128+
"aria-label",
129+
"Font Georgia",
130+
);
131+
cy.window().then((win) => {
132+
expect(
133+
win.tinymce.activeEditor.getContent().toLowerCase(),
134+
).to.not.contain("georgia");
135+
});
136+
agHelper
137+
.GetElement(
138+
locators._widgetInDeployed("richtexteditorwidget") + " iframe",
139+
)
140+
.then(($iframe) => {
141+
const $body = $iframe.contents().find("body");
142+
143+
return agHelper.TypeText($body, "GeorgiaText");
144+
});
145+
cy.window().then((win) => {
146+
expect(win.tinymce.activeEditor.getContent().toLowerCase()).to.contain(
147+
"georgia",
148+
);
149+
});
150+
});
151+
152+
it("6. Verify moving the caret after picking a font does not apply it at the new location", function () {
153+
cy.window().then((win) => {
154+
const editor = win.tinymce.activeEditor;
155+
156+
editor.focus();
157+
editor.selection.select(editor.getBody(), true);
158+
editor.selection.collapse(false);
159+
expect(editor.getContent().toLowerCase()).to.not.contain("courier");
160+
});
161+
agHelper.GetNClick(locators._richText_FontFamily);
162+
agHelper.GetNClick(locators._richText_FontFamilyOption("Courier New"));
163+
cy.get(locators._richText_FontFamily).should(
164+
"have.attr",
165+
"aria-label",
166+
"Font Courier New",
167+
);
168+
cy.window().then((win) => {
169+
const editor = win.tinymce.activeEditor;
170+
const body = editor.getBody();
171+
172+
expect(editor.getContent().toLowerCase()).to.not.contain("courier");
173+
// Stay collapsed: select-all would clear pending for a different reason.
174+
editor.selection.setCursorLocation(body.firstChild || body, 0);
175+
});
176+
cy.window().then((win) => {
177+
expect(
178+
win.tinymce.activeEditor.getContent().toLowerCase(),
179+
).to.not.contain("courier");
180+
});
181+
});
87182
},
88183
);

app/client/cypress/e2e/Regression/ClientSide/Widgets/RTE/RichTextEditor_2_spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ describe(
118118
// Set the content inside RTE widget by typing
119119
setRTEContent(`${testString} {enter} ${testString} 1`);
120120

121-
cy.get(".tox-tbtn--bespoke").click({ force: true });
121+
cy.get(locators._richText_TitleBlock).click({ force: true });
122122
cy.contains("Heading 1").click({ force: true });
123123

124124
cy.window().then((win) => {

app/client/cypress/support/Objects/CommonLocators.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,10 @@ export class CommonLocators {
318318
_richText_TitleBlock = "[aria-label='Block Paragraph']";
319319
_richText_Heading = "[aria-label='Heading 1']";
320320
_richText_Label_Text = ".tox-tbtn__select-label";
321+
// TinyMCE 7.9.3: data-mce-name is stable; aria-label is "Font {current}" (default "Font System Font")
322+
_richText_FontFamily = "[data-mce-name='fontfamily']";
323+
_richText_FontFamilyOption = (font: string) =>
324+
`.tox-collection__item[aria-label="${font}"]`;
321325
_richText_Text_Color = (color: string) =>
322326
`[aria-label="Text color ${color}"] .tox-split-button__chevron`;
323327
_richText_color = (value: string) =>

app/client/src/widgets/RichTextEditorWidget/component/index.tsx

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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+
334361
function 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

Comments
 (0)