Repository: odoo-dev/odoo
Pull request #4159: builder font family picker
Author: login: bso-odoo
Created: 2025-03-06T12:02:59Z | Merged: 2025-03-11T13:10:39Z



Diff:
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_fontfamilypicker.js b/addons/html_builder/static/src/core/building_blocks/builder_fontfamilypicker.js
new file mode 100644
index 0000000000000..cec2dbef6365c
--- /dev/null
+++ b/addons/html_builder/static/src/core/building_blocks/builder_fontfamilypicker.js
@@ -0,0 +1,81 @@
+import { Component, onMounted, onWillStart, useSubEnv } from "@odoo/owl";
+import { _t } from "@web/core/l10n/translation";
+import { useService } from "@web/core/utils/hooks";
+import {
+    basicContainerBuilderComponentProps,
+    useVisibilityObserver,
+    useApplyVisibility,
+    useSelectableComponent,
+} from "./utils";
+import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
+import { BuilderSelect } from "./builder_select";
+import { BuilderSelectItem } from "./builder_select_item";
+
+export class BuilderFontFamilyPicker extends Component {
+    static template = "html_builder.website.BuilderFontFamilyPicker";
+    static props = {
+        ...basicContainerBuilderComponentProps,
+        id: { type: String, optional: true },
+        valueParamName: String,
+    };
+    static components = {
+        BuilderSelect,
+        BuilderSelectItem,
+    };
+
+    setup() {
+        this.dialog = useService("dialog");
+        this.orm = useService("orm");
+        useVisibilityObserver("content", useApplyVisibility("root"));
+        useSelectableComponent(this.props.id, {
+            /*
+            onItemChange(item) {
+                currentLabel = item.getLabel();
+                updateCurrentLabel();
+            },
+            */
+        });
+        onMounted(() => {});
+        useSubEnv({
+            /*
+            onSelectItem: () => {
+                this.dropdown.close();
+            },
+            */
+        });
+        this.fonts = [];
+        onWillStart(async () => {
+            const fontsData = await this.env.editor.shared.websiteFont.getFontsData();
+            this.fonts = fontsData._fonts;
+        });
+    }
+    getAllClasses() {
+        return "TODO";
+    }
+    forwardProps(fontValue) {
+        const result = Object.assign({}, this.props, {
+            [this.props.valueParamName]: fontValue.fontFamily,
+        });
+        delete result.selectMethod;
+        delete result.valueParamName;
+        return result;
+    }
+    async onAddFontClick() {
+        await this.env.editor.shared.websiteFont.addFont();
+    }
+    async onDeleteFontClick(font) {
+        const save = await new Promise((resolve) => {
+            this.env.services.dialog.add(ConfirmationDialog, {
+                body: _t(
+                    "Deleting a font requires a reload of the page. This will save all your changes and reload the page, are you sure you want to proceed?"
+                ),
+                confirm: () => resolve(true),
+                cancel: () => resolve(false),
+            });
+        });
+        if (!save) {
+            return;
+        }
+        await this.env.editor.shared.websiteFont.deleteFont(font);
+    }
+}
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_fontfamilypicker.xml b/addons/html_builder/static/src/core/building_blocks/builder_fontfamilypicker.xml
new file mode 100644
index 0000000000000..cd9c2201d5d6a
--- /dev/null
+++ b/addons/html_builder/static/src/core/building_blocks/builder_fontfamilypicker.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+<t t-name="html_builder.website.BuilderFontFamilyPicker">
+    <BuilderSelect className="getAllClasses()">
+    <!-- TODO Can't t-att on component: t-att="getAllDataAttributes()" -->
+        <t t-foreach="fonts" t-as="font" t-key="font_index">
+            <BuilderSelectItem t-props="forwardProps(font)">
+                <div class="d-flex justify-content-between">
+                    <span t-attf-style="font-family: {{font.fontFamily}};">
+                        <i t-if="font.type === 'cloud'" role="button" class="text-info me-2 fa fa-cloud" title="This font is hosted and served to your visitors by Google servers"></i>
+                        <t t-out="font.string"/>
+                    </span>
+                    <div class="text-end">
+                        <t t-if="font.indexForType >= 0">
+                            <t t-set="delete_font_title">Delete this font</t>
+                            <i role="button"
+                               t-on-click.prevent.stop="() => this.onDeleteFontClick(font)"
+                               class="link-danger fa fa-trash-o o_we_delete_font_btn"
+                               t-att-aria-label="delete_font_title"
+                               t-att-title="delete_font_title"/>
+                        </t>
+                    </div>
+                </div>
+            </BuilderSelectItem>
+        </t>
+        <div class="d-flex flex-column cursor-pointer o-dropdown-item dropdown-item o-navigable o_we_add_font_btn"
+            role="menuitem"
+            tabindex="0"
+            t-on-click.stop="() => this.onAddFontClick()"
+        >
+            Add a Custom Font
+        </div>
+    </BuilderSelect>
+</t>
+
+</templates>
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_select.js b/addons/html_builder/static/src/core/building_blocks/builder_select.js
index 57dae671c7d95..b220d6e4a6529 100644
--- a/addons/html_builder/static/src/core/building_blocks/builder_select.js
+++ b/addons/html_builder/static/src/core/building_blocks/builder_select.js
@@ -15,6 +15,7 @@ export class BuilderSelect extends Component {
     static props = {
         ...basicContainerBuilderComponentProps,
         id: { type: String, optional: true },
+        className: { type: String, optional: true },
         slots: Object,
     };
     static components = {
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_select.xml b/addons/html_builder/static/src/core/building_blocks/builder_select.xml
index dd306b4d83d38..c39de3728490c 100644
--- a/addons/html_builder/static/src/core/building_blocks/builder_select.xml
+++ b/addons/html_builder/static/src/core/building_blocks/builder_select.xml
@@ -6,7 +6,7 @@
         <!-- Render the SelectItem(s) into an invisible node to ensure the label of the
         button is being set. -->
         <div t-ref="root" style="overflow: hidden;">
-            <div class="d-none" t-ref="content"><t t-slot="default" /></div>
+            <div t-att-class="'d-none ' + props.className" t-ref="content"><t t-slot="default" /></div>
             <Dropdown state="this.dropdown">
                 <button class="btn btn-primary" t-ref="button" style="width: 100%; overflow: hidden; text-overflow: ellipsis;" />
                 <t t-set-slot="content">
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_select_item.js b/addons/html_builder/static/src/core/building_blocks/builder_select_item.js
index 11cee1b6b0e70..50b31239bc14f 100644
--- a/addons/html_builder/static/src/core/building_blocks/builder_select_item.js
+++ b/addons/html_builder/static/src/core/building_blocks/builder_select_item.js
@@ -9,6 +9,7 @@ export class BuilderSelectItem extends Component {
         ...clickableBuilderComponentProps,
         id: { type: String, optional: true },
         title: { type: String, optional: true },
+        className: { type: String, optional: true },
         slots: { type: Object, optional: true },
     };
     static components = { BuilderComponent };
diff --git a/addons/html_builder/static/src/core/default_builder_components.js b/addons/html_builder/static/src/core/default_builder_components.js
index f39a24499eff5..818d309fa72a9 100644
--- a/addons/html_builder/static/src/core/default_builder_components.js
+++ b/addons/html_builder/static/src/core/default_builder_components.js
@@ -15,6 +15,8 @@ import { BuilderContext } from "./building_blocks/builder_context";
 import { BasicMany2Many } from "./building_blocks/basic_many2many";
 import { BuilderMany2Many } from "./building_blocks/builder_many2many";
 import { BuilderUrlPicker } from "./building_blocks/builder_urlpicker";
+// TODO Font family picker is actually website-specific
+import { BuilderFontFamilyPicker } from "./building_blocks/builder_fontfamilypicker";
 import { ModelMany2Many } from "./building_blocks/model_many2many";
 
 export const defaultBuilderComponents = {
@@ -36,4 +38,5 @@ export const defaultBuilderComponents = {
     ModelMany2Many,
     BuilderDateTimePicker,
     BuilderUrlPicker,
+    BuilderFontFamilyPicker,
 };
diff --git a/addons/html_builder/static/src/sidebar/theme_tab.js b/addons/html_builder/static/src/sidebar/theme_tab.js
index 33af2ac684ce1..0fcd233ddef17 100644
--- a/addons/html_builder/static/src/sidebar/theme_tab.js
+++ b/addons/html_builder/static/src/sidebar/theme_tab.js
@@ -16,7 +16,9 @@ export class ThemeTab extends Component {
     setup() {
         useOptionsSubEnv(() => [this.env.editor.document.body]);
         this.isActiveItem = useIsActiveItem();
-        this.state = useState({});
+        this.state = useState({
+            fontsData: {},
+        });
         this.optionsContainers = this.env.editor.resources["theme_options"];
     }
 }
diff --git a/addons/html_builder/static/src/sidebar/theme_tab.xml b/addons/html_builder/static/src/sidebar/theme_tab.xml
index 9aede5d10c247..166e56019e69c 100644
--- a/addons/html_builder/static/src/sidebar/theme_tab.xml
+++ b/addons/html_builder/static/src/sidebar/theme_tab.xml
@@ -82,11 +82,6 @@
                 <BuilderButton title.translate="Pattern" actionValue="'pattern'" className="'fa fa-fw fa-th'"/>
                 <BuilderButton title.translate="None" actionValue="'NONE'" className="'fa fa-fw fa-ban'"/>
             </BuilderButtonGroup>
-            <!-- Hidden imagepicker enabled by above button-group -->
-            <!--
-            <we-imagepicker data-name="body_bg_image_opt"
-                            data-customize-body-bg=""/>
-            -->
         </BuilderRow>
     </BuilderContext>
 </t>
@@ -102,10 +97,7 @@
             </t>
         </BuilderRow>
         <BuilderRow label.translate="Font Family">
-            TODO BuilderFontFamilyPicker
-            <!--
-            <we-fontfamilypicker data-variable="font"/>
-            -->
+            <BuilderFontFamilyPicker action="'customizeWebsiteVariable'" actionParam="'font'" valueParamName="'actionValue'"/>
         </BuilderRow>
         <BuilderRow label.translate="Line Height">
             <!-- "× ": \u00D7\u2000 -->
@@ -139,11 +131,8 @@
                 </t>
             </t>
         </BuilderRow>
-        <BuilderRow label.translate="Font Family" action="'customizeWebsiteVariable'">
-            TODO BuilderFontFamilyPicker
-            <!--
-            <BuilderFontFamilyPicker title.translate="Heading 1" actionParam="'headings-font'"/>
-            -->
+        <BuilderRow label.translate="Font Family" tooltip.translate="Heading 1" action="'customizeWebsiteVariable'">
+            <BuilderFontFamilyPicker actionParam="'headings-font'" valueParamName="'actionValue'"/>
             <BuilderButton className="'fa fa-fw fa-remove o_we_hover_danger o_we_link'"
                 title.translate="Reset to Paragraph Font Family"
                 action="'removeFont'"
@@ -152,10 +141,7 @@
             <t t-set-slot="collapse">
                 <t t-foreach="[2, 3, 4, 5, 6]" t-as="depth" t-key="depth">
                     <BuilderRow level="1" label="heading_label + ' ' + depth">
-                        TODO BuilderFontFamilyPicker
-                        <!--
-                        <BuilderFontFamilyPicker actionParam="'h' + depth + '-font'"/>
-                        -->
+                        <BuilderFontFamilyPicker actionParam="'h' + depth + '-font'" valueParamName="'actionValue'"/>
                         <BuilderButton className="'fa fa-fw fa-remove o_we_hover_danger o_we_link'"
                             title.translate="Reset to Headings Font Family"
                             action="'removeFont'"
@@ -165,10 +151,7 @@
                 </t>
                 <t t-foreach="used_display_font_sizes" t-as="depth" t-key="depth">
                     <BuilderRow level="1" label="display_label + ' ' + depth">
-                        TODO BuilderFontFamilyPicker
-                        <!--
-                        <BuilderFontFamilyPicker actionParam="'display-' + depth + '-font'"/>
-                        -->
+                        <BuilderFontFamilyPicker actionParam="'display-' + depth + '-font'" valueParamName="'actionValue'"/>
                         <BuilderButton className="'fa fa-fw fa-remove o_we_hover_danger o_we_link'"
                             title.translate="Reset to Headings Font Family"
                             action="'removeFont'"
@@ -244,10 +227,7 @@
             <BuilderNumberInput actionParam="'btn-secondary-outline-border-width'" unit="'px'" saveUnit="'rem'"/>
         </BuilderRow>
         <BuilderRow label.translate="Font Family">
-            TODO BuilderFontFamilyPicker
-            <!--
-            <we-fontfamilypicker data-variable="buttons-font"/>
-            -->
+            <BuilderFontFamilyPicker actionParam="'buttons-font'" valueParamName="'actionValue'"/>
             <BuilderButton className="'fa fa-fw fa-remove o_we_hover_danger o_we_link'"
                 title.translate="Reset to Paragraph Font Family"
                 action="'removeFont'"
diff --git a/addons/html_builder/static/src/sidebar/theme_tab_plugin.js b/addons/html_builder/static/src/sidebar/theme_tab_plugin.js
index 590e7581740e9..f9085b7df803d 100644
--- a/addons/html_builder/static/src/sidebar/theme_tab_plugin.js
+++ b/addons/html_builder/static/src/sidebar/theme_tab_plugin.js
@@ -10,6 +10,7 @@ import { withSequence } from "@html_editor/utils/resource";
 export class ThemeTabPlugin extends Plugin {
     static id = "themeTab";
     static dependencies = ["builderActions", "history", "savePlugin"];
+    static shared = ["makeSCSSCusto"];
     resources = {
         builder_actions: this.getActions(),
         theme_options: [
diff --git a/addons/html_builder/static/src/website_builder/plugins/font/add_font_dialog.js b/addons/html_builder/static/src/website_builder/plugins/font/add_font_dialog.js
new file mode 100644
index 0000000000000..0393a6277b5c2
--- /dev/null
+++ b/addons/html_builder/static/src/website_builder/plugins/font/add_font_dialog.js
@@ -0,0 +1,347 @@
+import { rpc } from "@web/core/network/rpc";
+import { Component, useEffect, useRef, useState } from "@odoo/owl";
+import { _t } from "@web/core/l10n/translation";
+import { useService } from "@web/core/utils/hooks";
+import { AutoComplete } from "@web/core/autocomplete/autocomplete";
+import { Dialog } from "@web/core/dialog/dialog";
+import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
+
+class GoogleFontAutoComplete extends AutoComplete {
+    setup() {
+        super.setup();
+        this.inputRef = useRef("input");
+        this.sourcesListRef = useRef("sourcesList");
+        useEffect(
+            (el) => {
+                el.setAttribute("id", "google_font");
+            },
+            () => [this.inputRef.el]
+        );
+    }
+
+    get dropdownOptions() {
+        return {
+            ...super.dropdownOptions,
+            position: "bottom-fit",
+        };
+    }
+
+    onInput(ev) {
+        super.onInput(ev);
+        if (this.sourcesListRef.el) {
+            this.sourcesListRef.el.scrollTop = 0;
+        }
+    }
+}
+
+export class AddFontDialog extends Component {
+    static template = "html_builder.website.dialog.addFont";
+    static components = { GoogleFontAutoComplete, Dialog };
+    static props = {
+        close: Function,
+        allFonts: Array,
+        googleFonts: Array,
+        googleLocalFonts: Array,
+        uploadedLocalFonts: Array,
+        customize: Function,
+    };
+    state = useState({
+        valid: true,
+        loading: false,
+        googleFontFamily: undefined,
+        googleServe: true,
+        uploadedFontName: undefined,
+        uploadedFonts: [],
+        uploadedFontFaces: undefined,
+        previewText: _t("The quick brown fox jumps over the lazy dog."),
+    });
+    setup() {
+        this.fileInput = useRef("fileInput");
+        this.dialog = useService("dialog");
+        this.orm = useService("orm");
+    }
+
+    async onClickSave() {
+        if (this.state.loading) {
+            return;
+        }
+        this.state.loading = true;
+        const shouldClose = await this.save(this.state);
+        if (shouldClose) {
+            this.props.close();
+            return;
+        }
+        this.state.loading = false;
+    }
+    onClickCancel() {
+        this.props.close();
+    }
+    get getGoogleFontList() {
+        return [
+            {
+                options: async (term) => {
+                    if (!this.googleFontList) {
+                        await rpc("/website/google_font_metadata").then((data) => {
+                            this.googleFontList = data.familyMetadataList.map(
+                                (font) => font.family
+                            );
+                        });
+                    }
+                    const lowerCaseTerm = term.toLowerCase();
+                    const filtered = this.googleFontList.filter((value) =>
+                        value.toLowerCase().includes(lowerCaseTerm)
+                    );
+                    return filtered.map((fontFamilyName) => ({
+                        label: fontFamilyName,
+                        value: fontFamilyName,
+                    }));
+                },
+            },
+        ];
+    }
+    async onGoogleFontSelect(selected) {
+        this.fileInput.el.value = "";
+        this.state.uploadedFonts = [];
+        this.state.uploadedFontName = undefined;
+        this.state.uploadedFontFaces = undefined;
+        try {
+            const fontFamily = selected.value;
+            const result = await fetch(
+                `https://fonts.googleapis.com/css?family=${encodeURIComponent(
+                    fontFamily
+                )}:300,300i,400,400i,700,700i`,
+                { method: "HEAD" }
+            );
+            // Google fonts server returns a 400 status code if family is not valid.
+            if (result.ok) {
+                const linkId = `previewFont${fontFamily}`;
+                if (!document.querySelector(`link[id='${linkId}']`)) {
+                    const linkEl = document.createElement("link");
+                    linkEl.id = linkId;
+                    linkEl.setAttribute("href", result.url);
+                    linkEl.setAttribute("rel", "stylesheet");
+                    linkEl.dataset.fontPreview = true;
+                    document.head.appendChild(linkEl);
+                }
+                this.state.googleFontFamily = fontFamily;
+            } else {
+                this.state.googleFontFamily = undefined;
+            }
+        } catch (error) {
+            console.error(error);
+        }
+    }
+    async onUploadChange(e) {
+        this.state.googleFontFamily = undefined;
+        const file = this.fileInput.el.files[0];
+        if (!file) {
+            this.state.uploadedFonts = [];
+            this.state.uploadedFontName = undefined;
+            this.state.uploadedFontFaces = undefined;
+            return;
+        }
+        const reader = new FileReader();
+        reader.onload = (e) => {
+            const base64 = e.target.result.split(",")[1];
+            rpc("/website/theme_upload_font", {
+                name: file.name,
+                data: base64,
+            }).then((result) => {
+                this.state.uploadedFonts = result;
+                this.updateFontStyle(file.name.substr(0, file.name.lastIndexOf(".")));
+            });
+        };
+        reader.readAsDataURL(file);
+    }
+    /**
+     * Deduces the style of uploaded fonts and creates inline style
+     * elements in the backend iframe's head to make the font-faces
+     * available for preview.
+     *
+     * @param baseFontName
+     */
+    updateFontStyle(baseFontName) {
+        const targetFonts = {};
+        // Add candidate tags to fonts.
+        let shortestNamedFont;
+        for (const font of this.state.uploadedFonts) {
+            if (!shortestNamedFont || font.name.length < shortestNamedFont.name.length) {
+                shortestNamedFont = font;
+            }
+            font.isItalic = /italic/i.test(font.name);
+            font.isLight = /light|300/i.test(font.name);
+            font.isBold = /bold|700/i.test(font.name);
+            font.isRegular = /regular|400/i.test(font.name);
+            font.weight = font.isRegular ? 400 : font.isLight ? 300 : font.isBold ? 700 : undefined;
+            if (font.isItalic && !font.weight) {
+                if (!/00|thin|medium|black|condense|extrude/i.test(font.name)) {
+                    font.isRegular = true;
+                    font.weight = 400;
+                }
+            }
+            font.style = font.isItalic ? "italic" : "normal";
+            if (font.weight) {
+                targetFonts[`${font.weight}${font.style}`] = font;
+            }
+        }
+        if (!Object.values(targetFonts).filter((font) => font.isRegular).length) {
+            // Keep font with shortest name.
+            shortestNamedFont.weight = 400;
+            shortestNamedFont.style = "normal";
+            targetFonts["400"] = shortestNamedFont;
+        }
+        const fontFaces = [];
+        for (const font of Object.values(targetFonts)) {
+            fontFaces.push(`@font-face{
+                font-family: ${baseFontName};
+                font-style: ${font.style};
+                font-weight: ${font.weight};
+                src:url("${font.url}");
+            }`);
+        }
+        let styleEl = document.head.querySelector(
+            `style[id='WebsiteThemeFontPreview-${baseFontName}']`
+        );
+        if (!styleEl) {
+            styleEl = document.createElement("style");
+            styleEl.id = `WebsiteThemeFontPreview-${baseFontName}`;
+            styleEl.dataset.fontPreview = true;
+            document.head.appendChild(styleEl);
+        }
+        const previewFontFaces = fontFaces.join("");
+        styleEl.textContent = previewFontFaces;
+        this.state.uploadedFontName = baseFontName;
+        this.state.uploadedFontFaces = previewFontFaces;
+    }
+    async save(state) {
+        const uploadedFontName = state.uploadedFontName;
+        const uploadedFontFaces = state.uploadedFontFaces;
+        let font = undefined;
+        if (uploadedFontName && uploadedFontFaces) {
+            const fontExistsLocally = this.props.uploadedLocalFonts.some(
+                (localFont) => localFont.split(":")[0] === `'${uploadedFontName}'`
+            );
+            if (fontExistsLocally) {
+                this.dialog.add(ConfirmationDialog, {
+                    title: _t("Font exists"),
+                    body: _t(
+                        "This uploaded font already exists.\nTo replace an existing font, remove it first."
+                    ),
+                });
+                return;
+            }
+            const homonymGoogleFontExists =
+                this.props.googleFonts.some((font) => font === uploadedFontName) ||
+                this.props.googleLocalFonts.some(
+                    (font) => font.split(":")[0] === `'${uploadedFontName}'`
+                );
+            if (homonymGoogleFontExists) {
+                this.dialog.add(ConfirmationDialog, {
+                    title: _t("Font name already used"),
+                    body: _t(
+                        "A font with the same name already exists.\nTry renaming the uploaded file."
+                    ),
+                });
+                return;
+            }
+            // Create attachment.
+            const [fontCssId] = await this.orm.call("ir.attachment", "create_unique", [
+                [
+                    {
+                        name: uploadedFontName,
+                        description: `CSS font face for ${uploadedFontName}`,
+                        datas: btoa(uploadedFontFaces),
+                        res_model: "ir.attachment",
+                        mimetype: "text/css",
+                        public: true,
+                    },
+                ],
+            ]);
+            this.props.uploadedLocalFonts.push(`'${uploadedFontName}': ${fontCssId}`);
+            font = uploadedFontName;
+        } else {
+            let isValidFamily = false;
+            font = state.googleFontFamily;
+
+            try {
+                const result = await fetch(
+                    "https://fonts.googleapis.com/css?family=" +
+                        encodeURIComponent(font) +
+                        ":300,300i,400,400i,700,700i",
+                    { method: "HEAD" }
+                );
+                // Google fonts server returns a 400 status code if family is not valid.
+                if (result.ok) {
+                    isValidFamily = true;
+                }
+            } catch (error) {
+                console.error(error);
+            }
+
+            if (!isValidFamily) {
+                this.dialog.add(ConfirmationDialog, {
+                    title: _t("Font access"),
+                    body: _t("The selected font cannot be accessed."),
+                });
+                return;
+            }
+
+            const googleFontServe = state.googleServe;
+            const fontName = `'${font}'`;
+            // If the font already exists, it will only be added if
+            // the user chooses to add it locally when it is already
+            // imported from the Google Fonts server.
+            const fontExistsLocally = this.props.googleLocalFonts.some(
+                (localFont) => localFont.split(":")[0] === fontName
+            );
+            const fontExistsOnServer = this.props.allFonts.includes(fontName);
+            const preventFontAddition =
+                fontExistsLocally || (fontExistsOnServer && googleFontServe);
+            if (preventFontAddition) {
+                this.dialog.add(ConfirmationDialog, {
+                    title: _t("Font exists"),
+                    body: _t(
+                        "This font already exists, you can only add it as a local font to replace the server version."
+                    ),
+                });
+                return;
+            }
+            if (googleFontServe) {
+                this.props.googleFonts.push(font);
+            } else {
+                this.props.googleLocalFonts.push(`'${font}': ''`);
+            }
+        }
+        this.props.customize({
+            values: { [this.props.variable]: `'${font}'` },
+            googleFonts: this.props.googleFonts,
+            googleLocalFonts: this.props.googleLocalFonts,
+            uploadedLocalFonts: this.props.uploadedLocalFonts,
+        });
+        const styleEl = document.head.querySelector(`[id='WebsiteThemeFontPreview-${font}']`);
+        if (styleEl) {
+            delete styleEl.dataset.fontPreview;
+        }
+        return true;
+    }
+}
+
+export function showAddFontDialog(dialog, fontsData, customize) {
+    dialog.add(
+        AddFontDialog,
+        {
+            allFonts: fontsData.allFonts,
+            googleFonts: fontsData.googleFonts,
+            googleLocalFonts: fontsData.googleLocalFonts,
+            uploadedLocalFonts: fontsData.uploadedLocalFonts,
+            customize,
+        },
+        {
+            onClose: () => {
+                for (const el of document.head.querySelectorAll("[data-font-preview]")) {
+                    el.remove();
+                }
+            },
+        }
+    );
+}
diff --git a/addons/html_builder/static/src/website_builder/plugins/font/add_font_dialog.xml b/addons/html_builder/static/src/website_builder/plugins/font/add_font_dialog.xml
new file mode 100644
index 0000000000000..7ce09d80d47aa
--- /dev/null
+++ b/addons/html_builder/static/src/website_builder/plugins/font/add_font_dialog.xml
@@ -0,0 +1,106 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+<t t-name="html_builder.website.dialog.addFont.singlePreview">
+    <div class="row">
+        <label class="col-12" t-attf-style="font-weight: {{previewWeight}};" t-out="previewLabel"/>
+    </div>
+    <div class="mb-3 row">
+        <div class="col-11 offset-1" t-attf-style="font-family: '{{previewFontName}}';">
+            <input t-if="previewFontName" class="w-100 border-0 bg-transparent" t-model="state.previewText" t-attf-style="font-weight: {{previewWeight}};"/>
+            <input t-else="" class="w-100 border-0 bg-transparent" readonly="1" t-attf-style="font-weight: {{previewWeight}};"/>
+        </div>
+    </div>
+</t>
+<t t-name="html_builder.website.dialog.addFont.preview">
+    <div class="mb-3 row text-center">
+        <label class="col-12">
+            <t t-if="previewFontName">Preview of <t t-out="previewFontName"/></t>
+            <t t-else="">Preview</t>
+        </label>
+    </div>
+    <t t-call="html_builder.website.dialog.addFont.singlePreview">
+        <t t-set="previewLabel">Light</t>
+        <t t-set="previewWeight">300</t>
+    </t>
+    <t t-call="html_builder.website.dialog.addFont.singlePreview">
+        <t t-set="previewLabel">Regular</t>
+        <t t-set="previewWeight">400</t>
+    </t>
+    <t t-call="html_builder.website.dialog.addFont.singlePreview">
+        <t t-set="previewLabel">Bold</t>
+        <t t-set="previewWeight">700</t>
+    </t>
+</t>
+<t t-name="html_builder.website.dialog.addFont">
+    <Dialog title.translate="Add a Google font or upload a custom font" size="'xl'">
+        <div class="row">
+            <div class="col-lg-7">
+                <!-- Google Font -->
+                <div class="mb-3 row">
+                    <div class="col-form-label col-md-4">
+                        <label for="google_font">Choose from list</label>
+                        <div class="text-muted">Explore on <a target="_blank" href="https://fonts.google.com">fonts.google.com</a>.</div>
+                    </div>
+                    <div class="col-form-label col-md-8 o_field_widget">
+                        <div class="o_input_dropdown">
+                            <GoogleFontAutoComplete
+                                value="state.googleFontFamily"
+                                placeholder.translate="Select a Google Font..."
+                                sources="getGoogleFontList"
+                                onSelect.bind="onGoogleFontSelect"
+                            />
+                            <span class="o_dropdown_button" />
+                        </div>
+                    </div>
+                    <label class="col-form-label col-md-4" for="google_font_serve">
+                        Serve font from Google servers
+                        <sup class="text-info" title="To comply with some local regulations">
+                            <a target="_blank" href="https://www.odoo.com/forum/help-1/how-to-use-google-fonts-and-respecting-german-requirements-214049">?</a>
+                        </sup>
+                    </label>
+                    <label class="o_switch col-form-label col-md-8" t-att-class="{'o_switch_disabled': !state.googleFontFamily}" for="google_font_serve">
+                        <input type="checkbox" checked="checked" t-att-disabled="!state.googleFontFamily" id="google_font_serve" t-model="state.googleServe"/>
+                        <span/>
+                    </label>
+                </div>
+                <hr/>
+                <!-- Upload font -->
+                <div class="mb-3 row">
+                    <div class="col-form-label col-md-4">
+                        <label for="upload_font">Custom Font</label>
+                        <div class="text-muted">zip, ttf, woff, woff2, otf</div>
+                    </div>
+                    <div class="col-md-8">
+                        <input t-ref="fileInput" type="file" class="form-control s_website_form_input" name="Custom Font"
+                            id="upload_font" accept=".woff, .woff2, .ttf, .zip, .otf, font/*"
+                            t-on-change="onUploadChange"
+                        />
+                    </div>
+                </div>
+            </div>
+            <div class="col-lg-5" style="border-left: var(--gray-300) solid 1px;">
+                <!-- Preview -->
+                <t t-call="html_builder.website.dialog.addFont.preview">
+                    <t t-if="state.googleFontFamily">
+                        <t t-set="previewFontName" t-value="state.googleFontFamily"/>
+                    </t>
+                    <t t-elif="state.uploadedFonts.length">
+                        <t t-set="previewFontName" t-value="state.uploadedFontName"/>
+                    </t>
+                </t>
+            </div>
+        </div>
+        <t t-set-slot="footer">
+            <button class="btn btn-primary" t-att-disabled="state.loading" t-on-click="onClickSave">
+                <t t-if="state.loading">
+                    <i class="fa fa-icon fa-icon"/>
+                </t>
+                Save and Reload
+            </button>
+            <button class="btn btn-secondary" t-on-click="onClickCancel">Cancel</button>
+        </t>
+    </Dialog>
+</t>
+
+</templates>
diff --git a/addons/html_builder/static/src/website_builder/plugins/font/font_plugin.js b/addons/html_builder/static/src/website_builder/plugins/font/font_plugin.js
new file mode 100644
index 0000000000000..f89a6f82f75e7
--- /dev/null
+++ b/addons/html_builder/static/src/website_builder/plugins/font/font_plugin.js
@@ -0,0 +1,197 @@
+import { Plugin } from "@html_editor/plugin";
+import { registry } from "@web/core/registry";
+import { _t } from "@web/core/l10n/translation";
+import { Cache } from "@web/core/utils/cache";
+import { loadCSS } from "@web/core/assets";
+import { getCSSVariableValue } from "@html_builder/utils/utils_css";
+import { showAddFontDialog } from "./add_font_dialog";
+
+// TODO Website-specific
+class FontPlugin extends Plugin {
+    static id = "websiteFont";
+    static shared = ["addFont", "deleteFont", "getFontsData"];
+    static dependencies = ["savePlugin", "themeTab"];
+    resources = {
+        // Lists CSS variables that will be reset when a font is deleted if
+        // they refer to that font.
+        fontCssVariables: [
+            "font",
+            "headings-font",
+            "h2-font",
+            "h3-font",
+            "h4-font",
+            "h5-font",
+            "h6-font",
+            "display-1-font",
+            "display-2-font",
+            "display-3-font",
+            "display-4-font",
+            "buttons-font",
+        ],
+    };
+    setup() {
+        this.fontsCache = new Cache(this._fetchFonts.bind(this), JSON.stringify);
+    }
+    destroy() {
+        super.destroy();
+        this.fontsCache.invalidate();
+    }
+    async addFont() {
+        const fontsData = await this.getFontsData();
+        showAddFontDialog(this.services.dialog, fontsData, this.customizeFonts.bind(this));
+    }
+    async customizeFonts({ values = {}, googleFonts, googleLocalFonts, uploadedLocalFonts }) {
+        if (googleFonts.length) {
+            values["google-fonts"] = "('" + googleFonts.join("', '") + "')";
+        } else {
+            values["google-fonts"] = "null";
+        }
+        if (googleLocalFonts.length) {
+            values["google-local-fonts"] = "(" + googleLocalFonts.join(", ") + ")";
+        } else {
+            values["google-local-fonts"] = "null";
+        }
+        if (uploadedLocalFonts.length) {
+            values["uploaded-local-fonts"] = "(" + uploadedLocalFonts.join(", ") + ")";
+        } else {
+            values["uploaded-local-fonts"] = "null";
+        }
+        await this.dependencies.themeTab.makeSCSSCusto(
+            "/website/static/src/scss/options/user_values.scss",
+            values
+        );
+        this.fontsCache.invalidate();
+        // TODO reloadEditor: true
+        await this.dependencies.savePlugin.save(/* not in translation */);
+    }
+    async deleteFont(font) {
+        const { googleFonts, googleLocalFonts, uploadedLocalFonts } = await this.getFontsData();
+        const values = {};
+
+        // Remove Google font
+        const fontIndex = font.indexForType;
+        const localFont = font.type;
+        let fontName;
+        if (localFont === "uploaded") {
+            const font = uploadedLocalFonts[fontIndex].split(":");
+            // Remove double quotes
+            fontName = font[0].substring(1, font[0].length - 1);
+            values["delete-font-attachment-id"] = font[1];
+            uploadedLocalFonts.splice(fontIndex, 1);
+        } else if (localFont === "google") {
+            const googleFont = googleLocalFonts[fontIndex].split(":");
+            // Remove double quotes
+            fontName = googleFont[0].substring(1, googleFont[0].length - 1);
+            values["delete-font-attachment-id"] = googleFont[1];
+            googleLocalFonts.splice(fontIndex, 1);
+        } else {
+            fontName = googleFonts[fontIndex];
+            googleFonts.splice(fontIndex, 1);
+        }
+
+        // Adapt font variable indexes to the removal
+        const style = window.getComputedStyle(this.document.documentElement);
+        this.getResource("fontCssVariables").forEach((variable) => {
+            const value = getCSSVariableValue(variable, style);
+            if (value.substring(1, value.length - 1) === fontName) {
+                // If an element is using the google font being removed, reset
+                // it to the theme default.
+                values[variable] = "null";
+            }
+        });
+        await this.customizeFonts({
+            values: values,
+            googleFonts: googleFonts,
+            googleLocalFonts: googleLocalFonts,
+            uploadedLocalFonts: uploadedLocalFonts,
+        });
+    }
+    async getFontsData() {
+        return this.fontsCache.read({});
+    }
+    async _fetchFonts() {
+        const style = window.getComputedStyle(this.document.documentElement);
+        const nbFonts = parseInt(getCSSVariableValue("number-of-fonts", style));
+        // User fonts served by google server.
+        const googleFontsProperty = getCSSVariableValue("google-fonts", style);
+        let googleFonts = googleFontsProperty ? googleFontsProperty.split(/\s*,\s*/g) : [];
+        googleFonts = googleFonts.map((font) => font.substring(1, font.length - 1)); // Unquote
+        // Local user fonts.
+        const googleLocalFontsProperty = getCSSVariableValue("google-local-fonts", style);
+        const googleLocalFonts = googleLocalFontsProperty
+            ? googleLocalFontsProperty.slice(1, -1).split(/\s*,\s*/g)
+            : [];
+        const uploadedLocalFontsProperty = getCSSVariableValue("uploaded-local-fonts", style);
+        const uploadedLocalFonts = uploadedLocalFontsProperty
+            ? uploadedLocalFontsProperty.slice(1, -1).split(/\s*,\s*/g)
+            : [];
+        // If a same font exists both remotely and locally, we remove the remote
+        // font to prioritize the local font. The remote one will never be
+        // displayed or loaded as long as the local one exists.
+        googleFonts = googleFonts.filter((font) => {
+            const localFonts = googleLocalFonts.map((localFont) => localFont.split(":")[0]);
+            return localFonts.indexOf(`'${font}'`) === -1;
+        });
+        const allFonts = [];
+
+        const fontsToLoad = [];
+        for (const font of googleFonts) {
+            const fontURL = `https://fonts.googleapis.com/css?family=${encodeURIComponent(
+                font
+            ).replace(/%20/g, "+")}`;
+            fontsToLoad.push(fontURL);
+        }
+        for (const font of googleLocalFonts) {
+            const attachmentId = font.split(/\s*:\s*/)[1];
+            const fontURL = `/web/content/${encodeURIComponent(attachmentId)}`;
+            fontsToLoad.push(fontURL);
+        }
+        const proms = fontsToLoad.map(async (fontURL) => loadCSS(fontURL));
+
+        const _fonts = [];
+        const themeFontsNb =
+            nbFonts - (googleLocalFonts.length + googleFonts.length + uploadedLocalFonts.length);
+        const localFontsOffset = nbFonts - googleLocalFonts.length - uploadedLocalFonts.length;
+        const uploadedFontsOffset = nbFonts - uploadedLocalFonts.length;
+
+        for (let fontNb = 0; fontNb < nbFonts; fontNb++) {
+            const realFontNb = fontNb + 1;
+            const fontKey = getCSSVariableValue(`font-number-${realFontNb}`, style);
+            allFonts.push(fontKey);
+            let fontName = fontKey.slice(1, -1); // Unquote
+            let fontFamily = fontName;
+            const isSystemFonts = fontName === "SYSTEM_FONTS";
+            if (isSystemFonts) {
+                fontName = _t("System Fonts");
+                fontFamily = "var(--o-system-fonts)";
+            }
+
+            let type = "cloud";
+            let indexForType = fontNb - themeFontsNb;
+            if (fontNb >= localFontsOffset) {
+                if (fontNb < uploadedFontsOffset) {
+                    type = "google";
+                    indexForType = fontNb - localFontsOffset;
+                } else {
+                    type = "uploaded";
+                    indexForType = fontNb - uploadedFontsOffset;
+                }
+            }
+            _fonts.push({
+                type,
+                indexForType,
+                fontFamily,
+                string: fontName,
+            });
+        }
+        await Promise.all(proms);
+        return {
+            allFonts,
+            googleFonts,
+            googleLocalFonts,
+            uploadedLocalFonts,
+            _fonts,
+        };
+    }
+}
+registry.category("website-plugins").add(FontPlugin.id, FontPlugin);


Reviews:
user: login: FrancoisGe
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-11T09:13:32Z
user: login: bso-odoo
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-11T09:26:40Z
user: login: bso-odoo
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-11T09:33:41Z

Review comments:
diff_hunk: @@ -0,0 +1,83 @@
+import { Component, onMounted, onWillStart, useState, useSubEnv } from "@odoo/owl";
+import { _t } from "@web/core/l10n/translation";
+import { useService } from "@web/core/utils/hooks";
+import {
+    basicContainerBuilderComponentProps,
+    useVisibilityObserver,
+    useApplyVisibility,
+    useSelectableComponent,
+} from "./utils";
+import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
+import { BuilderSelect } from "./builder_select";
+import { BuilderSelectItem } from "./builder_select_item";
+
+export class BuilderFontFamilyPicker extends Component {
+    static template = "html_builder.website.BuilderFontFamilyPicker";
+    static props = {
+        ...basicContainerBuilderComponentProps,
+        id: { type: String, optional: true },
+        valueParamName: String,
+    };
+    static components = {
+        BuilderSelect,
+        BuilderSelectItem,
+    };
+
+    setup() {
+        this.dialog = useService("dialog");
+        this.orm = useService("orm");
+        useVisibilityObserver("content", useApplyVisibility("root"));
+        useSelectableComponent(this.props.id, {
+            /*
+            onItemChange(item) {
+                currentLabel = item.getLabel();
+                updateCurrentLabel();
+            },
+            */
+        });
+        onMounted(() => {});
+        useSubEnv({
+            /*
+            onSelectItem: () => {
+                this.dropdown.close();
+            },
+            */
+        });
+        this.state = useState({
+            fonts: [],
+        });
+        onWillStart(async () => {
+            const fontsData = await this.env.editor.shared.websiteFont.getFontsData();
+            this.state.fonts.push(...fontsData._fonts);
path: addons/html_builder/static/src/core/building_blocks/builder_fontfamilypicker.js
user: login: FrancoisGe
body: No need state if fonts never change
created_at: 2025-03-10T16:10:14Z
author_association: NONE
diff_hunk: @@ -0,0 +1,346 @@
+import { rpc } from "@web/core/network/rpc";
+import { Component, useEffect, useRef, useState } from "@odoo/owl";
+import { _t } from "@web/core/l10n/translation";
+import { useService } from "@web/core/utils/hooks";
+import { AutoComplete } from "@web/core/autocomplete/autocomplete";
+import { Dialog } from "@web/core/dialog/dialog";
+import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
+
+class GoogleFontAutoComplete extends AutoComplete {
+    setup() {
+        super.setup();
+        this.inputRef = useRef("input");
+        this.sourcesListRef = useRef("sourcesList");
+        useEffect(
+            (el) => {
+                el.setAttribute("id", "google_font");
+            },
+            () => [this.inputRef.el]
+        );
+    }
+
+    get dropdownOptions() {
+        return {
+            ...super.dropdownOptions,
+            position: "bottom-fit",
+        };
+    }
+
+    onInput(ev) {
+        super.onInput(ev);
+        if (this.sourcesListRef.el) {
+            this.sourcesListRef.el.scrollTop = 0;
+        }
+    }
+}
+
+export class AddFontDialog extends Component {
+    static template = "html_builder.website.dialog.addFont";
+    static components = { GoogleFontAutoComplete, Dialog };
+    static props = {
+        close: Function,
+        allFonts: Array,
+        googleFonts: Array,
+        googleLocalFonts: Array,
+        uploadedLocalFonts: Array,
+        customize: Function,
+    };
+    state = useState({
+        valid: true,
+        loading: false,
+        googleFontFamily: undefined,
+        googleServe: true,
+        uploadedFontName: undefined,
+        uploadedFonts: [],
+        uploadedFontFaces: undefined,
+        previewText: _t("The quick brown fox jumps over the lazy dog."),
+    });
+    setup() {
+        this.fileInput = useRef("fileInput");
+        this.dialog = useService("dialog");
+        this.orm = useService("orm");
+    }
+
+    async onClickSave() {
+        if (this.state.loading) {
+            return;
+        }
+        this.state.loading = true;
+        const shouldClose = await this.save(this.state);
+        if (shouldClose) {
+            this.props.close();
+            return;
+        }
+        this.state.loading = false;
+    }
+    onClickCancel() {
+        this.props.close();
+    }
+    get getGoogleFontList() {
+        return [
+            {
+                options: async (term) => {
+                    if (!this.googleFontList) {
+                        await rpc("/website/google_font_metadata").then((data) => {
+                            this.googleFontList = data.familyMetadataList.map(
+                                (font) => font.family
+                            );
+                        });
+                    }
+                    const lowerCaseTerm = term.toLowerCase();
+                    const filtered = this.googleFontList.filter((value) =>
+                        value.toLowerCase().includes(lowerCaseTerm)
+                    );
+                    return filtered.map((fontFamilyName) => ({
+                        label: fontFamilyName,
+                        value: fontFamilyName,
+                    }));
+                },
+            },
+        ];
+    }
+    async onGoogleFontSelect(selected) {
+        this.fileInput.el.value = "";
+        this.state.uploadedFonts = [];
+        this.state.uploadedFontName = undefined;
+        this.state.uploadedFontFaces = undefined;
+        try {
+            const fontFamily = selected.value;
+            const result = await fetch(
+                `https://fonts.googleapis.com/css?family=${encodeURIComponent(
+                    fontFamily
+                )}:300,300i,400,400i,700,700i`,
+                { method: "HEAD" }
+            );
+            // Google fonts server returns a 400 status code if family is not valid.
+            if (result.ok) {
+                const linkId = `previewFont${fontFamily}`;
+                if (!document.querySelector(`link[id='${linkId}']`)) {
+                    const linkEl = document.createElement("link");
+                    linkEl.id = linkId;
+                    linkEl.setAttribute("href", result.url);
+                    linkEl.setAttribute("rel", "stylesheet");
+                    linkEl.dataset.fontPreview = true;
+                    document.head.appendChild(linkEl);
+                }
+                this.state.googleFontFamily = fontFamily;
+            } else {
+                this.state.googleFontFamily = undefined;
+            }
+        } catch (error) {
+            console.error(error);
+        }
+    }
+    async onUploadChange(e) {
+        this.state.googleFontFamily = undefined;
+        const file = this.fileInput.el.files[0];
+        if (!file) {
+            this.state.uploadedFonts = [];
+            this.state.uploadedFontName = undefined;
+            this.state.uploadedFontFaces = undefined;
+            return;
+        }
+        const reader = new FileReader();
+        reader.onload = (e) => {
+            const base64 = e.target.result.split(",")[1];
+            rpc("/website/theme_upload_font", {
+                name: file.name,
+                data: base64,
+            }).then((result) => {
+                this.state.uploadedFonts = result;
+                this.updateFontStyle(file.name.substr(0, file.name.lastIndexOf(".")));
+            });
+        };
+        reader.readAsDataURL(file);
+    }
+    /**
+     * Deduces the style of uploaded fonts and creates inline style
+     * elements in the backend iframe's head to make the font-faces
+     * available for preview.
+     *
+     * @param baseFontName
+     */
+    updateFontStyle(baseFontName) {
+        const targetFonts = {};
+        // Add candidate tags to fonts.
+        let shortestNamedFont;
+        for (const font of this.state.uploadedFonts) {
+            if (!shortestNamedFont || font.name.length < shortestNamedFont.name.length) {
+                shortestNamedFont = font;
+            }
+            font.isItalic = /italic/i.test(font.name);
+            font.isLight = /light|300/i.test(font.name);
+            font.isBold = /bold|700/i.test(font.name);
+            font.isRegular = /regular|400/i.test(font.name);
+            font.weight = font.isRegular ? 400 : font.isLight ? 300 : font.isBold ? 700 : undefined;
+            if (font.isItalic && !font.weight) {
+                if (!/00|thin|medium|black|condense|extrude/i.test(font.name)) {
+                    font.isRegular = true;
+                    font.weight = 400;
+                }
+            }
+            font.style = font.isItalic ? "italic" : "normal";
+            if (font.weight) {
+                targetFonts[`${font.weight}${font.style}`] = font;
+            }
+        }
+        if (!Object.values(targetFonts).filter((font) => font.isRegular).length) {
+            // Keep font with shortest name.
+            shortestNamedFont.weight = 400;
+            shortestNamedFont.style = "normal";
+            targetFonts["400"] = shortestNamedFont;
+        }
+        const fontFaces = [];
+        for (const font of Object.values(targetFonts)) {
+            fontFaces.push(`@font-face{
+                font-family: ${baseFontName};
+                font-style: ${font.style};
+                font-weight: ${font.weight};
+                src:url("${font.url}");
+            }`);
+        }
+        let styleEl = document.head.querySelector(
+            `style[id='WebsiteThemeFontPreview-${baseFontName}']`
+        );
+        if (!styleEl) {
+            styleEl = document.createElement("style");
+            styleEl.id = `WebsiteThemeFontPreview-${baseFontName}`;
+            styleEl.dataset.fontPreview = true;
+            document.head.appendChild(styleEl);
+        }
+        const previewFontFaces = fontFaces.join("");
+        styleEl.textContent = previewFontFaces;
+        this.state.uploadedFontName = baseFontName;
+        this.state.uploadedFontFaces = previewFontFaces;
+    }
+    async save(state) {
+        const uploadedFontName = state.uploadedFontName;
+        const uploadedFontFaces = state.uploadedFontFaces;
+        let font = undefined;
+        if (uploadedFontName && uploadedFontFaces) {
+            const fontExistsLocally = this.props.uploadedLocalFonts.some(
+                (localFont) => localFont.split(":")[0] === `'${uploadedFontName}'`
+            );
+            if (fontExistsLocally) {
+                this.dialog.add(ConfirmationDialog, {
+                    title: _t("Font exists"),
+                    body: _t(
+                        "This uploaded font already exists.\nTo replace an existing font, remove it first."
+                    ),
+                });
+                return;
+            }
+            const homonymGoogleFontExists =
+                this.props.googleFonts.some((font) => font === uploadedFontName) ||
+                this.props.googleLocalFonts.some(
+                    (font) => font.split(":")[0] === `'${uploadedFontName}'`
+                );
+            if (homonymGoogleFontExists) {
+                this.dialog.add(ConfirmationDialog, {
+                    title: _t("Font name already used"),
+                    body: _t(
+                        "A font with the same name already exists.\nTry renaming the uploaded file."
+                    ),
+                });
+                return;
+            }
+            // Create attachment.
+            const [fontCssId] = await this.orm.call("ir.attachment", "create_unique", [
+                [
+                    {
+                        name: uploadedFontName,
+                        description: `CSS font face for ${uploadedFontName}`,
+                        datas: btoa(uploadedFontFaces),
+                        res_model: "ir.attachment",
+                        mimetype: "text/css",
+                        public: true,
+                    },
+                ],
+            ]);
+            this.props.uploadedLocalFonts.push(`'${uploadedFontName}': ${fontCssId}`);
+            font = uploadedFontName;
+        } else {
+            let isValidFamily = false;
+            font = state.googleFontFamily;
+
+            try {
+                const result = await fetch(
+                    "https://fonts.googleapis.com/css?family=" +
+                        encodeURIComponent(font) +
+                        ":300,300i,400,400i,700,700i",
+                    { method: "HEAD" }
+                );
+                // Google fonts server returns a 400 status code if family is not valid.
+                if (result.ok) {
+                    isValidFamily = true;
+                }
+            } catch (error) {
+                console.error(error);
+            }
+
+            if (!isValidFamily) {
+                this.dialog.add(ConfirmationDialog, {
+                    title: _t("Font access"),
+                    body: _t("The selected font cannot be accessed."),
+                });
+                return;
+            }
+
+            const googleFontServe = state.googleServe;
+            const fontName = `'${font}'`;
+            // If the font already exists, it will only be added if
+            // the user chooses to add it locally when it is already
+            // imported from the Google Fonts server.
+            const fontExistsLocally = this.props.googleLocalFonts.some(
+                (localFont) => localFont.split(":")[0] === fontName
+            );
+            const fontExistsOnServer = this.props.allFonts.includes(fontName);
+            const preventFontAddition =
+                fontExistsLocally || (fontExistsOnServer && googleFontServe);
+            if (preventFontAddition) {
+                this.dialog.add(ConfirmationDialog, {
+                    title: _t("Font exists"),
+                    body: _t(
+                        "This font already exists, you can only add it as a local font to replace the server version."
+                    ),
+                });
+                return;
+            }
+            if (googleFontServe) {
+                this.props.googleFonts.push(font);
+            } else {
+                this.props.googleLocalFonts.push(`'${font}': ''`);
+            }
+        }
+        this.props.customize({
+            values: { [this.props.variable]: `'${font}'` },
+            googleFonts: this.props.googleFonts,
+            googleLocalFonts: this.props.googleLocalFonts,
+            uploadedLocalFonts: this.props.uploadedLocalFonts,
+        });
+        const styleEl = document.head.querySelector(`[id='WebsiteThemeFontPreview-${font}']`);
+        if (styleEl) {
+            delete styleEl.dataset.fontPreview;
+        }
+        return true;
+    }
+    static show(dialog, fontsData, customize) {
+        dialog.add(
+            AddFontDialog,
+            {
+                allFonts: fontsData.allFonts,
+                googleFonts: fontsData.googleFonts,
+                googleLocalFonts: fontsData.googleLocalFonts,
+                uploadedLocalFonts: fontsData.uploadedLocalFonts,
+                customize,
+            },
+            {
+                onClose: () => {
+                    for (const el of document.head.querySelectorAll("[data-font-preview]")) {
+                        el.remove();
+                    }
+                },
+            }
+        );
+    }
path: addons/html_builder/static/src/website_builder/add_font_dialog.js
user: login: FrancoisGe
body: Why it s a static function ?
created_at: 2025-03-11T09:05:29Z
author_association: NONE
diff_hunk: @@ -0,0 +1,83 @@
+import { Component, onMounted, onWillStart, useState, useSubEnv } from "@odoo/owl";
+import { _t } from "@web/core/l10n/translation";
+import { useService } from "@web/core/utils/hooks";
+import {
+    basicContainerBuilderComponentProps,
+    useVisibilityObserver,
+    useApplyVisibility,
+    useSelectableComponent,
+} from "./utils";
+import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
+import { BuilderSelect } from "./builder_select";
+import { BuilderSelectItem } from "./builder_select_item";
+
+export class BuilderFontFamilyPicker extends Component {
+    static template = "html_builder.website.BuilderFontFamilyPicker";
+    static props = {
+        ...basicContainerBuilderComponentProps,
+        id: { type: String, optional: true },
+        valueParamName: String,
+    };
+    static components = {
+        BuilderSelect,
+        BuilderSelectItem,
+    };
+
+    setup() {
+        this.dialog = useService("dialog");
+        this.orm = useService("orm");
+        useVisibilityObserver("content", useApplyVisibility("root"));
+        useSelectableComponent(this.props.id, {
+            /*
+            onItemChange(item) {
+                currentLabel = item.getLabel();
+                updateCurrentLabel();
+            },
+            */
+        });
+        onMounted(() => {});
+        useSubEnv({
+            /*
+            onSelectItem: () => {
+                this.dropdown.close();
+            },
+            */
+        });
+        this.state = useState({
+            fonts: [],
+        });
+        onWillStart(async () => {
+            const fontsData = await this.env.editor.shared.websiteFont.getFontsData();
+            this.state.fonts.push(...fontsData._fonts);
path: addons/html_builder/static/src/core/building_blocks/builder_fontfamilypicker.js
user: login: bso-odoo
body: I thought it might depend on how will the reload happen, but indeed it can be removed for the time being.
created_at: 2025-03-11T09:26:40Z
author_association: NONE
diff_hunk: @@ -0,0 +1,346 @@
+import { rpc } from "@web/core/network/rpc";
+import { Component, useEffect, useRef, useState } from "@odoo/owl";
+import { _t } from "@web/core/l10n/translation";
+import { useService } from "@web/core/utils/hooks";
+import { AutoComplete } from "@web/core/autocomplete/autocomplete";
+import { Dialog } from "@web/core/dialog/dialog";
+import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
+
+class GoogleFontAutoComplete extends AutoComplete {
+    setup() {
+        super.setup();
+        this.inputRef = useRef("input");
+        this.sourcesListRef = useRef("sourcesList");
+        useEffect(
+            (el) => {
+                el.setAttribute("id", "google_font");
+            },
+            () => [this.inputRef.el]
+        );
+    }
+
+    get dropdownOptions() {
+        return {
+            ...super.dropdownOptions,
+            position: "bottom-fit",
+        };
+    }
+
+    onInput(ev) {
+        super.onInput(ev);
+        if (this.sourcesListRef.el) {
+            this.sourcesListRef.el.scrollTop = 0;
+        }
+    }
+}
+
+export class AddFontDialog extends Component {
+    static template = "html_builder.website.dialog.addFont";
+    static components = { GoogleFontAutoComplete, Dialog };
+    static props = {
+        close: Function,
+        allFonts: Array,
+        googleFonts: Array,
+        googleLocalFonts: Array,
+        uploadedLocalFonts: Array,
+        customize: Function,
+    };
+    state = useState({
+        valid: true,
+        loading: false,
+        googleFontFamily: undefined,
+        googleServe: true,
+        uploadedFontName: undefined,
+        uploadedFonts: [],
+        uploadedFontFaces: undefined,
+        previewText: _t("The quick brown fox jumps over the lazy dog."),
+    });
+    setup() {
+        this.fileInput = useRef("fileInput");
+        this.dialog = useService("dialog");
+        this.orm = useService("orm");
+    }
+
+    async onClickSave() {
+        if (this.state.loading) {
+            return;
+        }
+        this.state.loading = true;
+        const shouldClose = await this.save(this.state);
+        if (shouldClose) {
+            this.props.close();
+            return;
+        }
+        this.state.loading = false;
+    }
+    onClickCancel() {
+        this.props.close();
+    }
+    get getGoogleFontList() {
+        return [
+            {
+                options: async (term) => {
+                    if (!this.googleFontList) {
+                        await rpc("/website/google_font_metadata").then((data) => {
+                            this.googleFontList = data.familyMetadataList.map(
+                                (font) => font.family
+                            );
+                        });
+                    }
+                    const lowerCaseTerm = term.toLowerCase();
+                    const filtered = this.googleFontList.filter((value) =>
+                        value.toLowerCase().includes(lowerCaseTerm)
+                    );
+                    return filtered.map((fontFamilyName) => ({
+                        label: fontFamilyName,
+                        value: fontFamilyName,
+                    }));
+                },
+            },
+        ];
+    }
+    async onGoogleFontSelect(selected) {
+        this.fileInput.el.value = "";
+        this.state.uploadedFonts = [];
+        this.state.uploadedFontName = undefined;
+        this.state.uploadedFontFaces = undefined;
+        try {
+            const fontFamily = selected.value;
+            const result = await fetch(
+                `https://fonts.googleapis.com/css?family=${encodeURIComponent(
+                    fontFamily
+                )}:300,300i,400,400i,700,700i`,
+                { method: "HEAD" }
+            );
+            // Google fonts server returns a 400 status code if family is not valid.
+            if (result.ok) {
+                const linkId = `previewFont${fontFamily}`;
+                if (!document.querySelector(`link[id='${linkId}']`)) {
+                    const linkEl = document.createElement("link");
+                    linkEl.id = linkId;
+                    linkEl.setAttribute("href", result.url);
+                    linkEl.setAttribute("rel", "stylesheet");
+                    linkEl.dataset.fontPreview = true;
+                    document.head.appendChild(linkEl);
+                }
+                this.state.googleFontFamily = fontFamily;
+            } else {
+                this.state.googleFontFamily = undefined;
+            }
+        } catch (error) {
+            console.error(error);
+        }
+    }
+    async onUploadChange(e) {
+        this.state.googleFontFamily = undefined;
+        const file = this.fileInput.el.files[0];
+        if (!file) {
+            this.state.uploadedFonts = [];
+            this.state.uploadedFontName = undefined;
+            this.state.uploadedFontFaces = undefined;
+            return;
+        }
+        const reader = new FileReader();
+        reader.onload = (e) => {
+            const base64 = e.target.result.split(",")[1];
+            rpc("/website/theme_upload_font", {
+                name: file.name,
+                data: base64,
+            }).then((result) => {
+                this.state.uploadedFonts = result;
+                this.updateFontStyle(file.name.substr(0, file.name.lastIndexOf(".")));
+            });
+        };
+        reader.readAsDataURL(file);
+    }
+    /**
+     * Deduces the style of uploaded fonts and creates inline style
+     * elements in the backend iframe's head to make the font-faces
+     * available for preview.
+     *
+     * @param baseFontName
+     */
+    updateFontStyle(baseFontName) {
+        const targetFonts = {};
+        // Add candidate tags to fonts.
+        let shortestNamedFont;
+        for (const font of this.state.uploadedFonts) {
+            if (!shortestNamedFont || font.name.length < shortestNamedFont.name.length) {
+                shortestNamedFont = font;
+            }
+            font.isItalic = /italic/i.test(font.name);
+            font.isLight = /light|300/i.test(font.name);
+            font.isBold = /bold|700/i.test(font.name);
+            font.isRegular = /regular|400/i.test(font.name);
+            font.weight = font.isRegular ? 400 : font.isLight ? 300 : font.isBold ? 700 : undefined;
+            if (font.isItalic && !font.weight) {
+                if (!/00|thin|medium|black|condense|extrude/i.test(font.name)) {
+                    font.isRegular = true;
+                    font.weight = 400;
+                }
+            }
+            font.style = font.isItalic ? "italic" : "normal";
+            if (font.weight) {
+                targetFonts[`${font.weight}${font.style}`] = font;
+            }
+        }
+        if (!Object.values(targetFonts).filter((font) => font.isRegular).length) {
+            // Keep font with shortest name.
+            shortestNamedFont.weight = 400;
+            shortestNamedFont.style = "normal";
+            targetFonts["400"] = shortestNamedFont;
+        }
+        const fontFaces = [];
+        for (const font of Object.values(targetFonts)) {
+            fontFaces.push(`@font-face{
+                font-family: ${baseFontName};
+                font-style: ${font.style};
+                font-weight: ${font.weight};
+                src:url("${font.url}");
+            }`);
+        }
+        let styleEl = document.head.querySelector(
+            `style[id='WebsiteThemeFontPreview-${baseFontName}']`
+        );
+        if (!styleEl) {
+            styleEl = document.createElement("style");
+            styleEl.id = `WebsiteThemeFontPreview-${baseFontName}`;
+            styleEl.dataset.fontPreview = true;
+            document.head.appendChild(styleEl);
+        }
+        const previewFontFaces = fontFaces.join("");
+        styleEl.textContent = previewFontFaces;
+        this.state.uploadedFontName = baseFontName;
+        this.state.uploadedFontFaces = previewFontFaces;
+    }
+    async save(state) {
+        const uploadedFontName = state.uploadedFontName;
+        const uploadedFontFaces = state.uploadedFontFaces;
+        let font = undefined;
+        if (uploadedFontName && uploadedFontFaces) {
+            const fontExistsLocally = this.props.uploadedLocalFonts.some(
+                (localFont) => localFont.split(":")[0] === `'${uploadedFontName}'`
+            );
+            if (fontExistsLocally) {
+                this.dialog.add(ConfirmationDialog, {
+                    title: _t("Font exists"),
+                    body: _t(
+                        "This uploaded font already exists.\nTo replace an existing font, remove it first."
+                    ),
+                });
+                return;
+            }
+            const homonymGoogleFontExists =
+                this.props.googleFonts.some((font) => font === uploadedFontName) ||
+                this.props.googleLocalFonts.some(
+                    (font) => font.split(":")[0] === `'${uploadedFontName}'`
+                );
+            if (homonymGoogleFontExists) {
+                this.dialog.add(ConfirmationDialog, {
+                    title: _t("Font name already used"),
+                    body: _t(
+                        "A font with the same name already exists.\nTry renaming the uploaded file."
+                    ),
+                });
+                return;
+            }
+            // Create attachment.
+            const [fontCssId] = await this.orm.call("ir.attachment", "create_unique", [
+                [
+                    {
+                        name: uploadedFontName,
+                        description: `CSS font face for ${uploadedFontName}`,
+                        datas: btoa(uploadedFontFaces),
+                        res_model: "ir.attachment",
+                        mimetype: "text/css",
+                        public: true,
+                    },
+                ],
+            ]);
+            this.props.uploadedLocalFonts.push(`'${uploadedFontName}': ${fontCssId}`);
+            font = uploadedFontName;
+        } else {
+            let isValidFamily = false;
+            font = state.googleFontFamily;
+
+            try {
+                const result = await fetch(
+                    "https://fonts.googleapis.com/css?family=" +
+                        encodeURIComponent(font) +
+                        ":300,300i,400,400i,700,700i",
+                    { method: "HEAD" }
+                );
+                // Google fonts server returns a 400 status code if family is not valid.
+                if (result.ok) {
+                    isValidFamily = true;
+                }
+            } catch (error) {
+                console.error(error);
+            }
+
+            if (!isValidFamily) {
+                this.dialog.add(ConfirmationDialog, {
+                    title: _t("Font access"),
+                    body: _t("The selected font cannot be accessed."),
+                });
+                return;
+            }
+
+            const googleFontServe = state.googleServe;
+            const fontName = `'${font}'`;
+            // If the font already exists, it will only be added if
+            // the user chooses to add it locally when it is already
+            // imported from the Google Fonts server.
+            const fontExistsLocally = this.props.googleLocalFonts.some(
+                (localFont) => localFont.split(":")[0] === fontName
+            );
+            const fontExistsOnServer = this.props.allFonts.includes(fontName);
+            const preventFontAddition =
+                fontExistsLocally || (fontExistsOnServer && googleFontServe);
+            if (preventFontAddition) {
+                this.dialog.add(ConfirmationDialog, {
+                    title: _t("Font exists"),
+                    body: _t(
+                        "This font already exists, you can only add it as a local font to replace the server version."
+                    ),
+                });
+                return;
+            }
+            if (googleFontServe) {
+                this.props.googleFonts.push(font);
+            } else {
+                this.props.googleLocalFonts.push(`'${font}': ''`);
+            }
+        }
+        this.props.customize({
+            values: { [this.props.variable]: `'${font}'` },
+            googleFonts: this.props.googleFonts,
+            googleLocalFonts: this.props.googleLocalFonts,
+            uploadedLocalFonts: this.props.uploadedLocalFonts,
+        });
+        const styleEl = document.head.querySelector(`[id='WebsiteThemeFontPreview-${font}']`);
+        if (styleEl) {
+            delete styleEl.dataset.fontPreview;
+        }
+        return true;
+    }
+    static show(dialog, fontsData, customize) {
+        dialog.add(
+            AddFontDialog,
+            {
+                allFonts: fontsData.allFonts,
+                googleFonts: fontsData.googleFonts,
+                googleLocalFonts: fontsData.googleLocalFonts,
+                uploadedLocalFonts: fontsData.uploadedLocalFonts,
+                customize,
+            },
+            {
+                onClose: () => {
+                    for (const el of document.head.querySelectorAll("[data-font-preview]")) {
+                        el.remove();
+                    }
+                },
+            }
+        );
+    }
path: addons/html_builder/static/src/website_builder/add_font_dialog.js
user: login: bso-odoo
body: Old habit <https://docs.oracle.com/en/java/javase/11/docs/api/java.desktop/javax/swing/JOptionPane.html#showConfirmDialog(java.awt.Component,java.lang.Object)>... I'll turn it into a function outside the class.
created_at: 2025-03-11T09:33:41Z
author_association: NONE

PR comments:
user: login: robodoo
created_at: 2025-03-06T12:03:01Z
body: This PR targets the un-managed branch odoo-dev/odoo:master-mysterious-egg, it needs to be retargeted before it can be merged.
author_association: NONE
user: login: robodoo
created_at: 2025-03-11T10:19:26Z
body: This PR targets the un-managed branch odoo-dev/odoo:master-mysterious-egg, it needs to be retargeted before it can be merged.
author_association: NONE