Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,100 @@ describe(
);
});
});

it("4. Verify applying a font family from the toolbar writes font-family into the editor HTML", function () {
let htmlBefore = "";

cy.window().then((win) => {
htmlBefore = win.tinymce.activeEditor.getContent().toLowerCase();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
expect(htmlBefore).to.not.contain("font-family");
});
agHelper.GetNClick(locators._richText_FontFamily);
agHelper.GetNClick(locators._richText_FontFamilyOption("Arial"));
agHelper
.GetElement(
locators._widgetInDeployed("richtexteditorwidget") + " iframe",
)
.then(($iframe) => {
const $body = $iframe.contents().find("body");

return agHelper.TypeText($body, "ArialText");
});
cy.window().then((win) => {
const htmlAfter = win.tinymce.activeEditor.getContent().toLowerCase();

expect(htmlAfter).to.not.equal(htmlBefore);
expect(htmlAfter).to.contain("font-family");
expect(htmlAfter).to.contain("arial");
});
});

it("5. Verify choosing a font with a collapsed caret applies to the next typed text", function () {
cy.window().then((win) => {
const editor = win.tinymce.activeEditor;

editor.focus();
editor.selection.select(editor.getBody(), true);
editor.selection.collapse(false);
expect(editor.getContent().toLowerCase()).to.not.contain("georgia");
});
agHelper.GetNClick(locators._richText_FontFamily);
agHelper.GetNClick(locators._richText_FontFamilyOption("Georgia"));
cy.get(locators._richText_FontFamily).should(
"have.attr",
"aria-label",
"Font Georgia",
);
cy.window().then((win) => {
expect(
win.tinymce.activeEditor.getContent().toLowerCase(),
).to.not.contain("georgia");
});
agHelper
.GetElement(
locators._widgetInDeployed("richtexteditorwidget") + " iframe",
)
.then(($iframe) => {
const $body = $iframe.contents().find("body");

return agHelper.TypeText($body, "GeorgiaText");
});
cy.window().then((win) => {
expect(win.tinymce.activeEditor.getContent().toLowerCase()).to.contain(
"georgia",
);
});
});

it("6. Verify moving the caret after picking a font does not apply it at the new location", function () {
cy.window().then((win) => {
const editor = win.tinymce.activeEditor;

editor.focus();
editor.selection.select(editor.getBody(), true);
editor.selection.collapse(false);
expect(editor.getContent().toLowerCase()).to.not.contain("courier");
});
agHelper.GetNClick(locators._richText_FontFamily);
agHelper.GetNClick(locators._richText_FontFamilyOption("Courier New"));
cy.get(locators._richText_FontFamily).should(
"have.attr",
"aria-label",
"Font Courier New",
);
cy.window().then((win) => {
const editor = win.tinymce.activeEditor;
const body = editor.getBody();

expect(editor.getContent().toLowerCase()).to.not.contain("courier");
// Stay collapsed: select-all would clear pending for a different reason.
editor.selection.setCursorLocation(body.firstChild || body, 0);
});
cy.window().then((win) => {
expect(
win.tinymce.activeEditor.getContent().toLowerCase(),
).to.not.contain("courier");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ describe(
// Set the content inside RTE widget by typing
setRTEContent(`${testString} {enter} ${testString} 1`);

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

cy.window().then((win) => {
Expand Down
4 changes: 4 additions & 0 deletions app/client/cypress/support/Objects/CommonLocators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,10 @@ export class CommonLocators {
_richText_TitleBlock = "[aria-label='Block Paragraph']";
_richText_Heading = "[aria-label='Heading 1']";
_richText_Label_Text = ".tox-tbtn__select-label";
// TinyMCE 7.9.3: data-mce-name is stable; aria-label is "Font {current}" (default "Font System Font")
_richText_FontFamily = "[data-mce-name='fontfamily']";
_richText_FontFamilyOption = (font: string) =>
`.tox-collection__item[aria-label="${font}"]`;
_richText_Text_Color = (color: string) =>
`[aria-label="Text color ${color}"] .tox-split-button__chevron`;
_richText_color = (value: string) =>
Expand Down
179 changes: 178 additions & 1 deletion app/client/src/widgets/RichTextEditorWidget/component/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,33 @@ export interface RichtextEditorComponentProps {
onValueChange: (valueAsString: string) => void;
}

function titleForFontFamilyFormat(formats: string, format: string): string {
for (const entry of formats.split(";")) {
const separator = entry.indexOf("=");

if (separator === -1) {
continue;
}

if (entry.slice(separator + 1).trim() === format) {
return entry.slice(0, separator).trim();
}
}

return format.split(",")[0]?.trim() ?? format;
}

const FONT_CARET_NAVIGATION_KEYS = new Set([
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
]);

function RichtextEditorComponent(props: RichtextEditorComponentProps) {
const {
compactMode,
Expand All @@ -350,7 +377,27 @@ function RichtextEditorComponent(props: RichtextEditorComponentProps) {
const initialRender = useRef(true);

const toolbarConfig =
"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";
"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";

// TinyMCE 7.9.3 default minus Symbol/Webdings/Wingdings, plus Default
// mapped to the iframe UA serif (Times) so existing apps keep the same
// look and the dropdown has a real selected option.
const fontFamilyFormats =
"Default=times,times new roman,serif;" +
"Andale Mono=andale mono,monospace;" +
"Arial=arial,helvetica,sans-serif;" +
"Arial Black=arial black,sans-serif;" +
"Book Antiqua=book antiqua,palatino,serif;" +
"Comic Sans MS=comic sans ms,sans-serif;" +
"Courier New=courier new,courier,monospace;" +
"Georgia=georgia,palatino,serif;" +
"Helvetica=helvetica,arial,sans-serif;" +
"Impact=impact,sans-serif;" +
"Tahoma=tahoma,arial,helvetica,sans-serif;" +
"Terminal=terminal,monaco,monospace;" +
"Times New Roman=times new roman,times,serif;" +
"Trebuchet MS=trebuchet ms,geneva,sans-serif;" +
"Verdana=verdana,geneva,sans-serif";

const handleEditorChange = useCallback(
// TODO: Fix this the next time the file is edited
Expand Down Expand Up @@ -429,6 +476,7 @@ function RichtextEditorComponent(props: RichtextEditorComponentProps) {
forced_root_block: "p",
branding: false,
resize: false,
font_family_formats: fontFamilyFormats,
browser_spellcheck: true,
convert_unsafe_embeds: true,
sandbox_iframes: true,
Expand Down Expand Up @@ -493,11 +541,140 @@ function RichtextEditorComponent(props: RichtextEditorComponentProps) {
: [];
},
});
// Collapsed FontName is a caret format. Closing the toolbar menu
// restores the pre-menu bookmark (Times) and NodeChange then
// overwrites the dropdown. Keep the pick as pending only while
// the caret stays at that same text offset — a click, arrow
// key, or programmatic move must drop it so we do not restyle
// an unrelated location.
let pendingFontFamily: string | null = null;
let pendingFontTitle: string | null = null;
let pendingCaretOffset: number | null = null;
let applyingPendingFont = false;

const firstFamily = (font: string) =>
font.split(",")[0].trim().replace(/['"]/g, "").toLowerCase();

const collapsedTextOffset = () => {
const rng = editor.selection.getRng();
const body = editor.getBody();

if (!body) {
return 0;
}

try {
const probe = rng.cloneRange();

probe.selectNodeContents(body);
probe.setEnd(rng.startContainer, rng.startOffset);

return probe.toString().replace(/[\uFEFF\u200B]/g, "").length;
} catch {
return pendingCaretOffset ?? 0;
}
};

const clearPendingFont = () => {
pendingFontFamily = null;
pendingFontTitle = null;
pendingCaretOffset = null;
};

const pendingFontIsActive = () => {
if (!pendingFontFamily) {
return true;
}

const current = (
editor.queryCommandValue("FontName") || ""
).toLowerCase();

return current.includes(firstFamily(pendingFontFamily));
};

const paintPendingFontLabel = () => {
if (!pendingFontTitle) {
return;
}

const button = editor
.getContainer()
?.querySelector("[data-mce-name='fontfamily']");
const label = button?.querySelector(".tox-tbtn__select-label");

if (label) {
label.textContent = pendingFontTitle;
}

button?.setAttribute("aria-label", `Font ${pendingFontTitle}`);
};

const ensurePendingFont = () => {
if (
applyingPendingFont ||
editor.removed ||
!pendingFontFamily
) {
return;
}

if (!editor.selection.isCollapsed()) {
clearPendingFont();

return;
}

if (
pendingCaretOffset !== null &&
collapsedTextOffset() !== pendingCaretOffset
) {
clearPendingFont();

return;
}

if (!pendingFontIsActive()) {
applyingPendingFont = true;
editor.formatter.apply("fontname", {
value: pendingFontFamily,
});
applyingPendingFont = false;
}

paintPendingFontLabel();
};

editor.on("BeforeExecCommand", (event) => {
if (event.command !== "FontName" || !event.value) {
return;
}

pendingFontFamily = String(event.value);
pendingFontTitle = titleForFontFamilyFormat(
fontFamilyFormats,
pendingFontFamily,
);
pendingCaretOffset = collapsedTextOffset();
});
editor.on("NodeChange", () => {
setTimeout(ensurePendingFont, 0);
});
editor.on("mousedown", clearPendingFont);
editor.on("keydown", (event) => {
if (FONT_CARET_NAVIGATION_KEYS.has(event.key)) {
clearPendingFont();
}
});
},
}}
key={`editor_${props.isToolbarHidden}_${props.isDisabled}`}
licenseKey="gpl"
onEditorChange={handleEditorChange}
// Local `value` is not a veto. tinymce-react's rollback would
// setContent() 200ms later and wipe caret formats; WDS RTE
// disables it for the same contract.
rollback={false}
toolbar={props.isToolbarHidden ? false : toolbarConfig}
value={editorValue}
/>
Expand Down
Loading