replace stale web_editor with html_editor and html_builder for 19.0

web_editor was removed in Odoo 19.0 and replaced by html_editor
and html_builder. The old web_editor was incorrectly included in
the 19.0 vanilla import.

🤖 assisted by claude
This commit is contained in:
Ernad Husremovic 2026-03-09 15:31:13 +01:00
parent 4b94f0abc5
commit f866779561
1513 changed files with 396049 additions and 358525 deletions

View file

@ -0,0 +1,83 @@
.o-snippets-menu .btn {
--btn-font-weight: normal;
--btn-font-size: #{$o-we-font-size-sm};
--btn-box-shadow: 0 0;
--btn-focus-box-shadow: inset 0 0 0 #{$o-we-border-width} var(--o-hb-btn-active-color, #{$o-we-color-accent}), inset 0 0 0 #{$o-we-border-width * 2} #{$o-we-bg-light};
&:where(:not(.btn-lg)) {
--btn-padding-x: #{map-get($spacers , 1)};
--btn-padding-y: #{map-get($spacers , 1)};
}
@include button-variant(
$background: transparent,
$border: transparent,
$color: $o-we-fg-dark,
$hover-background: $o-we-bg-lighter,
$hover-border: transparent,
$hover-color: $o-we-fg-lighter,
$active-background: transparent,
$active-border: transparent,
$active-color: var(--o-hb-btn-active-color, #{$o-we-color-accent}),
$disabled-background: transparent,
$disabled-border: transparent,
$disabled-color: $o-we-fg-darker,
);
&.btn-secondary {
--btn-focus-box-shadow: inset 0 0 0 #{$o-we-border-width} var(--o-hb-btn-active-color, #{$o-we-color-accent});
@include button-variant(
$background: $o-we-item-clickable-bg,
$border: $o-we-bg-light,
$color: $o-we-item-clickable-color,
$hover-background: $o-we-item-clickable-hover-bg,
$hover-border: $o-we-bg-light,
$hover-color: $o-we-item-clickable-color,
$active-background: var(--o-hb-btn-secondary-active-bg, RGBA(#{to-rgb($o-we-color-accent)}, 0.4)),
$active-border: $o-we-bg-light,
$active-color: $o-we-fg-lighter,
$disabled-background: transparent,
$disabled-border: transparent,
$disabled-color: $o-we-fg-darker,
);
}
@each $-name, $-base in $o-we-colors {
&.btn-#{$-name} {
$-hover-base: scale-color($-base, $lightness: -10%);
$-active-base: scale-color($-base, $lightness: 10%);
$-disabled-base: mix($o-we-bg-dark, $-base);
@include button-variant(
$background: $-base,
$border: $o-we-bg-light,
$color: color-contrast($-base),
$hover-background: $-hover-base,
$hover-border: $o-we-bg-light,
$hover-color: color-contrast($-hover-base),
$active-background: $-active-base,
$active-border: $o-we-bg-light,
$active-color: color-contrast($-active-base),
$disabled-background: $-disabled-base,
$disabled-border: $-disabled-base,
$disabled-color: color-contrast($-disabled-base),
);
}
}
// Specific States color deviations
@each $-name, $-color in $o-we-colors {
&.btn-#{$-name}-color-hover {
--btn-hover-color: #{scale-color($-color, $lightness: 5%, $saturation: 100%)};
}
}
@each $-name, $-color in $o-we-colors {
&.btn-#{$-name}-color-active {
--btn-active-color: #{scale-color($-color, $lightness: 5%, $saturation: 100%)};
--btn-active-bg: #{$o-we-bg-light};
--btn-active-border-color: #{$o-we-bg-darker};
}
}
}

View file

@ -0,0 +1,341 @@
import { Editor } from "@html_editor/editor";
import {
Component,
EventBus,
onMounted,
onWillDestroy,
onWillStart,
onWillUnmount,
onWillUpdateProps,
status,
useRef,
useState,
useSubEnv,
} from "@odoo/owl";
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
import { _t } from "@web/core/l10n/translation";
import { SIZES, MEDIAS_BREAKPOINTS } from "@web/core/ui/ui_service";
import { useService } from "@web/core/utils/hooks";
import { addLoadingEffect as addButtonLoadingEffect } from "@web/core/utils/ui";
import { InvisibleElementsPanel } from "@html_builder/sidebar/invisible_elements_panel";
import { BlockTab } from "@html_builder/sidebar/block_tab";
import { CustomizeTab } from "@html_builder/sidebar/customize_tab";
import { useSnippets } from "@html_builder/snippets/snippet_service";
import { setBuilderCSSVariables } from "@html_builder/utils/utils_css";
import { withSequence } from "@html_editor/utils/resource";
import { getHtmlStyle } from "@html_editor/utils/formatting";
import { isVisible } from "@html_builder/utils/utils";
export class Builder extends Component {
static template = "html_builder.Builder";
static components = { BlockTab, CustomizeTab };
static props = {
closeEditor: { type: Function, optional: true },
reloadEditor: { type: Function, optional: true },
onEditorLoad: { type: Function, optional: true },
installSnippetModule: { type: Function, optional: true },
snippetsName: { type: String },
toggleMobile: { type: Function },
overlayRef: { type: Function },
iframeLoaded: { type: Object },
isMobile: { type: Boolean },
Plugins: { type: Array, optional: true },
config: { type: Object, optional: true },
getThemeTab: { type: Function, optional: true },
editableSelector: { type: String },
themeTabDisplayName: { type: String, optional: true },
slots: { type: Object, optional: true },
getCustomizeTranslationTab: { type: Function, optional: true },
};
static defaultProps = {
config: {},
themeTabDisplayName: _t("Theme"),
};
setup() {
this.ThemeTab = this.props.getThemeTab?.();
this.CustomizeTranslationTab = this.props.getCustomizeTranslationTab?.();
// const actionService = useService("action");
this.builder_sidebarRef = useRef("builder_sidebar");
this.state = useState({
canUndo: false,
canRedo: false,
activeTab: this.props.config.initialTab || "blocks",
currentOptionsContainers: undefined,
});
this.invisibleElementsPanelState = useState({
invisibleEls: [],
invisibleSelector: this.getInvisibleSelector(),
});
useHotkey("control+z", () => this.undo());
useHotkey("control+y", () => this.redo());
useHotkey("control+shift+z", () => this.redo());
this.orm = useService("orm");
this.ui = useService("ui");
this.notification = useService("notification");
this.snippetModel = useSnippets(this.props.snippetsName);
this.lastTrigerUpdateId = 0;
this.editorBus = new EventBus();
this.colorPresetToShow = null;
this.activeTargetEl = null;
const mobileBreakpoint = this.props.config.mobileBreakpoint ?? "lg";
// TODO: maybe do a different config for the translate mode and the
// "regular" mode.
this.editor = new Editor(
{
Plugins: this.props.Plugins,
...this.props.config,
mobileBreakpoint,
isMobileView: (targetEl) => {
const mobileViewThreshold =
MEDIAS_BREAKPOINTS[SIZES[mobileBreakpoint.toUpperCase()]].minWidth;
const clientWidth =
targetEl.ownerDocument.defaultView?.frameElement?.clientWidth ||
targetEl.ownerDocument.documentElement.clientWidth;
return !!clientWidth && clientWidth < mobileViewThreshold;
},
onChange: ({ isPreviewing }) => {
if (!isPreviewing) {
this.state.canUndo = this.editor.shared.history.canUndo();
this.state.canRedo = this.editor.shared.history.canRedo();
this.updateInvisibleEls();
this.editorBus.trigger("UPDATE_EDITING_ELEMENT");
this.triggerDomUpdated();
this.props.config.onChange?.();
}
},
reloadEditor: async (param = {}) => {
await this.props.reloadEditor?.({
initialTab: this.state.activeTab,
...param,
});
},
closeEditor: async () => {
await this.props.closeEditor?.();
},
installSnippetModule: (snippet) => this.props.installSnippetModule?.(snippet),
resources: {
trigger_dom_updated: () => {
this.triggerDomUpdated();
},
on_mobile_preview_clicked: withSequence(20, () => {
this.triggerDomUpdated();
}),
before_save_handlers: () => {
const snippetMenuEl = this.builder_sidebarRef.el;
const saveButton = snippetMenuEl.querySelector("[data-action='save']");
delete this.removeLoadingEffect;
if (saveButton) {
// Add a loading effect on the save button and disable the other actions
this.removeLoadingEffect = addButtonLoadingEffect(
snippetMenuEl.querySelector("[data-action='save']")
);
}
this.actionButtonEls = snippetMenuEl.querySelectorAll("[data-action]");
for (const actionButtonEl of this.actionButtonEls) {
actionButtonEl.disabled = true;
}
},
after_save_handlers: () => {
for (const actionButtonEl of this.actionButtonEls) {
actionButtonEl.removeAttribute("disabled");
}
this.removeLoadingEffect?.();
},
on_snippet_dropped_handlers: () => {
this.activeTargetEl = null;
},
change_current_options_containers_listeners: (currentOptionsContainers) => {
this.state.currentOptionsContainers = currentOptionsContainers;
if (!currentOptionsContainers.length) {
// If there is no option, fallback on the current
// fallback tab.
this.setTab(this.noSelectionTab);
return;
}
this.activeTargetEl = null;
this.setTab("customize");
},
lower_panel_entries: withSequence(20, {
Component: InvisibleElementsPanel,
props: this.invisibleElementsPanelState,
}),
unsplittable_node_predicates: (/** @type {Node} */ node) =>
node.querySelector?.("[data-oe-translation-source-sha]"),
can_display_toolbar: (namespace) => !["image", "icon"].includes(namespace),
// disable the toolbar for images and icons
},
localOverlayContainers: {
key: this.env.localOverlayContainerKey,
ref: this.props.overlayRef,
},
saveSnippet: (snippetEl, cleanForSaveHandlers, wrapWithSaveSnippetHandlers) =>
this.snippetModel.saveSnippet(
snippetEl,
cleanForSaveHandlers,
wrapWithSaveSnippetHandlers
),
snippetModel: this.snippetModel,
getShared: () => this.editor.shared,
updateInvisibleElementsPanel: () => this.updateInvisibleEls(),
allowCustomStyle: true,
allowTargetBlank: true,
dropImageAsAttachment: true,
getAnimateTextConfig: () => ({ editor: this.editor, editorBus: this.editorBus }),
baseContainers: ["P"],
cleanEmptyStructuralContainers: false,
isEditableRTL: false,
},
this.env.services
);
this.props.onEditorLoad?.(this.editor);
onWillStart(async () => {
await this.snippetModel.load();
// Ensure that the iframe is loaded and the editor is created before
// instantiating the sub components that potentially need the
// editor.
const iframeEl = await this.props.iframeLoaded;
if (status(this) === "destroyed") {
return;
}
this.editableEl = iframeEl.contentDocument.body.querySelector(
this.props.editableSelector
);
if (this.editableEl.matches(".o_rtl")) {
this.editor.config.isEditableRTL = true;
}
// Prevent image dragging in the website builder. Not via css because
// if one of the image ancestor has a dragstart listener, the dragstart handler
// can be called with the image as target.
this.onDragStart = (ev) => {
if (ev.target.nodeName === "IMG") {
ev.preventDefault();
ev.stopPropagation();
}
};
this.editor.attachTo(this.editableEl);
});
useSubEnv({
editor: this.editor,
editorBus: this.editorBus,
triggerDomUpdated: this.triggerDomUpdated.bind(this),
editColorCombination: this.editColorCombination.bind(this),
});
onWillDestroy(() => {
this.editor.destroy();
});
onMounted(() => {
this.editor.document.body.classList.add("editor_enable");
setBuilderCSSVariables(getHtmlStyle(this.editor.document));
// TODO: onload editor
this.updateInvisibleEls();
this.editableEl.addEventListener("dragstart", this.onDragStart);
});
onWillUnmount(() => {
this.editableEl.removeEventListener("dragstart", this.onDragStart);
});
onWillUpdateProps((nextProps) => {
if (nextProps.isMobile !== this.props.isMobile) {
this.updateInvisibleEls(nextProps.isMobile);
this.invisibleElementsPanelState.invisibleSelector = this.getInvisibleSelector(
nextProps.isMobile
);
}
});
// Fallback tab when no option is active.
this.noSelectionTab = "blocks";
}
async triggerDomUpdated() {
this.lastTrigerUpdateId++;
const currentTriggerId = this.lastTrigerUpdateId;
const getStatePromises = [];
const { promise: updatePromise, resolve } = Promise.withResolvers();
this.editorBus.trigger("DOM_UPDATED", { getStatePromises, updatePromise });
await Promise.all(getStatePromises);
const isLastTriggerId = this.lastTrigerUpdateId === currentTriggerId;
resolve(isLastTriggerId);
}
get displayOnlyCustomizeTab() {
return this.props.config.isTranslationMode;
}
getInvisibleSelector(isMobile = this.props.isMobile) {
return `.o_snippet_invisible, ${
isMobile ? ".o_snippet_mobile_invisible" : ".o_snippet_desktop_invisible"
}`;
}
/**
* Called when clicking on a tab. Sets the active tab to the given tab.
*
* @param {String} tab the tab to set
* @param {Number | null} presetId the color preset expanding on "theme" tab
* open.
*/
onTabClick(tab, presetId = null) {
if (this.state.activeTab === tab) {
// If the tab is already active, do nothing.
return;
}
this.setTab(tab);
// Deactivate the options when clicking on the "BLOCKS" or "THEME" tabs.
if (tab === "theme" || tab === "blocks") {
this.colorPresetToShow = presetId;
this.activeTargetEl = this.activeTargetEl || this.getActiveTarget();
this.editor.shared["builderOptions"].deactivateContainers();
} else if (this.activeTargetEl) {
if (isVisible(this.activeTargetEl)) {
// Reactivate the previously active element.
this.editor.shared["builderOptions"].updateContainers(this.activeTargetEl);
}
this.activeTargetEl = null;
}
}
setTab(tab) {
this.state.activeTab = tab;
// Set the fallback tab on the "THEME" tab if it was selected.
this.noSelectionTab = tab === "theme" ? "theme" : "blocks";
}
undo() {
this.editor.shared.history.undo();
}
redo() {
this.editor.shared.history.redo();
}
onMobilePreviewClick() {
this.props.toggleMobile();
this.editor.resources["on_mobile_preview_clicked"].forEach((handler) => handler());
}
updateInvisibleEls(isMobile = this.props.isMobile) {
this.invisibleElementsPanelState.invisibleEls = [
...this.editor.editable.querySelectorAll(this.getInvisibleSelector(isMobile)),
];
}
lowerPanelEntries() {
return this.editor.resources["lower_panel_entries"] ?? [];
}
editColorCombination(presetId) {
this.onTabClick("theme", presetId);
}
getActiveTarget() {
return this.editor.shared["builderOptions"].getContainers().at(-1)?.element;
}
}

View file

@ -0,0 +1,219 @@
.o-snippets-menu {
background-color: $o-we-bg-darker;
font-family: $o-we-font-family;
font-size: $o-we-font-size;
color: $o-we-fg-light;
width: $o-we-sidebar-width;
}
.o-snippets-top-actions {
border-bottom: $o-we-border-width solid $o-we-bg-lighter;
height: 46px;
.o_rtl & {
.fa-undo, .fa-repeat {
transform: scaleX(-1);
}
}
}
.o-snippets-top-actions, .o-snippets-tabs {
.o-hb-btn {
--btn-font-size: #{$o-we-font-size};
--btn-padding-x: #{map-get($spacers, 2)};
flex: 1 auto;
}
}
.o-snippets-tabs {
.o-snippets-tabs-highlighter {
--x: -50%;
--border-color: #{$o-we-color-accent};
--bg-color: #{rgba($o-we-color-accent, .15)};
width: percentage(1/3);
transition: all .5s, transform .25s;
border-bottom: #{$o-we-sidebar-tabs-active-border-width} solid var(--border-color);
background-color: var(--bg-color);
transform: translateX(var(--x));
&.o-highlight-blocks {
--x: -150%;
--border-color: #{$o-we-color-success};
--bg-color: #{rgba($o-we-color-success, .15)};
}
&.o-highlight-theme {
--x: 50%;
--border-color: #{$o-we-color-global};
--bg-color: #{rgba($o-we-color-global, .15)};
}
.o_rtl & {
--x: 50%;
&.o-highlight-blocks {
--x: 150%;
}
&.o-highlight-theme {
--x: -50%;
}
}
}
.nav-underline {
--nav-underline-gap: 0;
--nav-link-color: #{$o-we-fg-light};
--nav-link-hover-color: #{$o-we-fg-lighter};
--nav-underline-link-active-color: #{$o-we-fg-light};
.nav-item {
width: percentage(1/3);
}
.nav-link {
transition: none;
}
.nav-link[data-name="blocks"]{
--o-snippets-tabs-accent-color: #{$o-we-color-success};
--o-snippets-tabs-border-color-hover: #{rgba($o-we-color-success, .5)};
}
.nav-link[data-name="customize"] {
--o-snippets-tabs-accent-color: #{$o-we-color-accent};
--o-snippets-tabs-border-color-hover: #{rgba($o-we-color-accent, .5)};
}
.nav-link[data-name="theme"] {
--o-snippets-tabs-accent-color: #{$o-we-color-global};
--o-snippets-tabs-border-color-hover: #{rgba($o-we-color-global, .5)};
}
.nav-link.active, .nav-link:hover, & .show > .nav-link {
border-bottom-color: var(--o-snippets-tabs-border-color-hover);
}
.nav-link:focus-visible {
box-shadow: none;
outline: 1px solid var(--o-snippets-tabs-accent-color);
outline-offset: -1px;
border-bottom-color: transparent;
}
& .fa {
color: var(--o-snippets-tabs-accent-color);
}
}
}
.o-tab-content {
border-top: $o-we-border-width solid $o-we-bg-lighter;
scrollbar-color: $o-we-bg-lightest $o-we-bg-darker;
scrollbar-width: thin;
will-change: scroll-position;
}
.o_theme_tab {
--o-hb-btn-active-color: #{$o-we-color-global};
--o-hb-btn-secondary-active-bg: RGBA(#{to-rgb($o-we-color-global)}, 0.4);
--o-hb-input-active-border: #{$o-we-color-global};
--o-hb-form-switch-color-active: #{$o-we-color-global};
}
.o_we_color_preview {
@extend %o-preview-alpha-background;
flex: 0 0 auto;
display: block;
width: $o-we-sidebar-content-field-colorpicker-size;
height: $o-we-sidebar-content-field-colorpicker-size;
border: $o-we-sidebar-content-field-border-width solid $o-we-bg-darkest;
background: unset;
border-radius: 10rem;
cursor: pointer;
&::after {
content: "" !important;
box-shadow: $o-we-sidebar-content-field-colorpicker-shadow;
}
&:active, &:focus-visible {
outline: #{$o-we-border-width} solid var(--o-hb-btn-active-color, #{$o-we-color-accent});
}
}
.o_we_lower_panel {
max-height: 220px;
overflow-y: auto;
background-color: $o-we-sidebar-blocks-content-bg;
box-shadow: $o-we-item-standup-top $o-we-bg-lighter;
&:has(*) {
padding: $o-we-sidebar-blocks-content-spacing;
}
}
.o_we_invisible_el_panel {
.o_panel_header {
padding: $o-we-sidebar-content-field-spacing 0;
}
.o_we_invisible_entry {
padding: $o-we-sidebar-content-field-spacing $o-we-sidebar-content-field-clickable-spacing;
cursor: pointer;
&:hover {
background-color: $o-we-sidebar-bg;
}
}
ul {
list-style: none;
padding-inline-start: 15px;
margin-bottom: $o-we-sidebar-content-field-spacing - 4px;
}
}
%o_we_sublevel > div.o_title::before {
content: ""; // TODO The size and look of this depends on the
// browser default font, we should use a SVG instead.
display: inline-block;
margin-right: 0.4em;
.o_rtl & {
transform: scaleX(-1);
}
}
@for $level from 1 through 3 {
.o_we_sublevel_#{$level} {
--o-we-checkbox-color: #{$o-we-fg-light};
@extend %o_we_sublevel;
color: $o-we-fg-light;
@if $level > 1 {
> div.o_title {
padding-left: ($level - 1) * 0.6em;
}
}
}
}
// TODO: adjust the style of those elements
.o_pager_container {
overflow-y: scroll;
scroll-behavior: smooth;
}
// TODO Gray scale HUE slider
.o_we_slider_tint input[type="range"] {
appearance: none;
&::-webkit-slider-thumb {
appearance: auto !important;
}
&::-moz-range-thumb {
appearance: auto !important;
}
}

View file

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.Builder">
<div class="h-100 o-snippets-menu d-flex flex-column" t-ref="builder_sidebar">
<div class="o-snippets-top-actions d-flex justify-content-between flex-shrink-0 p-2">
<div class="d-flex gap-1">
<button type="button" t-on-click="() => this.undo()" class="o-hb-btn btn fa fa-undo" t-att-disabled="!state.canUndo"/>
<button type="button" t-on-click="() => this.redo()" class="o-hb-btn btn fa fa-repeat" t-att-disabled="!state.canRedo"/>
</div>
<div class="d-flex gap-1">
<button t-on-click="onMobilePreviewClick" type="button" class="o-hb-btn btn btn-success-color-active d-flex align-items-center" t-att-class="{ active: props.isMobile }" data-action="mobile" title="Mobile Preview" accesskey="m" style="--btn-font-size: 20px"><span class="fa fa-mobile" role="img"/></button>
<t t-if="props.slots?.extraActions" t-slot="extraActions"/>
</div>
</div>
<div t-if="!props.config.isTranslationMode" class="o-snippets-tabs position-relative">
<div
class="o-snippets-tabs-highlighter position-absolute start-50 h-100 pe-none"
t-att-class="{
'o-highlight-blocks': state.activeTab === 'blocks',
'o-highlight-theme': state.activeTab === 'theme',
}"
/>
<ul class="nav nav-underline" role="tablist">
<li class="nav-item" role="presentation">
<button id="blocks-tab" data-name="blocks" data-hotkey="1" class="o-hb-tab nav-link position-relative w-100" t-att-class="{'active cursor-default': state.activeTab === 'blocks'}" t-on-click="() => this.onTabClick('blocks')" t-att-disabled="displayOnlyCustomizeTab" role="tab" t-att-aria-selected="state.activeTab === 'blocks' ? 'true' : 'false'">
<i class="fa fa-th-large me-2" role="none"/>Blocks
</button>
</li>
<li class="nav-item" role="presentation">
<button id="customize-tab" data-name="customize" class="o-hb-tab nav-link position-relative w-100" t-att-class="{'active cursor-default': state.activeTab === 'customize'}" t-on-click="() => this.onTabClick('customize')" role="tab" t-att-aria-selected="state.activeTab === 'customize' ? 'true' : 'false'">
<i class="fa fa-paint-brush me-2" role="none"/>Style
</button>
</li>
<li class="nav-item" role="presentation">
<button id="theme-tab" data-name="theme" data-hotkey="2" t-if="ThemeTab" class="o-hb-tab nav-link position-relative w-100" t-att-class="{'active cursor-default': state.activeTab === 'theme'}" t-on-click="() => this.onTabClick('theme')" t-att-disabled="displayOnlyCustomizeTab" role="tab" t-att-aria-selected="state.activeTab === 'theme' ? 'true' : 'false'">
<i class="fa fa-cog me-2" role="none"/><t t-out="props.themeTabDisplayName"/>
</button>
</li>
</ul>
</div>
<div class="o-tab-content overflow-y-auto overflow-x-hidden flex-grow-1">
<t t-if="state.activeTab === 'blocks'">
<BlockTab snippetsName="props.snippetsName" />
</t>
<t t-if="state.activeTab === 'customize'">
<t t-if="props.config.isTranslationMode" t-component="CustomizeTranslationTab"/>
<CustomizeTab t-else="" currentOptionsContainers="state.currentOptionsContainers" snippetModel="snippetModel"/>
</t>
<t t-if="state.activeTab === 'theme'">
<t t-component="ThemeTab" colorPresetToShow="colorPresetToShow"/>
</t>
</div>
<div class="o_we_lower_panel mt-auto flex-grow-0 flex-shrink-0">
<t t-foreach="lowerPanelEntries()" t-as="entry" t-key="entry_index">
<t t-component="entry.Component" t-props="entry.props"/>
</t>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,38 @@
import { Component, useRef, useState } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { Dialog } from "@web/core/dialog/dialog";
export class AnchorDialog extends Component {
static template = "html_builder.AnchorDialog";
static components = { Dialog };
static props = {
currentAnchorName: { type: String },
renameAnchor: { type: Function },
deleteAnchor: { type: Function },
formatAnchor: { type: Function },
close: { type: Function },
};
setup() {
this.title = _t("Link Anchor");
this.inputRef = useRef("anchor-input");
this.state = useState({ isValid: true });
}
async onConfirmClick() {
const newAnchorName = this.props.formatAnchor(this.inputRef.el.value);
if (newAnchorName === this.props.currentAnchorName) {
this.props.close();
}
this.state.isValid = await this.props.renameAnchor(newAnchorName);
if (this.state.isValid) {
this.props.close();
}
}
onRemoveClick() {
this.props.deleteAnchor();
this.props.close();
}
}

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="html_builder.AnchorDialog">
<Dialog title="this.title" size="'lg'">
<div class="mb-3 row">
<label class="col-form-label col-md-3" for="anchorName">Choose an anchor name</label>
<div class="col-md-9">
<input t-ref="anchor-input" type="text" class="form-control" id="anchorName"
t-att-class="{'is-invalid': !state.isValid}"
t-attf-value="#{props.currentAnchorName}" placeholder="Anchor name"/>
<div class="invalid-feedback">
<p t-att-class="{'d-none': state.isValid}">The chosen name already exists</p>
</div>
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="onConfirmClick">Save &amp; Copy</button>
<button class="btn btn-secondary" t-on-click="props.close">Discard</button>
<button class="btn btn-link ms-auto" t-on-click="onRemoveClick">
<i class="fa fa-icon fa-trash"/>
Remove
</button>
</t>
</Dialog>
</t>
</templates>

View file

@ -0,0 +1,134 @@
import { Plugin } from "@html_editor/plugin";
import { withSequence } from "@html_editor/utils/resource";
import { browser } from "@web/core/browser/browser";
import { _t } from "@web/core/l10n/translation";
import { markup } from "@odoo/owl";
import { AnchorDialog } from "./anchor_dialog";
import { getElementsWithOption } from "@html_builder/utils/utils";
const anchorSelector = ":not(p).oe_structure > *, :not(p)[data-oe-type=html] > *";
const anchorExclude =
".modal *, .oe_structure .oe_structure *, [data-oe-type=html] .oe_structure *, .s_popup";
export function canHaveAnchor(element) {
return element.matches(anchorSelector) && !element.matches(anchorExclude);
}
export class AnchorPlugin extends Plugin {
static id = "anchor";
static dependencies = ["history"];
static shared = ["createOrEditAnchorLink"];
resources = {
on_cloned_handlers: this.onCloned.bind(this),
get_options_container_top_buttons: withSequence(
0,
this.getOptionsContainerTopButtons.bind(this)
),
};
onCloned({ cloneEl }) {
const anchorEls = getElementsWithOption(cloneEl, anchorSelector, anchorExclude);
anchorEls.forEach((anchorEl) => this.deleteAnchor(anchorEl));
}
getOptionsContainerTopButtons(el) {
if (!canHaveAnchor(el)) {
return [];
}
return [
{
class: "fa fa-fw fa-link oe_snippet_anchor btn o-hb-btn btn-accent-color-hover",
title: _t("Create and copy a link targeting this block or edit it"),
handler: this.createOrEditAnchorLink.bind(this),
},
];
}
// TODO check if no other way when doing popup options.
isModal(element) {
return element.classList.contains("modal");
}
setAnchorName(element, value) {
if (value) {
element.id = value;
if (!this.isModal(element)) {
element.dataset.anchor = true;
}
} else {
this.deleteAnchor(element);
}
this.dependencies.history.addStep();
}
createAnchor(element) {
const titleEls = element.querySelectorAll("h1, h2, h3, h4, h5, h6");
const title = titleEls.length > 0 ? titleEls[0].innerText : element.dataset.name;
const anchorName = this.formatAnchor(title);
let n = "";
while (this.document.getElementById(anchorName + n)) {
n = (n || 1) + 1;
}
this.setAnchorName(element, anchorName + n);
}
deleteAnchor(element) {
element.removeAttribute("data-anchor");
element.removeAttribute("id");
}
getAnchorLink(element) {
const pathName = this.isModal(element) ? "" : this.document.location.pathname;
return `${pathName}#${element.id}`;
}
async createOrEditAnchorLink(element) {
if (!element.id) {
this.createAnchor(element);
}
const anchorLink = this.getAnchorLink(element);
await browser.navigator.clipboard.writeText(anchorLink);
const message = _t("Anchor copied to clipboard%(br)sLink: %(anchorLink)s", {
anchorLink,
br: markup`<br>`,
});
const closeNotification = this.services.notification.add(message, {
type: "success",
buttons: [
{
name: _t("Edit"),
primary: true,
onClick: () => {
closeNotification();
// Open the "rename anchor" dialog.
this.services.dialog.add(AnchorDialog, {
currentAnchorName: decodeURIComponent(element.id),
renameAnchor: async (anchorName) => {
const alreadyExists = !!this.document.getElementById(anchorName);
if (alreadyExists) {
return false;
}
this.setAnchorName(element, anchorName);
await this.createOrEditAnchorLink(element);
return true;
},
deleteAnchor: () => {
this.deleteAnchor(element);
this.dependencies.history.addStep();
},
formatAnchor: this.formatAnchor,
});
},
},
],
});
}
formatAnchor(text) {
return encodeURIComponent(text.trim().replace(/\s+/g, "-"));
}
}

View file

@ -0,0 +1,89 @@
export class BuilderAction {
static dependencies = [];
constructor(plugin, dependencies) {
this.window = plugin.window;
this.document = plugin.document;
this.editable = plugin.editable;
this.dependencies = dependencies;
this.services = plugin.services;
this.config = plugin.config;
this.getResource = plugin.getResource.bind(plugin);
this.dispatchTo = plugin.dispatchTo.bind(plugin);
this.delegateTo = plugin.delegateTo.bind(plugin);
this.setup();
// Preview is enabled by default in non-reload actions,
// and disabled by default in reload actions.
this.preview ??= this.reload ? false : true;
this.withLoadingEffect ??= true;
this.loadOnClean ??= false;
}
/**
* Called after dependencies and services are assigned.
* Subclasses override this instead of the constructor.
*/
setup() {
// Optional override in subclasses
}
prepare(context) {}
getPriority(context) {}
/**
* Apply the action on the editing element.
* @param {Object} context
* @param {boolean} context.isPreviewing
* @param {HTMLElement} context.editingElement
* @param {any} context.value
* @param {Object} [context.params]
*/
apply(context) {}
/**
* Return the current value of the action on the element.
* @param {Object} context
* @param {HTMLElement} context.editingElement
* @param {Object} [context.params]
* @returns {any}
*/
getValue(context) {}
/**
* Whether the action is already applied.
* @param {Object} context
* @param {HTMLElement} context.editingElement
* @param {any} context.value
* @param {Object} [context.params]
* @returns {boolean}
*/
isApplied(context) {}
/**
* Clean/reset the value if needed.
* @param {Object} context
* @param {boolean} context.isPreviewing
* @param {HTMLElement} context.editingElement
* @param {any} context.value
* @param {Object} [context.params]
*/
clean(context) {}
/**
* Load the options if needed.
* @param {Object} context
* @param {HTMLElement} context.editingElement
*/
async load(context) {}
/**
* Check if a method has been overridden.
* @param {string} method
* @returns {boolean}
*/
has(method) {
const baseMethod = BuilderAction.prototype[method];
const actualMethod = this.constructor.prototype[method];
return baseMethod !== actualMethod;
}
}

View file

@ -0,0 +1,70 @@
import { Plugin } from "@html_editor/plugin";
/**
* @typedef {Class} BuilderAction
* @property {string} id
* @property {Function} apply
* @property {Function} [isApplied]
* @property {Function} [clean]
* @property {() => Promise<any>} [load]
*/
export class BuilderActionsPlugin extends Plugin {
static id = "builderActions";
static shared = ["getAction", "applyAction"];
static dependencies = ["operation", "history"];
setup() {
this.actions = {};
for (const actions of this.getResource("builder_actions")) {
for (const Action of Object.values(actions)) {
if (Action.id in this.actions) {
throw new Error(`Duplicate builder action id: ${Action.id}`);
}
const deps = {};
for (const depName of Action.dependencies) {
deps[depName] = this.config.getShared()[depName];
}
this.actions[Action.id] = new Action(this, deps);
}
}
Object.freeze(this.actions);
}
/**
* Get the action object for the given action ID.
*
* @param {string} actionId
* @returns {Object}
*/
getAction(actionId) {
const action = this.actions[actionId];
if (!action) {
throw new Error(`Unknown builder action id: ${actionId}`);
}
return action;
}
/**
* Apply action for the given action ID.
*
* @param {string} actionId
* @param {Object} spec
*/
applyAction(actionId, spec) {
const action = this.getAction(actionId);
this.dependencies.operation.next(
async () => {
await action.apply(spec);
this.dependencies.history.addStep();
},
{
...action,
load: async () => {
if (action.load) {
const loadResult = await action.load(spec);
spec.loadResult = loadResult;
}
},
}
);
}
}

View file

@ -0,0 +1,71 @@
import { BuilderList } from "@html_builder/core/building_blocks/builder_list";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { BuilderButtonGroup } from "./building_blocks/builder_button_group";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { BuilderDateTimePicker } from "./building_blocks/builder_datetimepicker";
import { BuilderRow } from "./building_blocks/builder_row";
import { BuilderButton } from "./building_blocks/builder_button";
import { BuilderNumberInput } from "./building_blocks/builder_number_input";
import { BuilderSelect } from "./building_blocks/builder_select";
import { BuilderSelectItem } from "./building_blocks/builder_select_item";
import { BuilderColorPicker } from "./building_blocks/builder_colorpicker";
import { BuilderTextInput } from "./building_blocks/builder_text_input";
import { BuilderCheckbox } from "./building_blocks/builder_checkbox";
import { BuilderRange } from "./building_blocks/builder_range";
import { BuilderContext } from "./building_blocks/builder_context";
import { BasicMany2Many } from "./building_blocks/basic_many2many";
import { BuilderMany2Many } from "./building_blocks/builder_many2many";
import { BuilderMany2One } from "./building_blocks/builder_many2one";
import { ModelMany2Many } from "./building_blocks/model_many2many";
import { Plugin } from "@html_editor/plugin";
import { Img } from "./img";
import { BuilderUrlPicker } from "./building_blocks/builder_urlpicker";
import { BuilderFontFamilyPicker } from "./building_blocks/builder_fontfamilypicker";
export class BuilderComponentPlugin extends Plugin {
static id = "builderComponents";
static shared = ["getComponents"];
resources = {
builder_components: {
BuilderContext,
BuilderFontFamilyPicker,
BuilderRow,
BuilderUrlPicker,
Dropdown,
DropdownItem,
BuilderButtonGroup,
BuilderButton,
BuilderTextInput,
BuilderNumberInput,
BuilderRange,
BuilderColorPicker,
BuilderSelect,
BuilderSelectItem,
BuilderCheckbox,
BasicMany2Many,
BuilderMany2Many,
BuilderMany2One,
ModelMany2Many,
BuilderDateTimePicker,
BuilderList,
Img,
},
};
setup() {
this.Components = {};
for (const r of this.getResource("builder_components")) {
for (const C in r) {
if (C in this.Components) {
throw new Error(`Duplicated builder component: ${C}`);
}
this.Components[C] = r[C];
}
}
}
getComponents() {
return this.Components;
}
}

View file

@ -0,0 +1,37 @@
import { Plugin } from "@html_editor/plugin";
import { registry } from "@web/core/registry";
export class BuilderContentEditablePlugin extends Plugin {
static id = "builderContentEditablePlugin";
resources = {
force_not_editable_selector: [
"section:has(> .o_container_small, > .container, > .container-fluid)",
".o_not_editable",
"[data-oe-field='arch']:empty",
],
force_editable_selector: [
"section > .o_container_small",
"section > .container",
"section > .container-fluid",
".o_editable",
],
filter_contenteditable_handlers: this.filterContentEditable.bind(this),
contenteditable_to_remove_selector: "[contenteditable]",
};
setup() {
this.editable.setAttribute("contenteditable", false);
}
filterContentEditable(contentEditableEls) {
return contentEditableEls.filter(
(el) =>
!el.matches("input, [data-oe-readonly]") &&
el.closest(".o_editable") &&
!el.closest(".o_not_editable")
);
}
}
registry
.category("builder-plugins")
.add(BuilderContentEditablePlugin.id, BuilderContentEditablePlugin);

View file

@ -0,0 +1,500 @@
import { Plugin } from "@html_editor/plugin";
import { uniqueId } from "@web/core/utils/functions";
import { isRemovable } from "./remove_plugin";
import { isClonable } from "./clone_plugin";
import { getElementsWithOption, isElementInViewport } from "@html_builder/utils/utils";
import { shouldEditableMediaBeEditable } from "@html_builder/utils/utils_css";
export class BuilderOptionsPlugin extends Plugin {
static id = "builderOptions";
static dependencies = [
"selection",
"overlay",
"operation",
"history",
"builderOverlay",
"overlayButtons",
];
static shared = [
"computeContainers",
"findOption",
"getContainers",
"updateContainers",
"deactivateContainers",
"getTarget",
"getPageContainers",
"getRemoveDisabledReason",
"getCloneDisabledReason",
"getReloadSelector",
"setNextTarget",
];
resources = {
before_add_step_handlers: this.onWillAddStep.bind(this),
step_added_handlers: this.onStepAdded.bind(this),
post_undo_handlers: (revertedStep) => this.restoreContainers(revertedStep, "undo"),
post_redo_handlers: (revertedStep) => this.restoreContainers(revertedStep, "redo"),
clean_for_save_handlers: this.cleanForSave.bind(this),
// Resources definitions:
remove_disabled_reason_providers: [
// ({ el, reasons }) => {
// reasons.push(`I hate ${el.dataset.name}`);
// }
],
clone_disabled_reason_providers: [
// ({ el, reasons }) => {
// reasons.push(`I hate ${el.dataset.name}`);
// }
],
start_edition_handlers: () => {
if (this.config.initialTarget) {
const el = this.editable.querySelector(this.config.initialTarget);
this.updateContainers(el);
}
},
};
setup() {
this.builderOptions = withIds(this.getResource("builder_options"));
this.elementsToOptionsTitleComponents = withIds(
this.getResource("elements_to_options_title_components")
);
this.getResource("patch_builder_options").forEach((option) => {
this.patchBuilderOptions(option);
});
this.builderHeaderMiddleButtons = withIds(
this.getResource("builder_header_middle_buttons")
);
this.builderContainerTitle = withIds(this.getResource("container_title"));
// doing this manually instead of using addDomListener. This is because
// addDomListener will ignore all events from protected targets. But in
// our case, we still want to update the containers.
this.onClick = this.onClick.bind(this);
this.editable.addEventListener("click", this.onClick, { capture: true });
this.lastContainers = [];
// Selector of elements that should not update/have containers when they
// are clicked.
this.notActivableElementsSelector = [
"#web_editor-top-edit",
"#oe_manipulators",
".oe_drop_zone",
".o_notification_manager",
".o_we_no_overlay",
".ui-autocomplete",
".modal .btn-close",
".transfo-container",
".o_datetime_picker",
].join(", ");
}
destroy() {
this.editable.removeEventListener("click", this.onClick, { capture: true });
}
onClick(ev) {
this.dependencies.operation.next(() => {
this.updateContainers(ev.target);
});
}
getReloadSelector(editingElement) {
for (const container of [...this.lastContainers].reverse()) {
for (const option of container.options) {
if (option.reloadTarget) {
return option.selector;
}
}
}
if (editingElement.closest("header")) {
return "header";
}
if (editingElement.closest("main")) {
return "main";
}
if (editingElement.closest("footer")) {
return "footer";
}
return null;
}
updateContainers(target, { forceUpdate = false } = {}) {
if (this.dependencies.history.getIsCurrentStepModified()) {
console.warn(
"Should not have any mutations in the current step when you update the container selection"
);
}
if (this.dependencies.history.getIsPreviewing()) {
return;
}
if (target) {
if (target.closest(this.notActivableElementsSelector)) {
return;
}
this.target = target;
}
if (!this.target || !this.target.isConnected) {
const connectedContainers = this.lastContainers.filter((c) => c.element.isConnected);
this.target = connectedContainers.at(-1)?.element;
}
const newContainers = this.computeContainers(this.target);
// Do not update the containers if they did not change and are not
// forced to update.
if (
!forceUpdate &&
this.target?.isConnected &&
newContainers.length === this.lastContainers.length
) {
const previousIds = this.lastContainers.map((c) => c.id);
const newIds = newContainers.map((c) => c.id);
const areSameElements = newIds.every((id, i) => id === previousIds[i]);
// Check if the overlay options status changed.
const previousOverlays = this.lastContainers.map((c) => c.hasOverlayOptions);
const newOverlays = newContainers.map((c) => c.hasOverlayOptions);
const areSameOverlays = previousOverlays.every((check, i) => check === newOverlays[i]);
if (areSameElements && areSameOverlays) {
const previousOptions = this.lastContainers.flatMap((c) => [
...c.options,
...c.headerMiddleButtons,
c.containerTitle,
]);
const newOptions = newContainers.flatMap((c) => [
...c.options,
...c.headerMiddleButtons,
c.containerTitle,
]);
const areSameOptions =
newOptions.length === previousOptions.length &&
newOptions.every((option, i) => option.id === previousOptions[i].id);
if (areSameOptions) {
return;
}
}
}
this.lastContainers = newContainers;
this.dispatchTo("change_current_options_containers_listeners", this.lastContainers);
}
getTarget() {
return this.target;
}
deactivateContainers() {
this.target = null;
this.lastContainers = [];
this.dispatchTo("change_current_options_containers_listeners", this.lastContainers);
}
computeContainers(target) {
const mapElementsToOptions = (options) => {
const map = new Map();
for (const option of options) {
const { selector, exclude, editableOnly } = option;
let elements = getClosestElements(target, selector);
if (!elements.length) {
continue;
}
elements = elements.filter((el) => checkElement(el, { exclude, editableOnly }));
for (const element of elements) {
if (map.has(element)) {
map.get(element).push(option);
} else {
map.set(element, [option]);
}
}
}
return map;
};
const elementToOptions = mapElementsToOptions(this.builderOptions);
const elementToHeaderMiddleButtons = mapElementsToOptions(this.builderHeaderMiddleButtons);
const elementToContainerTitle = mapElementsToOptions(this.builderContainerTitle);
const elementToOptionTitleComponents = mapElementsToOptions(
this.elementsToOptionsTitleComponents
);
// Find the closest element with no options that should still have the
// overlay buttons.
let element = target;
while (element && !elementToOptions.has(element)) {
if (this.hasOverlayOptions(element)) {
elementToOptions.set(element, []);
break;
}
element = element.parentElement;
}
const previousElementToIdMap = new Map(this.lastContainers.map((c) => [c.element, c.id]));
let containers = [...elementToOptions]
.sort(([a], [b]) => (b.contains(a) ? 1 : -1))
.map(([element, options]) => ({
id: previousElementToIdMap.get(element) || uniqueId(),
element,
options,
optionTitleComponents: elementToOptionTitleComponents.get(element) || [],
headerMiddleButtons: elementToHeaderMiddleButtons.get(element) || [],
containerTitle: elementToContainerTitle.get(element)
? elementToContainerTitle.get(element)[0]
: {},
hasOverlayOptions: this.hasOverlayOptions(element),
isRemovable: isRemovable(element),
removeDisabledReason: this.getRemoveDisabledReason(element),
isClonable: isClonable(element),
cloneDisabledReason: this.getCloneDisabledReason(element),
optionsContainerTopButtons: this.getOptionsContainerTopButtons(element),
}));
const lastValidContainerIdx = containers.findLastIndex((c) =>
this.getResource("no_parent_containers").some((selector) => c.element.matches(selector))
);
if (lastValidContainerIdx > 0) {
containers = containers.slice(lastValidContainerIdx);
}
return containers;
}
getPageContainers() {
return this.computeContainers(this.editable.querySelector("main"));
}
getContainers() {
return this.lastContainers;
}
hasOverlayOptions(el) {
// An inner snippet alone in a column should not have overlay options.
const parentEl = el.parentElement;
const isAloneInColumn = parentEl?.children.length === 1 && parentEl.matches(".row > div");
const isInnerSnippet = this.config.snippetModel.isInnerContent(el);
const keepOptions = this.delegateTo("keep_overlay_options", el);
if (isInnerSnippet && isAloneInColumn && !keepOptions) {
return false;
}
for (const { hasOption, editableOnly } of this.getResource("has_overlay_options")) {
if (checkElement(el, { editableOnly }) && hasOption(el)) {
return true;
}
}
return false;
}
getOptionsContainerTopButtons(el) {
const buttons = [];
for (const getContainerButtons of this.getResource("get_options_container_top_buttons")) {
buttons.push(...getContainerButtons(el));
for (const button of buttons) {
const handler = button.handler;
button.handler = (...args) => {
this.dependencies.operation.next(async () => {
await handler(...args);
this.dependencies.history.addStep();
});
};
}
}
return buttons;
}
cleanForSave({ root }) {
for (const option of this.builderOptions) {
const { selector, exclude, cleanForSave } = option;
if (!cleanForSave) {
continue;
}
for (const el of getElementsWithOption(root, selector, exclude)) {
cleanForSave(el);
}
}
}
/**
* Activates the containers of the given element or deactivate them if false
* is given. They will be (de)activated once the current step is added (see
* `onStepAdded`).
*
* @param {HTMLElement|Boolean} targetEl the element to activate or `false`
*/
setNextTarget(targetEl) {
if (this.dependencies.history.getIsPreviewing()) {
return;
}
// Store the next target to activate in the current step.
this.dependencies.history.setStepExtra("nextTarget", targetEl);
}
onWillAddStep() {
// Store the current target in the current step.
this.dependencies.history.setStepExtra("currentTarget", this.target);
}
onStepAdded({ step }) {
// If a target is specified, activate its containers, otherwise simply
// update them.
const nextTargetEl = step.extraStepInfos.nextTarget;
if (nextTargetEl) {
this.updateContainers(nextTargetEl, { forceUpdate: true });
} else if (nextTargetEl === false) {
this.deactivateContainers();
} else {
this.updateContainers();
}
}
/**
* Restores the containers of the target stored in the reverted step.
*
* @param {Object} revertedStep the step
* @param {String} mode "undo" or "redo"
*/
restoreContainers(revertedStep, mode) {
if (revertedStep && revertedStep.extraStepInfos.currentTarget) {
let targetEl = revertedStep.extraStepInfos.currentTarget;
// If the step was supposed to activate another target, activate
// this one instead.
const nextTarget = revertedStep.extraStepInfos.nextTarget;
if (mode === "redo" && (nextTarget || nextTarget === false)) {
targetEl = nextTarget;
}
if (targetEl) {
this.dispatchTo("on_restore_containers_handlers", targetEl);
this.updateContainers(targetEl, { forceUpdate: true });
// Scroll to the target if not visible.
if (!isElementInViewport(targetEl)) {
targetEl.scrollIntoView({ behavior: "smooth", block: "center" });
}
} else {
this.deactivateContainers();
}
}
}
getRemoveDisabledReason(el) {
const reasons = [];
this.dispatchTo("remove_disabled_reason_providers", { el, reasons });
return reasons.length ? reasons.join(" ") : undefined;
}
getCloneDisabledReason(el) {
const reasons = [];
this.dispatchTo("clone_disabled_reason_providers", { el, reasons });
return reasons.length ? reasons.join(" ") : undefined;
}
patchBuilderOptions({ target_name, target_element, method, value }) {
if (!target_name || !target_element || !method || (!value && method !== "remove")) {
throw new Error(
`Missing patch_builder_options required parameters: target_name, target_element, method, value`
);
}
const builderOption = this.builderOptions.find((option) => option.name === target_name);
if (!builderOption) {
throw new Error(`Builder option ${target_name} not found`);
}
switch (method) {
case "replace":
builderOption[target_element] = value;
break;
case "remove":
delete builderOption[target_element];
break;
case "add":
if (!builderOption[target_element]) {
throw new Error(
`Builder option ${target_name} does not have ${target_element}`
);
}
builderOption[target_element] += `, ${value}`;
break;
default:
throw new Error(`Unknown method ${method}`);
}
}
/**
* Finds the given option in the given element closest options container, as
* well as in the parent containers if specified, and returns it and its
* target element.
*
* @param {HTMLElement} el the element
* @param {String} optionName the option name
* @param {Boolean} [allowParent=false] true if the parent containers should
* be considered
* @returns {Object} - `option`: the requested option
* - `targetEl`: the target element of the option
*/
findOption(el, optionName, allowParent = false) {
let containers = this.getContainers().filter((container) => container.element.contains(el));
containers.reverse();
if (!allowParent) {
containers = [containers[0]];
}
// Find the given option in the active containers and the element on
// which it applies.
let targetEl, requestedOption;
for (const { element, options } of containers) {
requestedOption = options.find((option) => {
if (option.OptionComponent) {
return option.OptionComponent.name === optionName;
} else {
return option.template.split(".").at(-1) === optionName;
}
});
if (requestedOption) {
const { applyTo } = requestedOption;
targetEl = applyTo ? element.querySelector(applyTo) : element;
break;
}
}
return { option: requestedOption, targetEl };
}
}
function getClosestElements(element, selector) {
if (!element) {
// TODO we should remove it
return [];
}
const parent = element.closest(selector);
return parent ? [parent, ...getClosestElements(parent.parentElement, selector)] : [];
}
/**
* Checks if the given element is valid in order to have an option.
*
* @param {HTMLElement} el
* @param {Boolean} editableOnly when set to false, the element does not need to
* be in an editable area and the checks are therefore lighter.
* (= previous data-no-check/noCheck)
* @param {String} exclude
* @returns {Boolean}
*/
export function checkElement(el, { editableOnly = true, exclude = "" }) {
// Unless specified otherwise, the element should be in an editable.
if (editableOnly && !el.closest(".o_editable")) {
return false;
}
// Check that the element is not to be excluded.
exclude += `${exclude && ", "}.o_snippet_not_selectable`;
if (el.matches(exclude)) {
return false;
}
// If an editable is not required, do not check anything else.
if (!editableOnly) {
return true;
}
// `o_editable_media` bypasses the `o_not_editable` class.
if (el.matches(".o_editable_media")) {
return shouldEditableMediaBeEditable(el);
}
return !el.matches('.o_not_editable:not(.s_social_media) :not([contenteditable="true"])');
}
function withIds(arr) {
return arr.map((el) => ({ ...el, id: uniqueId() }));
}

View file

@ -0,0 +1,15 @@
import { Plugin } from "@html_editor/plugin";
export class BuilderOptionsTranslationPlugin extends Plugin {
static id = "builderOptions";
static shared = ["deactivateContainers", "getTarget", "updateContainers", "setNextTarget"];
static dependencies = ["history"];
deactivateContainers() {}
getTarget() {}
updateContainers() {}
setNextTarget(targetEl) {
// Store the next target to activate in the current step.
this.dependencies.history.setStepExtra("nextTarget", targetEl);
}
}

View file

@ -0,0 +1,668 @@
import { renderToElement } from "@web/core/utils/render";
import {
addBackgroundGrid,
getGridProperties,
getGridItemProperties,
resizeGrid,
setElementToMaxZindex,
} from "@html_builder/utils/grid_layout_utils";
// TODO move them elsewhere.
export const sizingY = {
selector: "section, .row > div, .parallax, .s_hr, .carousel-item, .s_rating",
exclude:
"section:has(> .carousel), .s_image_gallery .carousel-item, .s_col_no_resize.row > div, .s_col_no_resize",
};
export const sizingX = {
selector: ".row > div",
exclude: ".s_col_no_resize.row > div, .s_col_no_resize",
};
export const sizingGrid = {
selector: ".row > div",
exclude: ".s_col_no_resize.row > div, .s_col_no_resize",
};
export class BuilderOverlay {
constructor(
overlayTarget,
{
iframe,
overlayContainer,
history,
hasOverlayOptions,
next,
isMobileView,
mobileBreakpoint,
isRtl,
}
) {
this.history = history;
this.next = next;
this.hasOverlayOptions = hasOverlayOptions;
this.iframe = iframe;
this.overlayContainer = overlayContainer;
this.overlayElement = renderToElement("html_builder.BuilderOverlay");
this.overlayTarget = overlayTarget;
this.hasSizingHandles = this.hasSizingHandles();
this.handlesWrapperEl = this.overlayElement.querySelector(".o_handles");
this.handleEls = this.overlayElement.querySelectorAll(".o_handle");
// Avoid "querySelectoring" the handles every time.
this.yHandles = this.handlesWrapperEl.querySelectorAll(
`.n:not(.o_grid_handle), .s:not(.o_grid_handle)`
);
this.xHandles = this.handlesWrapperEl.querySelectorAll(
`.e:not(.o_grid_handle), .w:not(.o_grid_handle)`
);
this.gridHandles = this.handlesWrapperEl.querySelectorAll(".o_grid_handle");
this.isMobileView = isMobileView;
this.mobileBreakpoint = mobileBreakpoint;
this.isRtl = isRtl;
this.initHandles();
this.initSizing();
this.refreshHandles();
}
hasSizingHandles() {
if (!this.hasOverlayOptions) {
return false;
}
return this.isResizableY() || this.isResizableX() || this.isResizableGrid();
}
// displayOverlayOptions(el) {
// // TODO when options will be more clear:
// // - moving
// // - timeline
// // (maybe other where `displayOverlayOptions: true`)
// }
isActive() {
// TODO active still necessary ? (check when we have preview mode)
return this.overlayElement.matches(".oe_active, .o_we_overlay_preview");
}
refreshPosition() {
if (!this.isActive()) {
return;
}
const openModalEl = this.overlayTarget.querySelector(".modal.show");
const overlayTarget = openModalEl ? openModalEl : this.overlayTarget;
// TODO transform
const iframeRect = this.iframe.getBoundingClientRect();
const overlayContainerRect = this.overlayContainer.getBoundingClientRect();
const targetRect = overlayTarget.getBoundingClientRect();
Object.assign(this.overlayElement.style, {
width: `${targetRect.width}px`,
height: `${targetRect.height}px`,
top: `${iframeRect.y + targetRect.y - overlayContainerRect.y + window.scrollY}px`,
left: `${iframeRect.x + targetRect.x - overlayContainerRect.x + window.scrollX}px`,
});
this.handlesWrapperEl.style.height = `${targetRect.height}px`;
}
refreshHandles() {
if (!this.hasSizingHandles || !this.isActive()) {
return;
}
if (this.overlayTarget.parentNode?.classList.contains("row")) {
const isMobile = this.isMobileView(this.overlayTarget);
const isGridOn = this.overlayTarget.classList.contains("o_grid_item");
const isGrid = !isMobile && isGridOn;
// Hiding/showing the correct resize handles if we are in grid mode
// or not.
this.handleEls.forEach((handleEl) => {
const isGridHandle = handleEl.classList.contains("o_grid_handle");
handleEl.classList.toggle("d-none", isGrid ^ isGridHandle);
// Disabling the vertical resize if we are in mobile view.
const isVerticalSizing = handleEl.matches(".n, .s");
handleEl.classList.toggle("readonly", isMobile && isVerticalSizing && isGridOn);
});
}
this.updateHandleY();
}
toggleOverlay(show) {
this.overlayElement.classList.toggle("oe_active", show);
this.refreshPosition();
this.refreshHandles();
}
toggleOverlayPreview(show) {
this.overlayElement.classList.toggle("o_we_overlay_preview", show);
this.refreshPosition();
this.refreshHandles();
}
toggleOverlayVisibility(show) {
if (!this.isActive()) {
return;
}
this.overlayElement.classList.toggle("o_overlay_hidden", !show);
}
destroy() {
if (!this.hasSizingHandles) {
return;
}
this.handleEls.forEach((handleEl) =>
handleEl.removeEventListener("pointerdown", this._onSizingStart)
);
}
//--------------------------------------------------------------------------
// Sizing
//--------------------------------------------------------------------------
isResizableY() {
return (
this.overlayTarget.matches(sizingY.selector) &&
!this.overlayTarget.matches(sizingY.exclude)
);
}
isResizableX() {
return (
this.overlayTarget.matches(sizingX.selector) &&
!this.overlayTarget.matches(sizingX.exclude)
);
}
isResizableGrid() {
return (
this.overlayTarget.matches(sizingGrid.selector) &&
!this.overlayTarget.matches(sizingGrid.exclude)
);
}
initHandles() {
if (!this.hasSizingHandles) {
return;
}
if (this.isResizableY()) {
this.yHandles.forEach((handleEl) => handleEl.classList.remove("readonly"));
}
if (this.isResizableX()) {
this.xHandles.forEach((handleEl) => handleEl.classList.remove("readonly"));
}
if (this.isResizableGrid()) {
this.gridHandles.forEach((handleEl) => handleEl.classList.remove("readonly"));
}
}
initSizing() {
if (!this.hasSizingHandles) {
return;
}
this._onSizingStart = this.onSizingStart.bind(this);
this.handleEls.forEach((handleEl) =>
handleEl.addEventListener("pointerdown", this._onSizingStart)
);
}
replaceSizingClass(classRegex, newClass) {
const newClassName = (this.overlayTarget.className || "").replace(classRegex, "");
this.overlayTarget.className = newClassName;
this.overlayTarget.classList.add(newClass);
}
getSizingYConfig() {
const isTargetHR = this.overlayTarget.matches("hr");
const nClass = isTargetHR ? "mt" : "pt";
const nProperty = isTargetHR ? "margin-top" : "padding-top";
const sClass = isTargetHR ? "mb" : "pb";
const sProperty = isTargetHR ? "margin-bottom" : "padding-bottom";
const values = [0, 4];
for (let i = 1; i <= 256 / 8; i++) {
values.push(i * 8);
}
return {
n: { classes: values.map((v) => nClass + v), values: values, cssProperty: nProperty },
s: { classes: values.map((v) => sClass + v), values: values, cssProperty: sProperty },
};
}
onResizeY(compass, initialClasses, currentIndex) {
this.updateHandleY();
}
updateHandleY() {
this.yHandles.forEach((handleEl) => {
const topOrBottom = handleEl.matches(".n") ? "top" : "bottom";
const padding = window.getComputedStyle(this.overlayTarget)[`padding-${topOrBottom}`];
handleEl.style.height = padding; // TODO outerHeight (deduce borders ?)
});
}
getSizingXConfig() {
const resolutionModifier = this.isMobile ? "" : `${this.mobileBreakpoint}-`;
const rowWidth = this.overlayTarget.closest(".row").getBoundingClientRect().width;
const valuesE = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
const valuesW = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
return {
e: {
classes: valuesE.map((v) => `col-${resolutionModifier}${v}`),
values: valuesE.map((v) => (rowWidth / 12) * v),
cssProperty: "width",
},
w: {
classes: valuesW.map((v) => `offset-${resolutionModifier}${v}`),
values: valuesW.map((v) => (rowWidth / 12) * v),
cssProperty: "margin-left",
},
};
}
onResizeX(compass, initialClasses, currentIndex) {
const resolutionModifier = this.isMobile ? "" : `${this.mobileBreakpoint}-`;
// (?!\S): following char cannot be a non-space character
const offsetRegex = new RegExp(`(?:^|\\s+)offset-${resolutionModifier}(\\d{1,2})(?!\\S)`);
const colRegex = new RegExp(`(?:^|\\s+)col-${resolutionModifier}(\\d{1,2})(?!\\S)`);
const initialOffset = Number(initialClasses.match(offsetRegex)?.[1] || 0);
if (compass === "w") {
// Replacing the col class so the right border does not move when we
// change the offset.
const initialCol = Number(initialClasses.match(colRegex)?.[1] || 12);
let offset = Number(this.overlayTarget.className.match(offsetRegex)?.[1] || 0);
const offsetClass = `offset-${resolutionModifier}${offset}`;
let colSize = initialCol - (offset - initialOffset);
if (colSize <= 0) {
colSize = 1;
offset = initialOffset + initialCol - 1;
}
this.overlayTarget.classList.remove(offsetClass);
this.replaceSizingClass(colRegex, `col-${resolutionModifier}${colSize}`);
if (offset > 0) {
this.overlayTarget.classList.add(`offset-${resolutionModifier}${offset}`);
}
// Add/remove the `offset-lg-0` class when needed.
if (this.isMobile && offset === 0) {
this.overlayTarget.classList.remove(`offset-${this.mobileBreakpoint}-0`);
} else {
const className = this.overlayTarget.className;
const hasDesktopClass = !!className.match(
new RegExp(`(^|\\s+)offset-${this.mobileBreakpoint}-\\d{1,2}(?!\\S)`)
);
const hasMobileClass = !!className.match(/(^|\s+)offset-\d{1,2}(?!\S)/);
if (
(this.isMobile && offset > 0 && !hasDesktopClass) ||
(!this.isMobile && offset === 0 && hasMobileClass)
) {
this.overlayTarget.classList.add(`offset-${this.mobileBreakpoint}-0`);
}
}
} else if (initialOffset > 0) {
const col = Number(this.overlayTarget.className.match(colRegex)?.[1] || 0);
// Avoid overflowing to the right if the column size + the offset
// exceeds 12.
if (col + initialOffset > 12) {
this.replaceSizingClass(colRegex, `col-${resolutionModifier}${12 - initialOffset}`);
}
}
}
getSizingGridConfig() {
const rowEl = this.overlayTarget.closest(".row");
const gridProp = getGridProperties(rowEl);
const { rowStart, rowEnd, columnStart, columnEnd } = getGridItemProperties(
this.overlayTarget
);
const valuesN = [];
const valuesS = [];
for (let i = 1; i < parseInt(rowEnd) + 12; i++) {
valuesN.push(i);
valuesS.push(i + 1);
}
const valuesW = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
const valuesE = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
return {
n: {
classes: valuesN.map((v) => "g-height-" + (rowEnd - v)),
values: valuesN.map((v) => (gridProp.rowSize + gridProp.rowGap) * (v - 1)),
cssProperty: "grid-row-start",
},
s: {
classes: valuesS.map((v) => "g-height-" + (v - rowStart)),
values: valuesS.map((v) => (gridProp.rowSize + gridProp.rowGap) * (v - 1)),
cssProperty: "grid-row-end",
},
w: {
classes: valuesW.map((v) => `g-col-${this.mobileBreakpoint}-` + (columnEnd - v)),
values: valuesW.map((v) => (gridProp.columnSize + gridProp.columnGap) * (v - 1)),
cssProperty: "grid-column-start",
},
e: {
classes: valuesE.map((v) => `g-col-${this.mobileBreakpoint}-` + (v - columnStart)),
values: valuesE.map((v) => (gridProp.columnSize + gridProp.columnGap) * (v - 1)),
cssProperty: "grid-column-end",
},
};
}
onResizeGrid(compass, initialClasses, currentIndex) {
const style = this.overlayTarget.style;
if (compass === "n") {
const rowEnd = parseInt(style.gridRowEnd);
if (currentIndex < 0) {
style.gridRowStart = 1;
} else if (currentIndex + 1 >= rowEnd) {
style.gridRowStart = rowEnd - 1;
} else {
style.gridRowStart = currentIndex + 1;
}
} else if (compass === "s") {
const rowStart = parseInt(style.gridRowStart);
const rowEnd = parseInt(style.gridRowEnd);
if (currentIndex + 2 <= rowStart) {
style.gridRowEnd = rowStart + 1;
} else {
style.gridRowEnd = currentIndex + 2;
}
// Updating the grid height.
const rowEl = this.overlayTarget.parentNode;
const rowCount = parseInt(rowEl.dataset.rowCount);
const backgroundGridEl = rowEl.querySelector(".o_we_background_grid");
const backgroundGridRowEnd = parseInt(backgroundGridEl.style.gridRowEnd);
let rowMove = 0;
if (style.gridRowEnd > rowEnd && style.gridRowEnd > rowCount + 1) {
rowMove = style.gridRowEnd - rowEnd;
} else if (style.gridRowEnd < rowEnd && style.gridRowEnd >= rowCount + 1) {
rowMove = style.gridRowEnd - rowEnd;
}
backgroundGridEl.style.gridRowEnd = backgroundGridRowEnd + rowMove;
} else if (compass === "w") {
const columnEnd = parseInt(style.gridColumnEnd);
if (currentIndex < 0) {
style.gridColumnStart = 1;
} else if (currentIndex + 1 >= columnEnd) {
style.gridColumnStart = columnEnd - 1;
} else {
style.gridColumnStart = currentIndex + 1;
}
} else if (compass === "e") {
const columnStart = parseInt(style.gridColumnStart);
if (currentIndex + 2 > 13) {
style.gridColumnEnd = 13;
} else if (currentIndex + 2 <= columnStart) {
style.gridColumnEnd = columnStart + 1;
} else {
style.gridColumnEnd = currentIndex + 2;
}
}
if (compass === "n" || compass === "s") {
const numberRows = style.gridRowEnd - style.gridRowStart;
this.replaceSizingClass(/\s*(g-height-)([0-9-]+)/g, `g-height-${numberRows}`);
}
if (compass === "w" || compass === "e") {
const numberColumns = style.gridColumnEnd - style.gridColumnStart;
this.replaceSizingClass(
new RegExp(`\\s*(g-col-${this.mobileBreakpoint}-)([0-9-]+)`, "g"),
`g-col-${this.mobileBreakpoint}-${numberColumns}`
);
}
}
getDirections(ev, handleEl, sizingConfig) {
let compass = false;
let XY = false;
if (handleEl.matches(".n")) {
compass = "n";
XY = "Y";
} else if (handleEl.matches(".s")) {
compass = "s";
XY = "Y";
} else if (handleEl.matches(".e")) {
compass = "e";
XY = "X";
} else if (handleEl.matches(".w")) {
compass = "w";
XY = "X";
} else if (handleEl.matches(".nw")) {
compass = "nw";
XY = "YX";
} else if (handleEl.matches(".ne")) {
compass = "ne";
XY = "YX";
} else if (handleEl.matches(".sw")) {
compass = "sw";
XY = "YX";
} else if (handleEl.matches(".se")) {
compass = "se";
XY = "YX";
}
if (this.isRtl) {
if (compass.includes("e")) {
compass = compass.replace("e", "w");
} else if (compass.includes("w")) {
compass = compass.replace("w", "e");
}
}
const currentConfig = [];
for (let i = 0; i < compass.length; i++) {
currentConfig.push(sizingConfig[compass[i]]);
}
const directions = [];
for (const [i, config] of currentConfig.entries()) {
// Compute the current index based on the current class/style.
let currentIndex = 0;
const cssProperty = config.cssProperty;
const cssPropertyValue = parseInt(
window.getComputedStyle(this.overlayTarget)[cssProperty]
);
config.classes.forEach((c, index) => {
if (this.overlayTarget.classList.contains(c)) {
currentIndex = index;
} else if (config.values[index] === cssPropertyValue) {
currentIndex = index;
}
});
directions.push({
config,
currentIndex,
initialIndex: currentIndex,
initialClasses: this.overlayTarget.className,
classRegex: new RegExp(
"\\s*" + config.classes[currentIndex].replace(/[-]*[0-9]+/, "[-]*[0-9]+"),
"g"
),
initialPageXY: ev["page" + XY[i]],
XY: XY[i],
compass: compass[i],
});
}
return directions;
}
onSizingStart(ev) {
ev.preventDefault();
const pointerDownTime = ev.timeStamp;
// Lock the mutex.
let sizingResolve;
const sizingProm = new Promise((resolve) => (sizingResolve = () => resolve()));
this.next(async () => await sizingProm, { withLoadingEffect: false });
const cancelSizing = this.history.makeSavePoint();
const handleEl = ev.currentTarget;
const isGridHandle = handleEl.classList.contains("o_grid_handle");
this.isMobile = this.isMobileView(this.overlayTarget);
// If we are in grid mode, add a background grid and place it in front
// of the other elements.
let rowEl, backgroundGridEl;
if (isGridHandle) {
rowEl = this.overlayTarget.parentNode;
backgroundGridEl = addBackgroundGrid(rowEl, 0);
setElementToMaxZindex(backgroundGridEl, rowEl);
}
let sizingConfig, onResize;
if (isGridHandle) {
sizingConfig = this.getSizingGridConfig();
onResize = this.onResizeGrid.bind(this);
} else if (handleEl.matches(".n, .s")) {
sizingConfig = this.getSizingYConfig();
onResize = this.onResizeY.bind(this);
} else {
sizingConfig = this.getSizingXConfig();
onResize = this.onResizeX.bind(this);
}
const directions = this.getDirections(ev, handleEl, sizingConfig);
// Set the cursor.
const cursorClass = `${window.getComputedStyle(handleEl)["cursor"]}-important`;
window.document.body.classList.add(cursorClass);
// Prevent the iframe from absorbing the pointer events.
const iframeEl = this.overlayTarget.ownerDocument.defaultView.frameElement;
iframeEl.classList.add("o_resizing");
this.overlayElement.classList.remove("o_handlers_idle");
const onSizingMove = (ev) => {
for (const dir of directions) {
const configValues = dir.config.values;
const currentIndex = dir.currentIndex;
const currentValue = configValues[currentIndex];
// Get the number of pixels by which the pointer moved, compared
// to the initial position of the handle.
let deltaRaw = ev[`page${dir.XY}`] - dir.initialPageXY;
// In RTL mode, reverse only horizontal movement (X axis).
if (dir.XY === "X" && this.isRtl) {
deltaRaw = -deltaRaw;
}
const delta = deltaRaw + configValues[dir.initialIndex];
// Compute the indexes of the next step and the step before it,
// based on the delta.
let nextIndex, beforeIndex;
if (delta > currentValue) {
const nextValue = configValues.find((v) => v > delta);
nextIndex = nextValue
? configValues.indexOf(nextValue)
: configValues.length - 1;
beforeIndex = nextIndex > 0 ? nextIndex - 1 : currentIndex;
} else if (delta < currentValue) {
const nextValue = configValues.findLast((v) => v < delta);
nextIndex = nextValue ? configValues.indexOf(nextValue) : 0;
beforeIndex =
nextIndex < configValues.length - 1 ? nextIndex + 1 : currentIndex;
}
let change = false;
if (delta !== currentValue) {
// First, catch up with the pointer (in the case we moved
// really fast).
if (beforeIndex !== currentIndex) {
this.replaceSizingClass(dir.classRegex, dir.config.classes[beforeIndex]);
dir.currentIndex = beforeIndex;
change = true;
}
// If the pointer moved by at least 2/3 of the space between
// the current and the next step, the handle is snapped to
// the next step and the class is replaced by the one
// matching this step.
const threshold =
(2 * configValues[nextIndex] + configValues[dir.currentIndex]) / 3;
if (
(delta > currentValue && delta > threshold) ||
(delta < currentValue && delta < threshold)
) {
this.replaceSizingClass(dir.classRegex, dir.config.classes[nextIndex]);
dir.currentIndex = nextIndex;
change = true;
}
}
if (change) {
onResize(dir.compass, dir.initialClasses, dir.currentIndex);
// TODO notify other options (e.g. steps)
}
}
};
const onSizingStop = (ev) => {
ev.preventDefault();
window.removeEventListener("pointermove", onSizingMove);
window.removeEventListener("pointerup", onSizingStop);
window.document.body.classList.remove(cursorClass);
iframeEl.classList.remove("o_resizing");
this.overlayElement.classList.add("o_handlers_idle");
// If we are in grid mode, removes the background grid.
// Also sync the col-* class with the g-col-* class so the
// toggle to normal mode and the mobile view are well done.
if (isGridHandle) {
backgroundGridEl.remove();
resizeGrid(rowEl);
const colClass = [...this.overlayTarget.classList].find((c) => /^col-/.test(c));
const gColClass = [...this.overlayTarget.classList].find((c) => /^g-col-/.test(c));
this.overlayTarget.classList.remove(colClass);
this.overlayTarget.classList.add(gColClass.substring(2));
}
// Cancel the sizing if the element was not resized (to not have
// mutations).
const wasResized = !directions.every((dir) => dir.initialIndex === dir.currentIndex);
if (wasResized) {
this.history.addStep();
} else {
cancelSizing();
}
// Free the mutex.
sizingResolve();
// If no resizing happened and if the pointer was down less than
// 500 ms, we assume that the user wanted to click on the element
// behind the handle.
if (!wasResized) {
const pointerUpTime = ev.timeStamp;
const pointerDownDuration = pointerUpTime - pointerDownTime;
if (pointerDownDuration < 500) {
// Find the first element behind the overlay.
const sameCoordinatesEls = this.overlayTarget.ownerDocument.elementsFromPoint(
ev.pageX,
ev.pageY
);
// Check if it has native JS `click` function
const toBeClickedEl = sameCoordinatesEls.find(
(el) =>
!this.overlayContainer.contains(el) &&
!el.matches(".o_loading_screen") &&
typeof el.click === "function"
);
if (toBeClickedEl) {
toBeClickedEl.click();
}
}
}
};
window.addEventListener("pointermove", onSizingMove);
window.addEventListener("pointerup", onSizingStop);
}
}

View file

@ -0,0 +1,278 @@
div[data-oe-local-overlay-id="builder-overlay-container"] {
position: absolute;
pointer-events: none;
.oe_overlay {
@include o-position-absolute;
display: none;
border-color: $o-we-handles-accent-color;
background: transparent;
text-align: center;
font-size: 16px;
transition: opacity 400ms linear 0s;
&.o_overlay_hidden {
opacity: 0 !important;
transition: none;
}
&.oe_active,
&.o_we_overlay_preview {
display: block;
z-index: 1;
}
&.o_we_overlay_preview {
transition: none;
}
// HANDLES
.o_handles {
@include o-position-absolute(-$o-we-handles-offset-to-hide, 0, auto, 0);
border-color: inherit;
pointer-events: auto;
> .o_handle {
position: absolute;
&.o_side_y {
height: $o-we-handle-edge-size;
}
&.o_side_x {
width: $o-we-handle-edge-size;
}
&.w {
inset: $o-we-handles-offset-to-hide auto $o-we-handles-offset-to-hide * -1 $o-we-handle-border-width * 0.5;
transform: translateX(-50%);
cursor: ew-resize;
body.o_rtl & {
transform: translateX(50%);
}
}
&.e {
inset: $o-we-handles-offset-to-hide $o-we-handle-border-width * 0.5 $o-we-handles-offset-to-hide * -1 auto;
transform: translateX(50%);
cursor: ew-resize;
body.o_rtl & {
transform: translateX(-50%);
}
}
&.n {
inset: $o-we-handles-offset-to-hide 0 auto 0;
cursor: ns-resize;
&.o_grid_handle {
transform: translateY(-50%);
&:before {
transform: translateY($o-we-handle-border-width * 0.5);
}
}
}
&.s {
inset: auto 0 $o-we-handles-offset-to-hide * -1 0;
cursor: ns-resize;
&.o_grid_handle {
transform: translateY(50%);
&:before {
transform: translateY($o-we-handle-border-width * -0.5);
}
}
}
&.ne {
inset: ($o-we-handles-offset-to-hide + $o-we-handle-border-width * 0.5) $o-we-handle-border-width * 0.5 auto auto;
transform: translate(50%, -50%);
cursor: nesw-resize;
body.o_rtl & {
cursor: nwse-resize;
}
}
&.se {
inset: auto $o-we-handle-border-width * 0.5 ($o-we-handles-offset-to-hide * -1 + $o-we-handle-border-width * 0.5) auto;
transform: translate(50%, 50%);
cursor: nwse-resize;
body.o_rtl & {
cursor: nesw-resize;
}
}
&.sw {
inset: auto auto ($o-we-handles-offset-to-hide * -1 + $o-we-handle-border-width * 0.5) $o-we-handle-border-width * 0.5;
transform: translate(-50%, 50%);
cursor: nesw-resize;
body.o_rtl & {
cursor: nwse-resize;
}
}
&.nw {
inset: ($o-we-handles-offset-to-hide + $o-we-handle-border-width * 0.5) auto auto $o-we-handle-border-width * 0.5;
transform: translate(-50%, -50%);
cursor: nwse-resize;
body.o_rtl & {
cursor: nesw-resize;
}
}
.o_handle_indicator {
position: absolute;
inset: $o-we-handles-btn-size * -0.5;
display: block;
width: $o-we-handles-btn-size;
height: $o-we-handles-btn-size;
margin: auto;
border: solid $o-we-handle-border-width $o-we-handles-accent-color;
border-radius: $o-we-handles-btn-size;
background: $o-we-fg-lighter;
outline: $o-we-handle-inside-line-width solid $o-we-fg-lighter;
outline-offset: -($o-we-handles-btn-size * 0.5);
transition: $transition-base;
&::before {
content: '';
position: absolute;
inset: $o-we-handles-btn-size * -0.5;
display: block;
border-radius: inherit;
}
}
&.o_column_handle.o_side_y {
background-color: rgba($o-we-handles-accent-color, .1);
&::after {
content: '';
position: absolute;
height: $o-we-handles-btn-size;
}
&.n {
border-bottom: dashed $o-we-handle-border-width * 0.5 rgba($o-we-handles-accent-color, 0.5);
&::after {
inset: 0 0 auto 0;
transform: translateY(-50%);
}
}
&.s {
border-top: dashed $o-we-handle-border-width * 0.5 rgba($o-we-handles-accent-color, 0.5);
&::after {
inset: auto 0 0 0;
transform: translateY(50%);
}
}
}
&.o_side {
&::before {
content: '';
position: absolute;
inset: 0;
background: $o-we-handles-accent-color;
}
&.o_side_x {
&::before {
width: $o-we-handle-border-width;
margin: 0 auto;
}
}
&.o_side_y {
&::before {
height: $o-we-handle-border-width;
margin: auto 0;
}
}
&.o_column_handle {
&.n::before {
margin: 0 auto auto;
}
&.s::before {
margin: auto auto 0;
}
}
}
&.readonly {
cursor: default;
pointer-events: none;
&.o_column_handle.o_side_y {
border: none;
background: none;
}
&::after, .o_handle_indicator {
display: none;
}
}
}
}
// HANDLES - ACTIVE AND HOVER STATES
// By using `o_handlers_idle` class, we can avoid hovering another
// handle when we're already dragging another one.
&.o_handlers_idle .o_handle:hover, .o_handle:active {
.o_handle_indicator {
outline-color: $o-we-handles-accent-color;
}
}
&.o_handlers_idle .o_corner_handle:hover, .o_corner_handle:active {
.o_handle_indicator {
transform: scale(1.25);
}
}
&.o_handlers_idle .o_column_handle.o_side_y:hover, .o_column_handle.o_side_y:active {
background: repeating-linear-gradient(
45deg,
rgba($o-we-handles-accent-color, .1),
rgba($o-we-handles-accent-color, .1) 5px,
darken(rgba($o-we-handles-accent-color, .25), 5%) 5px,
darken(rgba($o-we-handles-accent-color, .25), 5%) 10px
);
}
&.o_handlers_idle .o_side_x:hover, .o_side_x:active {
&::before {
width: $o-we-handle-border-width * 2;
}
.o_handle_indicator {
height: $o-we-handles-btn-size * 2;
}
}
&.o_handlers_idle .o_side_y:hover, .o_side_y:active {
&::before {
height: $o-we-handle-border-width * 2;
}
.o_handle_indicator {
width: $o-we-handles-btn-size * 2;
}
}
}
}
/* rtl:begin:ignore */
@each $cursor in (nesw-resize, nwse-resize, ns-resize, ew-resize, move) {
.#{$cursor}-important * {
cursor: $cursor !important;
}
}
/* rtl:end:ignore */
.o_resizing {
pointer-events: none;
}

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderOverlay">
<div class="oe_overlay o_handlers_idle">
<div class="o_handles">
<!-- Visible overlay borders + allow to resize when not readonly -->
<div class="o_handle o_column_handle o_side o_side_y n readonly">
<span class="o_handle_indicator"></span>
</div>
<div class="o_handle o_column_handle o_side o_side_y s readonly">
<span class="o_handle_indicator"></span>
</div>
<div class="o_handle o_column_handle o_side o_side_x e readonly" >
<span class="o_handle_indicator"></span>
</div>
<div class="o_handle o_column_handle o_side o_side_x w readonly">
<span class="o_handle_indicator"></span>
</div>
<!-- Grid resize handles -->
<div class="o_handle o_grid_handle o_side o_side_y n d-none">
<span class="o_handle_indicator"/>
</div>
<div class="o_handle o_grid_handle o_side o_side_x e d-none">
<span class="o_handle_indicator"/>
</div>
<div class="o_handle o_grid_handle o_side o_side_x w d-none">
<span class="o_handle_indicator"/>
</div>
<div class="o_handle o_grid_handle o_side o_side_y s d-none">
<span class="o_handle_indicator"/>
</div>
<div class="o_handle o_grid_handle o_corner_handle ne d-none">
<span class="o_handle_indicator"/>
</div>
<div class="o_handle o_grid_handle o_corner_handle nw d-none">
<span class="o_handle_indicator"/>
</div>
<div class="o_handle o_grid_handle o_corner_handle se d-none">
<span class="o_handle_indicator"/>
</div>
<div class="o_handle o_grid_handle o_corner_handle sw d-none">
<span class="o_handle_indicator"/>
</div>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,176 @@
import { Plugin } from "@html_editor/plugin";
import { throttleForAnimation } from "@web/core/utils/timing";
import { getScrollingElement, getScrollingTarget } from "@web/core/utils/scrolling";
import { checkElement } from "../builder_options_plugin";
import { BuilderOverlay, sizingY, sizingX, sizingGrid } from "./builder_overlay";
import { withSequence } from "@html_editor/utils/resource";
function isResizable(el) {
const isResizableY = el.matches(sizingY.selector) && !el.matches(sizingY.exclude);
const isResizableX = el.matches(sizingX.selector) && !el.matches(sizingX.exclude);
const isResizableGrid = el.matches(sizingGrid.selector) && !el.matches(sizingGrid.exclude);
return isResizableY || isResizableX || isResizableGrid;
}
export class BuilderOverlayPlugin extends Plugin {
static id = "builderOverlay";
static dependencies = ["localOverlay", "history", "operation"];
static shared = ["showOverlayPreview", "hideOverlayPreview", "refreshOverlays"];
resources = {
step_added_handlers: this.refreshOverlays.bind(this),
change_current_options_containers_listeners: this.openBuilderOverlays.bind(this),
on_mobile_preview_clicked: withSequence(20, this.refreshOverlays.bind(this)),
has_overlay_options: { hasOption: (el) => isResizable(el) },
};
setup() {
// TODO find how to not overflow the mobile preview.
this.iframe = this.editable.ownerDocument.defaultView.frameElement;
this.overlayContainer = this.dependencies.localOverlay.makeLocalOverlay(
"builder-overlay-container"
);
// If the user scrolls the mouse wheel while hovering overlayContainer,
// no scroll will happen to the page. We need to manually process
// wheel events happening on overlayContainer.
this.overlayContainer.addEventListener(
"wheel",
(ev) => (this.document.documentElement.scrollTop += ev.deltaY)
);
/** @type {[BuilderOverlay]} */
this.overlays = [];
// Refresh the overlays position everytime their target size changes.
this.resizeObserver = new ResizeObserver(() => this.refreshPositions());
this._refreshOverlays = throttleForAnimation(this.refreshOverlays.bind(this));
// Recompute the overlay when the window is resized.
this.addDomListener(window, "resize", this._refreshOverlays);
// On keydown, hide the overlay and then show it again when the mouse
// moves.
const onMouseMoveOrDown = throttleForAnimation(() => {
this.toggleOverlaysVisibility(true);
this.refreshPositions();
this.editable.removeEventListener("mousemove", onMouseMoveOrDown);
this.editable.removeEventListener("mousedown", onMouseMoveOrDown);
});
this.addDomListener(this.editable, "keydown", () => {
this.toggleOverlaysVisibility(false);
this.editable.addEventListener("mousemove", onMouseMoveOrDown);
this.editable.addEventListener("mousedown", onMouseMoveOrDown);
});
// Hide the overlay when scrolling. Show it again when the scroll is
// over and recompute its position.
const scrollingElement = getScrollingElement(this.document);
const scrollingTarget = getScrollingTarget(scrollingElement);
this.addDomListener(
scrollingTarget,
"scroll",
throttleForAnimation(() => {
this.toggleOverlaysVisibility(false);
clearTimeout(this.scrollingTimeout);
this.scrollingTimeout = setTimeout(() => {
this.toggleOverlaysVisibility(true);
this.refreshPositions();
}, 250);
}),
{ capture: true }
);
this._cleanups.push(() => {
this.removeBuilderOverlays();
this.resizeObserver.disconnect();
});
}
openBuilderOverlays(optionsContainer) {
this.removeBuilderOverlays();
if (!optionsContainer.length) {
return;
}
// Create the overlays.
optionsContainer.forEach((option) => {
const overlay = new BuilderOverlay(option.element, {
iframe: this.iframe,
overlayContainer: this.overlayContainer,
history: this.dependencies.history,
hasOverlayOptions: checkElement(option.element, {}) && option.hasOverlayOptions,
next: this.dependencies.operation.next,
isMobileView: this.config.isMobileView,
mobileBreakpoint: this.config.mobileBreakpoint,
isRtl: this.config.isEditableRTL,
});
this.overlays.push(overlay);
this.overlayContainer.append(overlay.overlayElement);
this.resizeObserver.observe(overlay.overlayTarget, { box: "border-box" });
});
// Activate the last overlay.
const innermostOverlay = this.overlays.at(-1);
innermostOverlay.toggleOverlay(true);
// Also activate the closest overlay that should have overlay options.
if (!innermostOverlay.hasOverlayOptions) {
for (let i = this.overlays.length - 2; i >= 0; i--) {
const parentOverlay = this.overlays[i];
if (parentOverlay.hasOverlayOptions) {
parentOverlay.toggleOverlay(true);
break;
}
}
}
}
removeBuilderOverlays() {
this.overlays.forEach((overlay) => {
overlay.destroy();
overlay.overlayElement.remove();
this.resizeObserver.unobserve(overlay.overlayTarget);
});
this.overlays = [];
}
refreshOverlays() {
this.overlays.forEach((overlay) => {
overlay.refreshPosition();
overlay.refreshHandles();
});
}
refreshPositions() {
this.overlays.forEach((overlay) => {
overlay.refreshPosition();
});
}
toggleOverlaysVisibility(show) {
this.overlays.forEach((overlay) => {
overlay.toggleOverlayVisibility(show);
});
}
showOverlayPreview(el) {
// Hide all the active overlays.
this.toggleOverlaysVisibility(false);
// Show the preview of the one corresponding to the given element.
const overlayToShow = this.overlays.find((overlay) => overlay.overlayTarget === el);
if (!overlayToShow) {
return;
}
overlayToShow.toggleOverlayPreview(true);
overlayToShow.toggleOverlayVisibility(true);
}
hideOverlayPreview(el) {
// Remove the preview.
const overlayToHide = this.overlays.find((overlay) => overlay.overlayTarget === el);
if (!overlayToHide) {
return;
}
overlayToHide.toggleOverlayPreview(false);
// Show back the active overlays.
this.toggleOverlaysVisibility(true);
}
}

View file

@ -0,0 +1,25 @@
import { Component } from "@odoo/owl";
import { basicContainerBuilderComponentProps } from "../utils";
import { SelectMany2X } from "./select_many2x";
export class BasicMany2Many extends Component {
static template = "html_builder.BasicMany2Many";
static props = {
...basicContainerBuilderComponentProps,
model: String,
fields: { type: Array, element: String, optional: true },
domain: { type: Array, optional: true },
limit: { type: Number, optional: true },
selection: { type: Array, element: Object },
setSelection: Function,
create: { type: Function, optional: true },
};
static components = { SelectMany2X };
select(entry) {
this.props.setSelection([...this.props.selection, entry]);
}
unselect(id) {
this.props.setSelection([...this.props.selection.filter((item) => item.id !== id)]);
}
}

View file

@ -0,0 +1,3 @@
.o-hb-m2m-table:not(:empty) {
margin-bottom: $o-hb-row-spacing;
}

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BasicMany2Many">
<div class="d-flex flex-column">
<table class="o-hb-m2m-table">
<tr t-foreach="props.selection" t-as="entry" t-key="entry.id">
<td>
<input type="text" class="o-hb-input-base" disabled="" t-att-data-name="entry.display_name" t-att-value="entry.display_name"/>
</td>
<td>
<button class="mt-0 border-0 p-0 bg-transparent text-danger fa fa-fw fa-minus" t-on-click="() => this.unselect(entry.id)"/>
</td>
</tr>
</table>
<SelectMany2X
model="props.model"
fields="props.fields"
limit="props.limit"
domain="props.domain"
selected="props.selection"
select="select.bind(this)"
create="props.create"
/>
</div>
</t>
</templates>

View file

@ -0,0 +1,69 @@
import { Component } from "@odoo/owl";
import {
clickableBuilderComponentProps,
useActionInfo,
useSelectableItemComponent,
} from "../utils";
import { BuilderComponent } from "./builder_component";
import { Img } from "../img";
export class BuilderButton extends Component {
static template = "html_builder.BuilderButton";
static components = { BuilderComponent, Img };
static props = {
...clickableBuilderComponentProps,
title: { type: String, optional: true },
label: { type: String, optional: true },
iconImg: { type: String, optional: true },
iconImgAlt: { type: String, optional: true },
icon: { type: String, optional: true },
className: { type: String, optional: true },
classActive: { type: String, optional: true },
style: { type: String, optional: true },
type: { type: String, optional: true },
slots: { type: Object, optional: true },
};
static defaultProps = {
type: "secondary",
};
setup() {
this.info = useActionInfo();
const { state, operation } = useSelectableItemComponent(this.props.id);
this.state = state;
this.onClick = operation.commit;
this.onPointerEnter = operation.preview;
this.onPointerLeave = operation.revert;
}
get className() {
let className = this.props.className || "";
if (this.props.type) {
className += ` btn-${this.props.type}`;
}
if (this.state.isActive) {
className = `active ${className}`;
if (this.props.classActive) {
className += ` ${this.props.classActive}`;
}
}
if (this.props.icon) {
className += ` o-hb-btn-has-icon`;
}
if (this.props.iconImg) {
className += ` o-hb-btn-has-img-icon`;
}
return className;
}
get iconClassName() {
if (this.props.icon.startsWith("fa-")) {
return `fa ${this.props.icon}`;
} else if (this.props.icon.startsWith("oi-")) {
return `oi ${this.props.icon}`;
}
}
}

View file

@ -0,0 +1,45 @@
.o-hb-btn {
transition: none;
min-width: 0;
text-overflow: ellipsis;
overflow: hidden;
flex: 1 1 25%;
white-space: nowrap;
min-height: var(--o-hb-btn-minHeight);
&.o-hb-btn-has-icon, &.fa, &.oi {
--btn-padding-x: var(--o-hb-btn-has-icon-paddingX, #{map-get($spacers , 2)});
flex: 0 1 auto;
min-width: fit-content;
}
&.o-hb-btn-has-img-icon, &:has(.hb-svg) {
flex: 0 1 auto;
display: flex;
align-items: center;
justify-content: center;
--btn-padding-x: #{map-get($spacers , 2)};
}
&.o-hb-btn-has-img-icon > img {
max-height: 1em;
width: 1.4em;
margin-top: 0.2em;
margin-bottom: 0.2em;
}
&.o-hb-btn-has-img-icon .hb-svg {
&, .o_graphic, .o_subdle {
fill: currentColor;
}
.o_subdle {
fill-opacity: 0.5;
}
}
.fa, .oi, &:before {
line-height: inherit;
}
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderButton">
<BuilderComponent>
<button type="button" class="o-hb-btn btn" t-att-style="this.props.style"
t-att-data-action-id="info.actionId"
t-att-data-action-param="info.actionParam"
t-att-data-action-value="info.actionValue"
t-att-data-class-action="info.classAction"
t-att-data-style-action="info.styleAction"
t-att-data-style-action-value="info.styleActionValue"
t-att-data-attribute-action="info.attributeAction"
t-att-data-attribute-action-value="info.attributeActionValue"
t-att-class="className"
t-att-title="props.title"
t-att-aria-label="props.title"
t-on-click="() => this.onClick()"
t-on-pointerenter="() => this.onPointerEnter(props.id)"
t-on-pointerleave="() => this.onPointerLeave(props.id)">
<Img t-if="props.iconImg" src="props.iconImg" attrs="{alt:props.iconImgAlt}"/>
<i t-if="props.icon" t-att-class="iconClassName" role="img"/>
<t t-if="props.label" t-out="props.label"/>
<t t-slot="default"/>
</button>
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,23 @@
import { Component } from "@odoo/owl";
import {
basicContainerBuilderComponentProps,
useVisibilityObserver,
useApplyVisibility,
useSelectableComponent,
} from "../utils";
import { BuilderComponent } from "./builder_component";
export class BuilderButtonGroup extends Component {
static template = "html_builder.BuilderButtonGroup";
static props = {
...basicContainerBuilderComponentProps,
slots: { type: Object, optional: true },
};
static components = { BuilderComponent };
setup() {
useVisibilityObserver("root", useApplyVisibility("root"));
useSelectableComponent(this.props.id);
}
}

View file

@ -0,0 +1,18 @@
.o-hb-button-group.btn-group {
flex: 1 1 50%;
min-width: 0;
align-items: center;
> :not(.btn-check:first-child) + .btn,
.btn-group > .btn-group:not(:first-child) {
margin-left: calc(var(--border-width) * -1);
}
.o-hb-btn {
flex: 1 1 auto;
&.active {
z-index: 2;
}
}
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderButtonGroup">
<BuilderComponent>
<div class="o-hb-button-group btn-group" t-ref="root">
<t t-slot="default"/>
</div>
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,39 @@
import { Component } from "@odoo/owl";
import { CheckBox } from "@web/core/checkbox/checkbox";
import {
clickableBuilderComponentProps,
useActionInfo,
useClickableBuilderComponent,
useDependencyDefinition,
useDomState,
} from "../utils";
import { BuilderComponent } from "./builder_component";
export class BuilderCheckbox extends Component {
static template = "html_builder.BuilderCheckbox";
static components = { BuilderComponent, CheckBox };
static props = {
...clickableBuilderComponentProps,
};
setup() {
this.info = useActionInfo();
const { operation, isApplied, onReady } = useClickableBuilderComponent();
if (this.props.id) {
useDependencyDefinition(this.props.id, { isActive: isApplied }, { onReady });
}
this.state = useDomState(async () => {
await onReady;
return {
isActive: isApplied(),
};
});
this.onPointerEnter = operation.preview;
this.onPointerLeave = operation.revert;
this.onChange = operation.commit;
}
getClassName() {
return "o-hb-checkbox o_field_boolean o_boolean_toggle form-switch";
}
}

View file

@ -0,0 +1,35 @@
.o-hb-checkbox.form-switch {
margin-bottom: 0;
.form-check-input {
--form-switch-bg: #{escape-svg($o-hb-form-switch-bg-image)};
height: $o-hb-form-check-input-height;
margin-top: ($line-height-base - $o-hb-form-check-input-height) * .5;
border-color: $o-we-sidebar-content-field-border-color;
background-color: $o-we-sidebar-content-field-input-bg;
&:focus, &:hover {
--form-switch-bg: #{escape-svg($o-hb-form-switch-focus-bg-image)};
}
&:checked {
--form-switch-bg: #{escape-svg($o-hb-form-switch-checked-bg-image)};
background-color: var(--o-hb-form-switch-color-active, #{$o-hb-form-check-input-checked-bg-color});
border-color: var(--o-hb-form-switch-color-active, #{$o-hb-form-check-input-checked-border-color});
}
}
&:focus, &:hover {
.form-check-input:not(:disabled) {
border-color: var(--o-hb-form-switch-color-active, #{$o-hb-form-check-input-checked-border-color});
box-shadow: none;
}
}
.form-check-input:focus-visible:not(:disabled) {
box-shadow:
0 0 0 #{$o-we-border-width} #{$o-we-bg-light},
0 0 0 #{$o-we-border-width * 2} var(--o-hb-form-switch-color-active, #{$o-hb-form-check-input-checked-border-color});
}
}

View file

@ -0,0 +1,7 @@
$o-hb-form-switch-bg-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#{$o-we-fg-light}'/></svg>") !default;
$o-hb-form-switch-focus-bg-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#{$o-we-fg-lighter}'/></svg>") !default;
$o-hb-form-switch-checked-bg-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#{$o-we-bg-dark}'/></svg>") !default;
$o-hb-form-check-input-height: 1.2em !default;
$o-hb-form-check-input-checked-bg-color: $o-we-color-success !default;
$o-hb-form-check-input-checked-border-color: $o-we-color-success !default;

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderCheckbox">
<BuilderComponent>
<div
t-att-data-action-id="info.actionId"
t-att-data-action-param="info.actionParam"
t-att-data-action-value="info.actionValue"
t-att-data-class-action="info.classAction"
t-att-data-style-action="info.styleAction"
t-att-data-style-action-value="info.styleActionValue"
t-att-data-attribute-action="info.attributeAction"
t-att-data-attribute-action-value="info.attributeActionValue"
t-on-pointerenter="() => this.onPointerEnter(props.id)"
t-on-pointerleave="() => this.onPointerLeave(props.id)">
<CheckBox className="getClassName()" onChange="onChange" value="state.isActive"/>
</div>
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,182 @@
import { ColorSelector } from "@html_editor/main/font/color_selector";
import { Component, useComponent, useRef } from "@odoo/owl";
import { useColorPicker } from "@web/core/color_picker/color_picker";
import { BuilderComponent } from "./builder_component";
import {
basicContainerBuilderComponentProps,
getAllActionsAndOperations,
useBuilderComponent,
useDomState,
useHasPreview,
} from "../utils";
import { isCSSColor, isColorGradient } from "@web/core/utils/colors";
import { getAllUsedColors } from "@html_builder/utils/utils_css";
// TODO replace by useInputBuilderComponent after extract unit by AGAU
export function useColorPickerBuilderComponent() {
const comp = useComponent();
const { getAllActions, callOperation } = getAllActionsAndOperations(comp);
const getAction = comp.env.editor.shared.builderActions.getAction;
const state = useDomState(getState);
const applyOperation = comp.env.editor.shared.history.makePreviewableAsyncOperation(
(applySpecs, isPreviewing) => {
const proms = [];
for (const applySpec of applySpecs) {
proms.push(
applySpec.action.apply({
isPreviewing,
editingElement: applySpec.editingElement,
params: applySpec.actionParam,
value: applySpec.actionValue,
loadResult: applySpec.loadResult,
dependencyManager: comp.env.dependencyManager,
})
);
}
return Promise.all(proms);
}
);
function getState(editingElement) {
// if (!editingElement || !editingElement.isConnected) {
// // TODO try to remove it. We need to move hook in BuilderComponent
// return {};
// }
const actionWithGetValue = getAllActions().find(
({ actionId }) => getAction(actionId).getValue
);
const { actionId, actionParam } = actionWithGetValue;
const actionValue = getAction(actionId).getValue({ editingElement, params: actionParam });
return {
mode: actionParam.mainParam || actionId,
selectedColor: actionValue || comp.props.defaultColor,
selectedColorCombination: comp.env.editor.shared.color.getColorCombination(
editingElement,
actionParam
),
getTargetedElements: () => [editingElement],
};
}
function getColor(colorValue) {
return colorValue.startsWith("color-prefix-")
? `var(${colorValue.replace("color-prefix-", "--")})`
: colorValue;
}
let previewValue = null;
function onApply(colorValue) {
previewValue = null;
callOperation(applyOperation.commit, { userInputValue: getColor(colorValue) });
}
let onPreview = (colorValue) => {
// Avoid previewing the same color twice.
if (previewValue === colorValue) {
return;
}
previewValue = colorValue;
callOperation(applyOperation.preview, {
preview: true,
userInputValue: getColor(colorValue),
operationParams: {
cancellable: true,
cancelPrevious: () => applyOperation.revert(),
},
});
};
const hasPreview = useHasPreview(getAllActions);
if (!hasPreview) {
onPreview = () => {};
}
return {
state,
onApply,
onPreview,
onPreviewRevert: () => {
previewValue = null;
// The `next` will cancel the previous operation, which will revert
// the operation in case of a preview.
comp.env.editor.shared.operation.next();
},
};
}
export class BuilderColorPicker extends Component {
static template = "html_builder.BuilderColorPicker";
static props = {
...basicContainerBuilderComponentProps,
noTransparency: { type: Boolean, optional: true },
enabledTabs: { type: Array, optional: true },
grayscales: { type: Object, optional: true },
unit: { type: String, optional: true },
title: { type: String, optional: true },
getUsedCustomColors: { type: Function, optional: true },
selectedTab: { type: String, optional: true },
defaultColor: { type: String, optional: true },
defaultOpacity: { type: Number, optional: true },
};
static defaultProps = {
enabledTabs: ["theme", "gradient", "custom"],
defaultColor: "#FFFFFF00",
};
static components = {
ColorSelector: ColorSelector,
BuilderComponent,
};
setup() {
useBuilderComponent();
const { state, onApply, onPreview, onPreviewRevert } = useColorPickerBuilderComponent();
this.colorButton = useRef("colorButton");
this.state = state;
this.state.defaultTab = this.props.selectedTab || "solid"; // TODO: select the correct tab based on the color
useColorPicker(
"colorButton",
{
state,
applyColor: onApply,
applyColorPreview: onPreview,
applyColorResetPreview: onPreviewRevert,
getUsedCustomColors:
this.props.getUsedCustomColors || this.getUsedCustomColors.bind(this),
colorPrefix: "color-prefix-",
cssVarColorPrefix: "hb-cp-",
noTransparency: this.props.noTransparency,
enabledTabs: this.props.enabledTabs,
grayscales: this.props.grayscales,
defaultOpacity: this.props.defaultOpacity,
className: "o-hb-colorpicker",
editColorCombination: this.env.editColorCombination,
},
{
onClose: onPreviewRevert,
popoverClass: "o-hb-colorpicker-popover",
}
);
}
getSelectedColorStyle() {
if (this.state.selectedColor) {
if (isColorGradient(this.state.selectedColor)) {
return `background-image: ${this.state.selectedColor}`;
}
if (isCSSColor(this.state.selectedColor)) {
return `background-color: ${this.state.selectedColor}`;
}
return `background-color: var(--${this.state.selectedColor})`;
}
if (this.state.selectedColorCombination) {
const colorCombination = this.state.selectedColorCombination.replace("_", "-");
const el = this.env.getEditingElement();
const style = el.ownerDocument.defaultView.getComputedStyle(el);
if (style.backgroundImage !== "none") {
return `background-image: ${style.backgroundImage}`;
} else {
return `background-color: var(--${colorCombination}-bg)`;
}
}
return "";
}
getUsedCustomColors() {
return getAllUsedColors(this.env.editor.editable);
}
}

View file

@ -0,0 +1,120 @@
.o-hb-colorpicker-popover {
--popover-bg: #{$o-we-bg-darker};
--popover-border-color: #{$o-we-bg-darker};
}
.o-hb-colorpicker {
--o-color-picker-active-color: var(--o-hb-select-item-active-color, #{$o-we-sidebar-content-field-toggle-active-bg});
--bg: #{$o-we-item-clickable-bg};
background-color: $o-we-item-clickable-bg;
color: $white;
&:has(.theme-tab){
width: 250px;
}
& > div:first-child {
background-color: $o-we-bg-darker;
padding-block: 0.5em;
}
& > div:not(:first-child) {
.btn:not(.o-hb-edit-color-combination) {
@include button-variant(
$background: $o-we-item-clickable-bg,
$border: transparent,
$color: $o-we-item-clickable-color,
$hover-background: $o-we-item-clickable-hover-bg,
$hover-border: transparent,
$hover-color: $o-we-item-clickable-color,
$active-background: rgba($o-we-color-accent, .15),
$active-border: var(--o-hb-select-item-active-color, $o-we-sidebar-content-field-toggle-active-bg),
$active-color: $o-we-fg-lighter,
$disabled-background: rgba($o-we-item-clickable-hover-bg, 0.5),
$disabled-border: transparent,
$disabled-color: $o-we-item-clickable-color,
);
&:hover {
border: $o-we-border-width solid var(--o-color-picker-active-color);
}
}
}
.btn[title=Reset] {
@include button-variant(
$background: $o-we-bg-light,
$border: transparent,
$color: $o-we-item-clickable-color,
$hover-background: $o-we-bg-lighter,
$hover-border: transparent,
$hover-color: $o-we-item-clickable-color,
$active-background: $o-we-bg-lighter,
$active-border: transparent,
$active-color: $o-we-fg-lighter,
$disabled-background: rgba($o-we-item-clickable-hover-bg, 0.5),
$disabled-border: transparent,
$disabled-color: $o-we-item-clickable-color,
);
}
.btn-tab {
@include button-variant(
$background: $o-we-bg-darker,
$border: transparent,
$color: $o-we-item-clickable-color,
$hover-background: $o-we-bg-light,
$hover-border: transparent,
$hover-color: $o-we-item-clickable-color,
$active-background: rgba($o-we-color-accent, .15),
$active-border: var(--o-hb-select-item-active-color, $o-we-sidebar-content-field-toggle-active-bg),
$active-color: $o-we-fg-lighter,
$disabled-background: rgba($o-we-item-clickable-hover-bg, 0.5),
$disabled-border: transparent,
$disabled-color: $o-we-item-clickable-color,
);
}
.btn:focus-visible {
border: $o-we-border-width solid var(--o-color-picker-active-color);
z-index: 1;
}
.color-combination-button:focus-visible {
box-shadow:
0 0 0 1px $o-we-bg-darker,
0 0 0 3px var(--o-color-picker-active-color);
outline: none;
}
.o-hb-edit-color-combination {
color: $o-we-item-clickable-color;
border: none;
&:hover {
color: $o-we-fg-lighter;
}
&:not(:focus-visible) {
border: 1px solid transparent; // keep size stable on focus
}
}
// Gradients
.o_custom_gradient_button {
color: $o-we-item-clickable-color;
}
.gradient-angle-thumb {
background-color: $white;
}
.o_color_picker_inputs, .o_color_gradient_input {
color: $o-we-item-clickable-color;
& input {
color: currentColor;
}
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderColorPicker">
<BuilderComponent>
<button t-att-title="props.title" class="o_we_color_preview" t-ref="colorButton" t-att-style="this.getSelectedColorStyle()" />
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,18 @@
import { Component, xml } from "@odoo/owl";
import { useDomState } from "../utils";
export class BuilderComponent extends Component {
static template = xml`<t t-if="this.state.isVisible"><t t-slot="default"/></t>`;
static props = {
slots: { type: Object },
};
setup() {
this.state = useDomState(
(editingElement) => ({
isVisible: !!editingElement,
}),
{ checkEditingElement: false }
);
}
}

View file

@ -0,0 +1,22 @@
import { Component, xml } from "@odoo/owl";
import { basicContainerBuilderComponentProps, useBuilderComponent } from "../utils";
import { BuilderComponent } from "./builder_component";
export class BuilderContext extends Component {
static template = xml`
<BuilderComponent>
<t t-slot="default"/>
</BuilderComponent>
`;
static props = {
...basicContainerBuilderComponentProps,
slots: { type: Object },
};
static components = {
BuilderComponent,
};
setup() {
useBuilderComponent();
}
}

View file

@ -0,0 +1,148 @@
import { useDateTimePicker } from "@web/core/datetime/datetime_picker_hook";
import { Component, useState } from "@odoo/owl";
import { effect } from "@web/core/utils/reactive";
import { ConversionError, formatDate, formatDateTime, parseDateTime } from "@web/core/l10n/dates";
import { pick } from "@web/core/utils/objects";
import {
basicContainerBuilderComponentProps,
useBuilderComponent,
useInputBuilderComponent,
} from "../utils";
import { BuilderComponent } from "./builder_component";
import { BuilderTextInputBase, textInputBasePassthroughProps } from "./builder_text_input_base";
const { DateTime } = luxon;
export class BuilderDateTimePicker extends Component {
static template = "html_builder.BuilderDateTimePicker";
static props = {
...basicContainerBuilderComponentProps,
...textInputBasePassthroughProps,
type: { type: [{ value: "date" }, { value: "datetime" }], optional: true },
format: { type: String, optional: true },
acceptEmptyDate: { type: Boolean, optional: true },
};
static defaultProps = {
type: "datetime",
acceptEmptyDate: true,
};
static components = {
BuilderComponent,
BuilderTextInputBase,
};
setup() {
useBuilderComponent();
this.defaultValue = DateTime.now().toUnixInteger().toString();
const { state, commit, preview } = useInputBuilderComponent({
id: this.props.id,
defaultValue: this.props.acceptEmptyDate ? undefined : this.defaultValue,
formatRawValue: this.formatRawValue.bind(this),
parseDisplayValue: this.parseDisplayValue.bind(this),
});
this.domState = state;
this.state = useState({});
effect(
({ value }) => {
// State to display in the input.
this.state.value = value;
},
[state]
);
this.commit = (userInputValue) => {
this.isPreviewing = false;
const result = commit(userInputValue);
return result;
};
this.preview = (userInputValue) => {
this.isPreviewing = true;
preview(userInputValue);
};
const minDate = DateTime.fromObject({ year: 1000 });
const maxDate = DateTime.now().plus({ year: 200 });
const getPickerProps = () => ({
type: this.props.type,
minDate,
maxDate,
value: this.getCurrentValueDateTime(),
rounding: 0,
});
this.formatDateTime = this.props.type === "date" ? formatDate : formatDateTime;
this.dateTimePicker = useDateTimePicker({
target: "root",
format: this.props.format,
get pickerProps() {
return getPickerProps();
},
onApply: (value) => {
this.commit(this.formatDateTime(value));
},
onChange: (value) => {
const dateString = this.formatDateTime(value);
this.preview(dateString);
this.state.value = this.parseDisplayValue(dateString);
},
});
}
/**
* @returns {DateTime} the current value of the datetime picker
*/
getCurrentValueDateTime() {
return this.domState.value ? DateTime.fromSeconds(parseInt(this.domState.value)) : false;
}
/**
* @param {String} rawValue - the raw value in seconds
* @returns {String} a formatted date string
*/
formatRawValue(rawValue) {
return rawValue ? this.formatDateTime(DateTime.fromSeconds(parseInt(rawValue))) : "";
}
/**
* @param {String} displayValue - representing a date
* @returns {String} number of seconds
*/
parseDisplayValue(displayValue) {
if (displayValue === "" && this.props.acceptEmptyDate) {
return undefined;
}
try {
const parsedDateTime = parseDateTime(displayValue);
if (parsedDateTime) {
return parsedDateTime.toUnixInteger().toString();
}
} catch (e) {
// A ConversionError means displayValue is an invalid date: fall
// back to default value.
if (!(e instanceof ConversionError)) {
throw e;
}
if (!this.isPreviewing && displayValue !== "") {
return this.domState.value;
}
}
return this.defaultValue;
}
/**
* @returns {String} a formatted date string
*/
get displayValue() {
return this.state.value !== undefined ? this.formatRawValue(this.state.value) : undefined;
}
get textInputBaseProps() {
return pick(this.props, ...Object.keys(textInputBasePassthroughProps));
}
onFocus() {
this.dateTimePicker.open();
}
}

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderDateTimePicker">
<BuilderComponent>
<div t-ref="root" class="w-100">
<BuilderTextInputBase
t-props="textInputBaseProps"
commit="commit"
preview="preview"
onFocus.bind="onFocus"
value="displayValue"
/>
</div>
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,60 @@
import { Component, onWillStart } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import {
basicContainerBuilderComponentProps,
useVisibilityObserver,
useApplyVisibility,
} from "@html_builder/core/utils";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { BuilderSelect } from "@html_builder/core/building_blocks/builder_select";
import { BuilderSelectItem } from "@html_builder/core/building_blocks/builder_select_item";
export class BuilderFontFamilyPicker extends Component {
static template = "html_builder.BuilderFontFamilyPicker";
static props = {
...basicContainerBuilderComponentProps,
valueParamName: String,
};
static components = {
BuilderSelect,
BuilderSelectItem,
};
setup() {
this.dialog = useService("dialog");
this.orm = useService("orm");
useVisibilityObserver("content", useApplyVisibility("root"));
this.fonts = [];
onWillStart(async () => {
const fontsData = await this.env.editor.shared.builderFont.getFontsData();
this.fonts = fontsData._fonts;
});
}
forwardProps(fontValue) {
const result = Object.assign({}, this.props, {
[this.props.valueParamName]: fontValue.fontFamilyValue,
});
delete result.selectMethod;
delete result.valueParamName;
return result;
}
async onAddFontClick() {
await this.env.editor.shared.websiteFont.addFont(this.props.actionParam);
}
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);
}
}

View file

@ -0,0 +1,4 @@
.o-hidden-font-family-picker + .o-hb-select-toggle {
--btn-padding-x: #{map-get($spacers , 2)};
line-height: 1;
}

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderFontFamilyPicker">
<BuilderSelect className="'o-hidden-font-family-picker'">
<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.styleFontFamily}};">
<i t-if="font.type === 'cloud'" role="button" class="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 o_select_item_only">
<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>

View file

@ -0,0 +1,202 @@
import { BuilderComponent } from "@html_builder/core/building_blocks/builder_component";
import {
basicContainerBuilderComponentProps,
useBuilderComponent,
useInputBuilderComponent,
} from "@html_builder/core/utils";
import { isSmallInteger } from "@html_builder/utils/utils";
import { Component, onWillUpdateProps, useRef } from "@odoo/owl";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { useDropdownState } from "@web/core/dropdown/dropdown_hooks";
import { _t } from "@web/core/l10n/translation";
import { useSortable } from "@web/core/utils/sortable_owl";
export class BuilderList extends Component {
static template = "html_builder.BuilderList";
static props = {
...basicContainerBuilderComponentProps,
id: { type: String, optional: true },
addItemTitle: { type: String, optional: true },
itemShape: {
type: Object,
values: [
{ value: "number" },
{ value: "text" },
{ value: "boolean" },
{ value: "exclusive_boolean" },
],
validate: (value) =>
// is not empty object and doesn't include reserved fields
Object.keys(value).length > 0 && !Object.keys(value).includes("_id"),
optional: true,
},
default: { optional: true },
sortable: { optional: true },
hiddenProperties: { type: Array, optional: true },
records: { type: String, optional: true },
defaultNewValue: { type: Object, optional: true },
columnWidth: { optional: true },
};
static defaultProps = {
addItemTitle: _t("Add"),
itemShape: { value: "text" },
default: { value: _t("Item") },
sortable: true,
hiddenProperties: [],
mode: "button",
defaultNewValue: {},
columnWidth: {},
};
static components = { BuilderComponent, Dropdown };
setup() {
this.validateProps();
this.dropdown = useDropdownState();
useBuilderComponent();
const { state, commit, preview } = useInputBuilderComponent({
id: this.props.id,
defaultValue: this.parseDisplayValue([]),
parseDisplayValue: this.parseDisplayValue,
formatRawValue: this.formatRawValue,
});
this.state = state;
this.commit = commit;
this.preview = preview;
this.allRecords = this.formatRawValue(this.props.records);
onWillUpdateProps((props) => {
this.allRecords = this.formatRawValue(props.records);
});
if (this.props.sortable) {
useSortable({
enable: () => this.props.sortable,
ref: useRef("table"),
elements: ".o_row_draggable",
handle: ".o_handle_cell",
cursor: "grabbing",
placeholderClasses: ["d-table-row"],
onDrop: (params) => {
const { element, previous } = params;
this.reorderItem(element.dataset.id, previous?.dataset.id);
},
});
}
}
validateProps() {
// keys match
const itemShapeKeys = Object.keys(this.props.itemShape);
const defaultKeys = Object.keys(this.props.default);
const allKeys = new Set([...itemShapeKeys, ...defaultKeys]);
if (allKeys.size !== itemShapeKeys.length) {
throw new Error("default properties don't match itemShape");
}
}
get availableRecords() {
const items = this.formatRawValue(this.state.value);
return this.allRecords.filter(
(record) => !items.some((item) => item.id === Number(record.id))
);
}
parseDisplayValue(displayValue) {
return JSON.stringify(displayValue);
}
formatRawValue(rawValue) {
const items = rawValue ? JSON.parse(rawValue) : [];
for (const item of items) {
if (!("_id" in item)) {
item._id = this.getNextAvailableItemId(items);
}
}
return items;
}
addItem(ev) {
const items = this.formatRawValue(this.state.value);
if (!ev.currentTarget.dataset.id) {
items.push(this.makeDefaultItem());
} else {
const elementToAdd = this.allRecords.find(
(el) => el.id === Number(ev.currentTarget.dataset.id)
);
if (!items.some((item) => item.id === Number(ev.currentTarget.dataset.id))) {
items.push(elementToAdd);
}
this.dropdown.close();
}
this.commit(items);
}
deleteItem(itemId) {
const items = this.formatRawValue(this.state.value);
this.commit(items.filter((item) => item._id !== itemId));
}
reorderItem(itemId, previousId) {
let items = this.formatRawValue(this.state.value);
const itemToReorder = items.find((item) => item._id === itemId);
items = items.filter((item) => item._id !== itemId);
const previousItem = items.find((item) => item._id === previousId);
const previousItems = items.slice(0, items.indexOf(previousItem) + 1);
const nextItems = items.slice(items.indexOf(previousItem) + 1, items.length);
const newItems = [...previousItems, itemToReorder, ...nextItems];
this.commit(newItems);
}
makeDefaultItem() {
return {
...this.props.defaultNewValue,
...this.props.default,
_id: this.getNextAvailableItemId(),
};
}
getNextAvailableItemId(items) {
items = items || this.formatRawValue(this.state?.value);
const biggestId = items
.map((item) => parseInt(item._id))
.reduce((acc, id) => (id > acc ? id : acc), -1);
const nextAvailableId = biggestId + 1;
return nextAvailableId.toString();
}
onInput(e) {
this.handleValueChange(e.target, false);
}
onChange(e) {
this.handleValueChange(e.target, true);
}
handleValueChange(targetInputEl, commitToHistory) {
const id = targetInputEl.dataset.id;
const propertyName = targetInputEl.name;
const isCheckbox = targetInputEl.type === "checkbox";
const value = isCheckbox ? targetInputEl.checked : targetInputEl.value;
const items = this.formatRawValue(this.state.value);
if (value === true && this.props.itemShape[propertyName] === "exclusive_boolean") {
for (const item of items) {
item[propertyName] = false;
}
}
const item = items.find((item) => item._id === id);
item[propertyName] = value;
if (!isCheckbox) {
item.id = isSmallInteger(value) ? parseInt(value) : value;
}
if (commitToHistory) {
this.commit(items);
} else {
this.preview(items);
}
}
}

View file

@ -0,0 +1,9 @@
.bl-dropdown-toggle:disabled {
cursor: default;
border: 1px solid #00000088;
&:active {
background-color: unset;
color: unset;
}
}

View file

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<template xml:space="preserve">
<t t-name="html_builder.BuilderList">
<BuilderComponent>
<div class="w-100 p-2 py-0">
<t t-if="state.value?.length > 2">
<div class="o_we_table_wrapper">
<table t-ref="table">
<t t-foreach="formatRawValue(state.value)" t-as="item" t-key="item._id">
<tr class="o_row_draggable" t-att-data-id="item._id">
<td t-if="props.sortable" class="o_handle_cell">
<button type="button" class="btn">
<i class="fa fa-fw fa-arrows" aria-hidden="true"/>
<span class="visually-hidden">Drag and sort field</span>
</button>
</td>
<t t-foreach="Object.entries(props.itemShape).filter(([key,_]) => !props.hiddenProperties.includes(key))" t-as="entry" t-key="entry[0]">
<td t-att-class="props.columnWidth[entry[0]] || ''">
<t t-if="entry[1].endsWith('boolean')">
<div class="o-hb-checkbox o-checkbox form-check o_field_boolean o_boolean_toggle form-switch">
<input type="checkbox" class="form-check-input"
t-att-name="entry[0]"
t-att-checked="item[entry[0]]"
t-att-data-id="item._id"
t-on-click="onChange"
/>
</div>
</t>
<t t-else="">
<input class="o-hb-input-base"
t-att-type="entry[1]"
t-att-name="entry[0]"
t-att-value="item[entry[0]]"
t-att-data-id="item._id"
t-on-input="onInput"
t-on-change="onChange"
/>
</t>
</td>
</t>
<td>
<button type="button" class="btn text-danger builder_list_remove_item"
t-on-click="() => this.deleteItem(item._id)">
<i class="fa fa-fw fa-minus" aria-hidden="true"/>
<span class="visually-hidden">Delete item</span>
</button>
</td>
</tr>
</t>
</table>
</div>
</t>
<div class="o-hb-select-wrapper" t-if="this.allRecords and this.allRecords.length" >
<div class="mt-1 ms-auto">
<Dropdown state="this.dropdown" menuClass="'o-hb-select-dropdown mt-1'">
<button class="bl-dropdown-toggle o-hb-select-toggle o-hb-btn btn btn-success text-start o-dropdown-caret w-100 mx-auto px-3"
t-att-disabled="!this.availableRecords.length">
<t t-set="noAvailableRecordLabel">You don't have any record to add</t>
<span class=""
t-att-title="!this.availableRecords.length ? noAvailableRecordLabel : ''"
t-att-style="!this.availableRecords.length ? 'pointer-events: auto; display: inline-block;' : ''">
<t t-out="props.addItemTitle" />
</span>
</button>
<t t-set-slot="content">
<t t-foreach="this.availableRecords" t-as="item" t-key="item.id">
<t t-foreach="Object.entries(props.itemShape).filter(([key, value]) => !value.endsWith('boolean'))" t-as="entry" t-key="entry[0]">
<div class="o-hb-select-dropdown-item d-flex flex-column cursor-pointer o-dropdown-item dropdown-item o-navigable w-100 mx-auto"
style="max-height: 50vh;"
t-att-type="entry[1]"
t-att-name="entry[0]"
t-att-data-id="item.id"
t-on-click="addItem"
>
<t t-out="item[entry[0]]" />
</div>
</t>
</t>
</t>
</Dropdown>
</div>
</div>
<t t-else="">
<div class="text-end mt-1">
<button type="button" class="builder_list_add_item o-hb-btn btn btn-success px-3"
t-on-click="addItem">
<t t-out="props.addItemTitle"/>
</button>
</div>
</t>
</div>
</BuilderComponent>
</t>
</template>

View file

@ -0,0 +1,90 @@
import { Component, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import {
basicContainerBuilderComponentProps,
getAllActionsAndOperations,
useBuilderComponent,
useDomState,
} from "../utils";
import { BuilderComponent } from "./builder_component";
import { BasicMany2Many } from "./basic_many2many";
export class BuilderMany2Many extends Component {
static template = "html_builder.BuilderMany2Many";
static props = {
...basicContainerBuilderComponentProps,
model: String,
m2oField: { type: String, optional: true },
fields: { type: Array, element: String, optional: true },
domain: { type: Array, optional: true },
limit: { type: Number, optional: true },
};
static defaultProps = BuilderComponent.defaultProps;
static components = { BuilderComponent, BasicMany2Many };
setup() {
useBuilderComponent();
this.fields = useService("field");
const { getAllActions, callOperation } = getAllActionsAndOperations(this);
this.callOperation = callOperation;
this.applyOperation = this.env.editor.shared.history.makePreviewableAsyncOperation(
this.callApply.bind(this)
);
this.state = useState({
searchModel: undefined,
});
this.domState = useDomState((el) => {
const getAction = this.env.editor.shared.builderActions.getAction;
const actionWithGetValue = getAllActions().find(
({ actionId }) => getAction(actionId).getValue
);
const { actionId, actionParam } = actionWithGetValue;
const actionValue = getAction(actionId).getValue({
editingElement: el,
params: actionParam,
});
return {
selection: JSON.parse(actionValue || "[]"),
};
});
onWillStart(async () => {
await this.handleProps(this.props);
});
onWillUpdateProps(async (newProps) => {
await this.handleProps(newProps);
});
}
async handleProps(props) {
if (props.m2oField) {
const modelData = await this.fields.loadFields(props.model, {
fieldNames: [props.m2oField],
});
this.state.searchModel = modelData[props.m2oField].relation;
if (!this.state.searchModel) {
throw new Error(`m2oField ${props.m2oField} is not a relation field`);
}
} else {
this.state.searchModel = props.model;
}
}
callApply(applySpecs) {
const proms = [];
for (const applySpec of applySpecs) {
proms.push(
applySpec.action.apply({
editingElement: applySpec.editingElement,
params: applySpec.actionParam,
value: applySpec.actionValue,
loadResult: applySpec.loadResult,
dependencyManager: this.env.dependencyManager,
})
);
}
return proms;
}
setSelection(newSelection) {
this.callOperation(this.applyOperation.commit, {
userInputValue: JSON.stringify(newSelection),
});
}
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderMany2Many">
<BuilderComponent>
<BasicMany2Many
model="state.searchModel"
limit="props.limit"
domain="props.domain"
fields="props.fields"
selection="domState.selection"
setSelection="setSelection.bind(this)"
/>
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,134 @@
import { Component } from "@odoo/owl";
import {
basicContainerBuilderComponentProps,
getAllActionsAndOperations,
useBuilderComponent,
useDependencyDefinition,
useDomState,
useHasPreview,
} from "../utils";
import { BuilderComponent } from "./builder_component";
import { SelectMany2X } from "./select_many2x";
import { useCachedModel } from "../cached_model_utils";
export class BuilderMany2One extends Component {
static template = "html_builder.BuilderMany2One";
static props = {
...basicContainerBuilderComponentProps,
model: String,
fields: { type: Array, element: String, optional: true },
domain: { type: Array, optional: true },
limit: { type: Number, optional: true },
id: { type: String, optional: true },
allowUnselect: { type: Boolean, optional: true },
defaultMessage: { type: String, optional: true },
createAction: { type: String, optional: true },
};
static defaultProps = {
...BuilderComponent.defaultProps,
allowUnselect: true,
};
static components = { BuilderComponent, SelectMany2X };
setup() {
useBuilderComponent();
const { getAllActions, callOperation } = getAllActionsAndOperations(this);
this.cachedModel = useCachedModel();
this.callOperation = callOperation;
this.hasPreview = useHasPreview(getAllActions);
this.applyOperation = this.env.editor.shared.history.makePreviewableAsyncOperation(
this.callApply.bind(this)
);
const getAction = this.env.editor.shared.builderActions.getAction;
const actionWithGetValue = getAllActions().find(
({ actionId }) => getAction(actionId).getValue
);
const { actionId, actionParam } = actionWithGetValue;
const getValue = (el) =>
getAction(actionId).getValue({ editingElement: el, params: actionParam });
this.domState = useDomState(async (el) => {
const selectedString = getValue(el);
const selected = selectedString && JSON.parse(selectedString);
if (selected && !("display_name" in selected && "name" in selected)) {
Object.assign(
selected,
(
await this.cachedModel.ormRead(
this.props.model,
[selected.id],
["display_name", "name"]
)
)[0]
);
}
return { selected };
});
if (this.props.id) {
useDependencyDefinition(this.props.id, {
getValue: () => getValue(this.env.getEditingElement()),
});
}
if (this.props.createAction) {
this.createAction = this.env.editor.shared.builderActions.getAction(
this.props.createAction
);
this.createOperation = this.env.editor.shared.history.makePreviewableOperation(
this.createAction.apply
);
}
}
callApply(applySpecs, isPreviewing) {
const proms = [];
for (const applySpec of applySpecs) {
if (applySpec.actionValue === undefined) {
applySpec.action.clean({
isPreviewing,
editingElement: applySpec.editingElement,
params: applySpec.actionParam,
dependencyManager: this.env.dependencyManager,
});
} else {
proms.push(
applySpec.action.apply({
isPreviewing,
editingElement: applySpec.editingElement,
params: applySpec.actionParam,
value: applySpec.actionValue,
loadResult: applySpec.loadResult,
dependencyManager: this.env.dependencyManager,
})
);
}
}
return Promise.all(proms);
}
select(newSelected) {
this.callOperation(this.applyOperation.commit, {
userInputValue: newSelected && JSON.stringify(newSelected),
});
}
preview(newSelected) {
this.callOperation(this.applyOperation.preview, {
preview: true,
userInputValue: newSelected && JSON.stringify(newSelected),
operationParams: {
cancellable: true,
cancelPrevious: () => this.applyOperation.revert(),
},
});
}
revert() {
// The `next` will cancel the previous operation, which will revert
// the operation in case of a preview.
this.env.editor.shared.operation.next();
}
create(name) {
const args = { editingElement: this.env.getEditingElement(), value: name };
this.env.editor.shared.operation.next(() => this.createOperation.commit(args), {
load: () =>
this.createAction.load?.(args).then((loadResult) => (args.loadResult = loadResult)),
});
}
}

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderMany2One">
<BuilderComponent>
<SelectMany2X
model="props.model"
fields="props.fields"
limit="props.limit"
domain="props.domain"
selected="domState.selected ? [domState.selected] : []"
select="select.bind(this)"
preview="hasPreview ? preview.bind(this) : undefined"
revert.bind="revert"
create="props.createAction ? create.bind(this) : undefined"
message="domState.selected?.display_name || props.defaultMessage"
/>
<button type="button"
t-if="domState.selected and props.allowUnselect"
class="o-hb-btn o-hb-btn-has-icon btn btn-secondary"
t-on-click="() => this.select()"
t-on-pointerenter="hasPreview ? () => this.preview() : undefined"
t-on-pointerleave="revert"
t-on-focusin="hasPreview ? () => this.preview() : undefined"
t-on-focusout="revert"
aria-label="Unselect">
<i class="oi oi-close" role="presentation"/>
</button>
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,6 @@
.o-hb-input-field-number {
.popover & {
--o-hb-field-input-bg-popover: #{$o-we-sidebar-content-field-input-bg};
--o-hb-field-color-popover: #{$o-we-sidebar-content-field-color};
}
}

View file

@ -0,0 +1,187 @@
import { convertNumericToUnit, getHtmlStyle } from "@html_editor/utils/formatting";
import { Component } from "@odoo/owl";
import {
basicContainerBuilderComponentProps,
useInputBuilderComponent,
useBuilderComponent,
useInputDebouncedCommit,
} from "../utils";
import { BuilderComponent } from "./builder_component";
import {
BuilderTextInputBase,
textInputBasePassthroughProps,
} from "@html_builder/core/building_blocks/builder_text_input_base";
import { useChildRef } from "@web/core/utils/hooks";
import { pick } from "@web/core/utils/objects";
export class BuilderNumberInput extends Component {
static template = "html_builder.BuilderNumberInput";
static props = {
...basicContainerBuilderComponentProps,
...textInputBasePassthroughProps,
default: { type: [Number, { value: null }], optional: true },
unit: { type: String, optional: true },
saveUnit: { type: String, optional: true },
step: { type: Number, optional: true },
min: { type: Number, optional: true },
max: { type: Number, optional: true },
composable: { type: Boolean, optional: true },
applyWithUnit: { type: Boolean, optional: true },
};
static components = { BuilderComponent, BuilderTextInputBase };
static defaultProps = {
composable: false,
applyWithUnit: true,
default: 0,
};
setup() {
if (this.props.saveUnit && !this.props.unit) {
throw new Error("'unit' must be defined to use the 'saveUnit' props");
}
useBuilderComponent();
const { state, commit, preview } = useInputBuilderComponent({
id: this.props.id,
defaultValue: this.props.default === null ? null : this.props.default?.toString(),
formatRawValue: this.formatRawValue.bind(this),
parseDisplayValue: this.parseDisplayValue.bind(this),
});
this.commit = commit;
this.preview = preview;
this.state = state;
this.inputRef = useChildRef();
this.debouncedCommitValue = useInputDebouncedCommit(this.inputRef);
}
/**
* @param {string | number} values - Values separated by spaces or a number
* @param {(string) => string} convertSingleValueFn - Convert a single value
*/
convertSpaceSplitValues(values, convertSingleValueFn) {
if (typeof values === "number") {
return convertSingleValueFn(values.toString());
}
if (values === null) {
return values;
}
if (!values) {
return "";
}
return values.trim().split(/\s+/g).map(convertSingleValueFn).join(" ");
}
formatRawValue(rawValue) {
return this.convertSpaceSplitValues(rawValue, (value) => {
const unit = this.props.unit;
const { savedValue, savedUnit } = value.match(
/(?<savedValue>[\d.e+-]+)(?<savedUnit>\w*)/
).groups;
if (savedUnit || this.props.saveUnit) {
// Convert value from saveUnit to unit
value = convertNumericToUnit(
parseFloat(savedValue),
savedUnit || this.props.saveUnit,
unit,
getHtmlStyle(this.env.getEditingElement().ownerDocument)
);
}
// Put *at most* 3 decimal digits
return parseFloat(parseFloat(value).toFixed(3)).toString();
});
}
clampValue(value) {
if (!value) {
return value;
}
value = parseFloat(value);
if (value < this.props.min) {
return `${this.props.min}`;
}
if (value > this.props.max) {
return `${this.props.max}`;
}
return +value.toFixed(3);
}
parseDisplayValue(displayValue) {
if (!displayValue) {
return displayValue;
}
displayValue = displayValue.replace(/,/g, ".");
// Only accept 0-9, dot, - sign and space if multiple values are allowed
if (this.props.composable) {
displayValue = displayValue
.trim()
.replace(/[^0-9.-\s]/g, "")
.replace(/(?<!^|\s)-/g, "");
} else {
displayValue = displayValue
.trim()
// Remove any space after a "-" to accept "- 10" as "-10"
.replace(/-\s*/g, "-")
.split(" ")[0]
.replace(/[^0-9.-]/g, "")
// Only keep "-" if it is at the start
.replace(/(?<!^)-/g, "")
// Only keep the first "."
.replace(/^([^.]*)\.?(.*)/, (_, a, b) => a + (b ? "." + b.replace(/\./g, "") : ""));
}
displayValue =
displayValue.split(" ").map(this.clampValue.bind(this)).join(" ") || this.props.default;
return this.convertSpaceSplitValues(displayValue, (value) => {
if (value === "") {
return value;
}
const unit = this.props.unit;
const saveUnit = this.props.saveUnit;
const applyWithUnit = this.props.applyWithUnit;
if (unit && saveUnit) {
// Convert value from unit to saveUnit
value = convertNumericToUnit(
value,
unit,
saveUnit,
getHtmlStyle(this.env.getEditingElement().ownerDocument)
);
}
if (unit && applyWithUnit) {
if (saveUnit || saveUnit === "") {
value = value + saveUnit;
} else {
value = value + unit;
}
}
return value;
});
}
get displayValue() {
return this.formatRawValue(this.state.value);
}
onKeydown(e) {
if (!["ArrowUp", "ArrowDown"].includes(e.key)) {
return;
}
const values = e.target.value.split(" ").map((number) => parseFloat(number) || 0);
if (e.key === "ArrowUp") {
values.forEach((value, i) => {
values[i] = this.clampValue(value + (this.props.step || 1));
});
} else if (e.key === "ArrowDown") {
values.forEach((value, i) => {
values[i] = this.clampValue(value - (this.props.step || 1));
});
}
e.target.value = values.join(" ");
this.preview(e.target.value);
this.debouncedCommitValue();
}
get textInputBaseProps() {
return pick(this.props, ...Object.keys(textInputBasePassthroughProps));
}
}

View file

@ -0,0 +1,25 @@
.o-hb-input-field-number {
@extend %o-hb-input-base;
flex: 0 1 calc(50% - calc(var(--hb-row-content-gap) * 0.5));
font-family: $o-we-font-monospace;
.popover & {
--o-hb-field-input-bg: var(--o-hb-field-input-bg-popover, #{$o-we-fg-lighter});
--o-hb-field-color: var(--o-hb-field-color-popover, #{$o-we-bg-lightest});
}
.o-hb-input-number {
appearance: none;
border: none;
background: none;
color: inherit;
padding: 0;
margin-right: $o-we-sidebar-content-field-clickable-spacing * 0.5;
}
.o-hb-input-field-unit {
color: var(--o-hb-field-color, #{$o-we-sidebar-content-field-color});
font-size: $o-we-font-size-xs;
}
}

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderNumberInput">
<BuilderComponent>
<BuilderTextInputBase
t-props="textInputBaseProps"
inputRef="inputRef"
value="displayValue"
classes="'o-hb-input-field-number justify-content-end'"
inputClasses="props.inputClasses ? `o-hb-input-number text-end ${props.inputClasses}` : 'o-hb-input-number text-end'"
commit="commit"
preview="preview"
onKeydown.bind="onKeydown"
>
<span class="o-hb-input-field-unit" t-out="props.unit"/>
</BuilderTextInputBase>
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,3 @@
.popover .o-hb-range {
--o-hb-range-thumb-color-popover: #{$o-we-fg-lighter};
}

View file

@ -0,0 +1,118 @@
import { Component, useRef } from "@odoo/owl";
import {
basicContainerBuilderComponentProps,
useActionInfo,
useBuilderComponent,
useInputBuilderComponent,
useInputDebouncedCommit,
} from "../utils";
import { BuilderComponent } from "./builder_component";
export class BuilderRange extends Component {
static template = "html_builder.BuilderRange";
static props = {
...basicContainerBuilderComponentProps,
min: { type: Number, optional: true },
max: { type: Number, optional: true },
step: { type: Number, optional: true },
displayRangeValue: { type: Boolean, optional: true },
computedOutput: { type: Function, optional: true },
unit: { type: String, optional: true },
};
static defaultProps = {
...BuilderComponent.defaultProps,
min: 0,
max: 100,
step: 1,
displayRangeValue: false,
};
static components = { BuilderComponent };
setup() {
this.info = useActionInfo();
useBuilderComponent();
const { state, commit, preview } = useInputBuilderComponent({
id: this.props.id,
formatRawValue: this.formatRawValue.bind(this),
parseDisplayValue: this.parseDisplayValue.bind(this),
});
this.inputRef = useRef("inputRef");
this.debouncedCommitValue = useInputDebouncedCommit(this.inputRef);
this.commit = commit;
this.preview = preview;
this.state = state;
}
formatRawValue(value) {
if (this.props.unit) {
// Remove the unit
value = value.slice(0, -this.props.unit.length);
}
return value;
}
parseDisplayValue(value) {
if (this.props.unit) {
// Add the unit
value = `${value}${this.props.unit}`;
}
return value;
}
onChange(e) {
const normalizedDisplayValue = this.commit(e.target.value);
e.target.value = normalizedDisplayValue;
}
onInput(e) {
this.preview(e.target.value);
if (this.props.displayRangeValue) {
this.state.value = this.parseDisplayValue(e.target.value);
}
}
onKeydown(e) {
if (!["ArrowLeft", "ArrowUp", "ArrowDown", "ArrowRight"].includes(e.key)) {
return;
}
e.preventDefault();
let value = parseInt(e.target.value);
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
value = Math.max(this.min, value - this.props.step);
} else {
value = Math.min(this.max, value + this.props.step);
}
e.target.value = value;
this.onInput(e);
this.debouncedCommitValue();
}
get rangeInputValue() {
return this.state.value ? this.formatRawValue(this.state.value) : "0";
}
get displayValue() {
let value = this.rangeInputValue;
if (this.props.computedOutput) {
value = this.props.computedOutput(value);
} else if (this.props.unit) {
value = `${value}${this.props.unit}`;
}
return value;
}
get className() {
const baseClasses = "p-0 border-0";
return this.props.min > this.props.max ? `${baseClasses} o_we_inverted_range` : baseClasses;
}
get min() {
return this.props.min > this.props.max ? this.props.max : this.props.min;
}
get max() {
return this.props.min > this.props.max ? this.props.min : this.props.max;
}
}

View file

@ -0,0 +1,151 @@
.o-hb-range {
.popover & {
--o-hb-range-thumb-color: var(--o-hb-range-thumb-color-popover, #{$o-we-bg-darkest});
}
&:has(output) {
.o-hb-rangeInput {
// Needed because without it the output overflows to the right
// and makes a horizontal scrollbar appear on
// 'o_we_customize_panel' if 'o_we_customize_panel' already has
// a vertical scrollbar.
min-width: 0;
}
}
.o-hb-rangeInput {
height: $o-we-sidebar-content-field-height;
padding: 0 $o-we-item-border-width 0 0;
background-color: transparent;
appearance: none;
cursor: col-resize;
width: 100%;
&:focus {
outline: none;
&::-webkit-slider-thumb { box-shadow: none; }
&::-moz-range-thumb { box-shadow: none; }
&::-ms-thumb { box-shadow: none; }
}
&:focus-visible {
&::-webkit-slider-thumb {
box-shadow: 0 0 0 1px $o-we-bg-dark, 0 0 0 3px $o-we-sidebar-content-field-progress-active-color;
}
&::-moz-range-thumb {
box-shadow: 0 0 0 1px $o-we-bg-dark, 0 0 0 3px $o-we-sidebar-content-field-progress-active-color;
}
&::-ms-thumb {
box-shadow: 0 0 0 1px $o-we-bg-dark, 0 0 0 3px $o-we-sidebar-content-field-progress-active-color;
}
}
&::-moz-focus-outer {
border: 0;
}
&::-webkit-slider-thumb {
width: $o-we-sidebar-content-field-progress-control-height;
height: $o-we-sidebar-content-field-progress-control-height;
margin-top: ($o-we-sidebar-content-field-progress-height - $o-we-sidebar-content-field-progress-control-height) / 2;
border: none;
border-radius: 10rem;
background-color: var(--o-hb-range-thumb-color, #{$o-we-fg-lighter});
box-shadow: none;
appearance: none;
&:active {
background-color: $o-we-sidebar-content-field-progress-active-color;
}
}
&::-webkit-slider-runnable-track {
width: 100%;
height: $o-we-sidebar-content-field-progress-height;
// Unfortunately, Chrome does not support customizing the lower part of the track
background-color: $o-we-sidebar-content-field-progress-color;
border-color: transparent;
border-radius: 10rem;
box-shadow: none;
position: relative;
// z-index: 1000;
}
&::-moz-range-thumb {
width: $o-we-sidebar-content-field-progress-control-height;
height: $o-we-sidebar-content-field-progress-control-height;
border: none;
border-radius: 10rem;
background-color: var(--o-hb-range-thumb-color, #{$o-we-fg-lighter});
box-shadow: none;
appearance: none;
&:active {
background-color: $o-we-sidebar-content-field-progress-active-color;
}
}
&::-moz-range-track {
width: 100%;
height: $o-we-sidebar-content-field-progress-height;
background-color: $o-we-sidebar-content-field-progress-color;
border-color: transparent;
border-radius: 10rem;
box-shadow: none;
}
&::-moz-range-progress {
background-color: $o-we-sidebar-content-field-progress-active-color;
height: $o-we-sidebar-content-field-progress-height;
border-color: transparent;
border-radius: 10rem;
}
&::-ms-thumb {
width: $o-we-sidebar-content-field-progress-control-height;
height: $o-we-sidebar-content-field-progress-control-height;
margin-top: 0;
margin-right: 0;
margin-left: 0;
border: none;
border-radius: 10rem;
background-color: var(--o-hb-range-thumb-color, #{$o-we-fg-lighter});
box-shadow: none;
appearance: none;
&:active {
background-color: $o-we-sidebar-content-field-progress-active-color;
}
}
&::-ms-track {
width: 100%;
height: $o-we-sidebar-content-field-progress-height;
background-color: transparent;
border-color: transparent;
border-width: $o-we-sidebar-content-field-progress-control-height / 2;
box-shadow: none;
}
&::-ms-fill-lower {
background-color: $o-we-sidebar-content-field-progress-active-color;
border-radius: 10rem;
@include border-radius($form-range-track-border-radius);
}
&::-ms-fill-upper {
background-color: $o-we-sidebar-content-field-progress-color;
border-radius: 10rem;
}
&.o_we_inverted_range {
transform: rotate(180deg);
&::-moz-range-track {
background-color: $o-we-sidebar-content-field-progress-active-color;
}
&::-moz-range-progress {
background-color: $o-we-sidebar-content-field-progress-color;
}
&::-ms-fill-lower {
background-color: $o-we-sidebar-content-field-progress-color;
}
&::-ms-fill-upper {
background-color: $o-we-sidebar-content-field-progress-active-color;
}
}
}
output {
font-size: $o-we-font-size-xs;
}
}

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderRange">
<BuilderComponent>
<div class="o-hb-range d-flex flex-row flex-nowrap align-items-center"
t-att-data-action-id="info.actionId"
t-att-data-action-param="info.actionParam"
t-att-data-action-value="info.actionValue"
t-att-data-class-action="info.classAction"
t-att-data-style-action="info.styleAction"
t-att-data-style-action-value="info.styleActionValue"
t-att-data-attribute-action="info.attributeAction"
t-att-data-attribute-action-value="info.attributeActionValue">
<input
type="range"
class="o-hb-rangeInput"
t-ref="inputRef"
t-att-class="className"
t-att-min="min"
t-att-max="max"
t-att-step="props.step"
t-att-value="rangeInputValue"
t-on-change="onChange"
t-on-input="onInput"
t-on-keydown="onKeydown" />
<output t-if="props.displayRangeValue" t-out="displayValue" class="ms-1"/>
</div>
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,11 @@
.hb-row {
.popover & {
--o-hb-row-active-bg-color-popover: #{mix($o-we-color-accent, $o-we-bg-dark, 20%)};
--o-hb-row-color-popover: #{$o-we-fg-dark};
--o-hb-row-color-active-popover: #{$o-we-fg-lighter};
--o-hb-row-sublevel-color-popover: #{mix($o-we-bg-lighter, $o-we-fg-light)};
.btn.btn-secondary {
--btn-border-color: #{$o-we-bg-light};
}
}
}

View file

@ -0,0 +1,133 @@
import { Component, onMounted, useEffect, useRef, useState } from "@odoo/owl";
import { useTransition } from "@web/core/transition";
import { uniqueId } from "@web/core/utils/functions";
import { useService } from "@web/core/utils/hooks";
import {
basicContainerBuilderComponentProps,
useApplyVisibility,
useBuilderComponent,
useVisibilityObserver,
} from "../utils";
import { BuilderComponent } from "./builder_component";
export class BuilderRow extends Component {
static template = "html_builder.BuilderRow";
static components = { BuilderComponent };
static props = {
...basicContainerBuilderComponentProps,
label: { type: String, optional: true },
tooltip: { type: String, optional: true },
slots: { type: Object, optional: true },
level: { type: Number, optional: true },
expand: { type: Boolean, optional: true },
initialExpandAnim: { type: Boolean, optional: true },
extraLabelClass: { type: String, optional: true },
observeCollapseContent: { type: Boolean, optional: true },
};
static defaultProps = { expand: false, observeCollapseContent: false };
setup() {
useBuilderComponent();
useVisibilityObserver("content", useApplyVisibility("root"));
this.state = useState({
expanded: this.props.expand,
});
this.hasTooltip = this.props.tooltip ? true : undefined;
if (this.props.slots.collapse) {
useVisibilityObserver("collapse-content", useApplyVisibility("collapse"));
this.collapseContentId = uniqueId("builder_collapse_content_");
}
this.labelRef = useRef("label");
this.collapseContentRef = useRef("collapse-content");
let isMounted = false;
onMounted(() => {
if (this.props.initialExpandAnim) {
setTimeout(() => {
this.toggleCollapseContent();
}, 150);
}
});
this.transition = useTransition({
initialVisibility: this.props.expand,
leaveDuration: 350,
name: "hb-collapse-content",
});
useEffect(
(stage) => {
const isFirstMount = !isMounted;
isMounted = true;
const contentEl = this.collapseContentRef.el;
if (!contentEl) return;
const setHeightAuto = () => {
contentEl.style.height = "auto";
};
// Skip transition on first mount if expand=true.
if (isFirstMount && this.props.expand) {
setHeightAuto();
return;
}
switch (stage) {
case "enter-active": {
contentEl.style.height = contentEl.scrollHeight + "px";
contentEl.addEventListener("transitionend", setHeightAuto, { once: true});
break;
}
case "leave": {
// Collapse from current height to 0
contentEl.style.height = contentEl.scrollHeight + "px";
void contentEl.offsetHeight; // force reflow
contentEl.style.height = "0px";
break;
}
}
},
() => [this.transition.stage]
);
this.tooltip = useService("tooltip");
}
getLevelClass() {
return this.props.level ? `hb-row-sublevel hb-row-sublevel-${this.props.level}` : "";
}
toggleCollapseContent() {
this.state.expanded = !this.state.expanded;
this.transition.shouldMount = this.state.expanded;
}
get displayCollapseContent() {
return this.transition.shouldMount || this.props.observeCollapseContent;
}
get collapseContentClass() {
const isNotVisible = this.props.observeCollapseContent && !this.transition.shouldMount;
return `${this.transition.className} ${isNotVisible ? "d-none" : ""}`;
}
openTooltip() {
if (this.hasTooltip === undefined) {
const labelEl = this.labelRef.el;
this.hasTooltip = labelEl && labelEl.clientWidth < labelEl.scrollWidth;
}
if (this.hasTooltip) {
const tooltip = this.props.tooltip || this.props.label;
this.removeTooltip = this.tooltip.add(this.labelRef.el, { tooltip });
}
}
closeTooltip() {
if (this.removeTooltip) {
this.removeTooltip();
}
}
}

View file

@ -0,0 +1,156 @@
@mixin sublevel-line($_level-left: 0) {
position: absolute;
top: 0;
bottom: 0;
left: $_level-left - $o-we-border-width;
border: $o-we-border-width solid var(--o-hb-row-sublevel-color, #{mix($o-we-bg-lighter, $o-we-fg-light)});
border-width: 0 0 $o-we-border-width $o-we-border-width;
pointer-events: none;
transform: translate(0, ($o-hb-row-min-height + $o-hb-row-spacing) * -0.5 + $o-we-border-width);
content: "";
}
.hb-row {
$_z-index: 4;
.popover & {
--o-hb-row-bg-color: var(--o-hb-row-bg-color-popover, --popover-bg);
--o-hb-row-active-bg-color: var(--o-hb-row-active-bg-color-popover, #{mix($o-we-color-accent, $o-we-fg-lighter, 20%)});
--o-hb-row-color: var(--o-hb-row-color-popover, #{$o-we-bg-lightest});
--o-hb-row-color-active: var(--o-hb-row-color-active-popover, #{$o-we-bg-darkest});
--o-hb-row-sublevel-color: var(--o-hb-row-sublevel-color-popover, #{mix($o-we-bg-lighter, $o-we-fg-light)});
.hb-row-content:has(button.fa-trash) {
align-items: stretch;
}
.hb-row-label {
flex: 0 0 38%;
}
.hb-row-content {
flex: 0 0 62%;
}
}
--o-hb-btn-minHeight: #{$o-hb-row-min-height - ($o-hb-row-spacing * 0.5)};
min-height: $o-hb-row-min-height;
padding-top: $o-hb-row-spacing;
box-sizing: content-box;
align-items: center;
color: var(--o-hb-row-color, #{$o-we-fg-dark});
.hb-container-subtitle {
padding: $o-hb-row-spacing 0 $o-hb-row-spacing $o-hb-row-padding-left;
}
.hb-row-label {
min-width: 0;
flex: 0 0 44%;
z-index: $_z-index;
background-color: var(--o-hb-row-bg-color, #{$o-we-bg-lighter});
padding: $o-hb-row-spacing 0 $o-hb-row-spacing $o-hb-row-padding-left;
align-self: baseline;
}
.hb-row-content {
--hb-row-content-gap: 2%;
flex: 0 0 56%;
width: 56%;
align-items: center;
gap: var(--hb-row-content-gap);
z-index: $_z-index; // Make sure outlines / shadows are not trimmed.
}
.o_hb_collapse_toggler {
position: absolute;
padding-left: $o-hb-row-spacing;
align-self: baseline;
z-index: $_z-index + 1;
box-shadow: none;
font-size: 1em;
color: inherit;
&.active i {
margin-left: -1px;
}
}
.icon-sup {
position: relative;
top: -0.4em;
margin-left: 0.2em;
font-size: 0.65em;
}
&:has(> div > .o_cc_preview_wrapper) > .o_hb_collapse_toggler {
align-self: center;
}
// ==== States
&:hover, &.active {
.hb-row-content {
color: var(--o-hb-row-color-active, #{$o-we-fg-lighter});
}
}
&:has(.hb-row-content:hover) {
color: var(--o-hb-row-color-active, #{$o-we-fg-lighter});
}
&:has(.hb-row-label-actionable:hover) .o_hb_collapse_toggler {
color: var(--o-hb-row-color-active, #{$o-we-fg-lighter});
}
&.active {
background-color: var(--o-hb-row-active-bg-color, #{mix($o-we-color-accent, $o-we-bg-dark, 20%)});
.hb-row-label {
background-color: inherit;
}
}
// Actionable labels hover states are currently dependant on the
// presence of a collapsable icon. Should be improved using states.
.o_hb_collapse_toggler:where(:not(.d-none)) + .hb-row-label-actionable:hover {
cursor: pointer;
color: var(--o-hb-row-color-active, #{$o-we-fg-lighter});
}
// ==== Sublevels
&.hb-row-sublevel > .hb-row-label::after {
@include sublevel-line;
}
// Sublevel specific rules
@for $_level from 1 through 3 {
$_level-padding: $o-hb-row-padding-left + ($o-hb-row-indent-left * $_level);
$_level-width: $o-hb-row-spacing + $o-we-border-width;
$_level-left: $_level-padding - $_level-width - $o-we-border-width;
&.hb-row-sublevel-#{$_level} {
.hb-row-label, & > .hb-row-label::after {
z-index: $_z-index - $_level;
}
.hb-row-label {
padding-left: $_level-padding;
}
& > .hb-row-label::after {
width: $_level-width;
left: $_level-left - $o-we-border-width;
}
}
&[data-label=" "]:not(.hb-row-sublevel):has(+ .hb-row-sublevel-#{$_level})::after {
@include sublevel-line($_level-left);
z-index: $_z-index - $_level;
}
}
& + .hb-collapse-content {
overflow: hidden;
height: 0; // default collapsed state
transition: height 0.35s ease;
}
}

View file

@ -0,0 +1,4 @@
$o-hb-row-min-height: 28px !default;
$o-hb-row-spacing: 4px !default;
$o-hb-row-padding-left: $o-hb-row-spacing * 2.6 + $o-we-border-width * 2 !default;
$o-hb-row-indent-left: $o-hb-row-spacing * 2 !default;

View file

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderRow">
<BuilderComponent>
<div
class="hb-row d-flex position-relative pe-2"
t-att-class="this.getLevelClass()"
t-ref="root"
t-att-data-label="props.label"
>
<button
t-ref="collapse"
t-if="props.slots.collapse"
class="o_hb_collapse_toggler btn border-0 bg-transparent"
t-att-class="{ 'active fa-rotate-90': state.expanded }"
title="Toggle more options"
t-on-click="toggleCollapseContent"
t-att-aria-expanded="state.expanded ? 'true' : 'false'"
t-att-aria-controls="collapseContentId">
<i
class="fa fa-caret-right"
role="img"
/>
</button>
<t t-if="props.label">
<div
class="hb-row-label d-flex align-items-center"
t-att-class="{'hb-row-label-actionable': props.slots.collapse, 'bg-transparent': !props.label.trim()}"
t-attf-class="#{this.props.extraLabelClass || ''}"
t-on-pointerover="openTooltip"
t-on-pointerleave="closeTooltip"
t-on-click="toggleCollapseContent"
>
<span class="text-nowrap text-truncate" t-out="props.label" t-ref="label"/>
<i t-if="state.tooltip" class="fa fa-question icon-sup"/>
</div>
<div
class="hb-row-content d-flex"
t-ref="content"
>
<t t-slot="default"/>
</div>
</t>
<div
t-else=""
class="d-flex"
style="flex-grow: 1; flex-basis: 0; min-width: 0; gap: 4px;"
t-ref="content"
>
<t t-slot="default" toggleCollapseContent="() => this.toggleCollapseContent()"/>
</div>
</div>
<div t-if="displayCollapseContent" t-att-class="collapseContentClass" t-ref="collapse-content" t-att-id="collapseContentId">
<t t-slot="collapse"/>
</div>
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,74 @@
import { Component, onMounted, useRef, useSubEnv, xml } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { Dropdown } from "@web/core/dropdown/dropdown";
import {
basicContainerBuilderComponentProps,
useVisibilityObserver,
useApplyVisibility,
useSelectableComponent,
} from "../utils";
import { BuilderComponent } from "./builder_component";
import { useDropdownState } from "@web/core/dropdown/dropdown_hooks";
import { setElementContent } from "@web/core/utils/html";
export class WithIgnoreItem extends Component {
static template = xml`<t t-slot="default"/>`;
static props = {
slots: { type: Object },
};
setup() {
useSubEnv({
ignoreBuilderItem: true,
});
}
}
export class BuilderSelect extends Component {
static template = "html_builder.BuilderSelect";
static props = {
...basicContainerBuilderComponentProps,
className: { type: String, optional: true },
dropdownContainerClass: { type: String, optional: true },
slots: {
type: Object,
shape: {
default: Object, // Content is not optional
fixedButton: { type: Object, optional: true },
},
},
};
static components = {
Dropdown,
BuilderComponent,
WithIgnoreItem,
};
setup() {
useVisibilityObserver("content", useApplyVisibility("root"));
this.dropdown = useDropdownState();
const buttonRef = useRef("button");
let currentLabel;
const updateCurrentLabel = () => {
if (!this.props.slots.fixedButton) {
const newHtml = currentLabel || _t("None");
if (buttonRef.el && buttonRef.el.innerHTML !== newHtml) {
setElementContent(buttonRef.el, newHtml);
}
}
};
useSelectableComponent(this.props.id, {
onItemChange(item) {
currentLabel = item.getLabel();
updateCurrentLabel();
},
});
onMounted(updateCurrentLabel);
useSubEnv({
onSelectItem: () => {
this.dropdown.close();
},
});
}
}

View file

@ -0,0 +1,104 @@
:not(.dropstart) > .dropdown-item {
&.active, &.selected {
&:not(.dropdown-item_active_noarrow)::before {
display: unset;
top: 50%;
transform: translate(-1.5em, -50%);
}
}
}
.dropdown-toggle .o_select_item_only {
display: none;
}
.o-hb-select-wrapper {
flex: 1 0 0;
min-width: 7ch; // Approx 4 characters + caret
max-width: 100%;
display: flex;
.o-hb-select-toggle.o-hb-btn.btn.o-dropdown.dropdown-toggle {
display: block;
flex: 1;
min-width: 0;
padding-right: 0.75rem;
transition: none;
&.show {
color: var(--btn-hover-color);
background-color: var(--btn-hover-bg);
border-color: var(--o-hb-btn-active-color, $o-we-color-accent);
}
> img {
max-width: 100%;
}
&:after {
@include o-position-absolute(50%, map-get($spacers, 1));
transform: translate(-50%, -50%);
font-size: .5rem;
}
}
&:first-child:last-child {
.o-hb-select-toggle {
--btn-padding-x: #{map-get($spacers , 2)};
}
}
}
// These styles are common between BuilderSelect and Many2X builder components.
// TODO: review the SelectMenu template to allow both light/dark backgrounds and
// remove most '!important' needed here to override Bootstrap classes. See
// implementations of Many2X builder components for examples.
.o-hb-select-dropdown {
--o-hb-select-bg: #{$o-we-item-clickable-bg};
--o-hb-select-color: #{$o-we-fg-light};
--o-hb-select-bg-hover: #{$o-we-bg-lighter};
--o-hb-select-color-hover: #{$o-we-fg-lighter};
--o-hb-border-color: #{$o-we-bg-darker};
--border-color: var(--o-hb-border-color);
font-family: $o-we-font-family;
font-size: $o-we-font-size;
border-color: var(--border-color);
background-color: var(--o-hb-select-bg) !important;
padding: 0;
.dropdown-item {
--dropdown-item-padding-y: 0;
color: var(--o-hb-select-color, $white);
line-height: 2rem;
border-bottom: $o-we-border-width solid var(--border-color);
&.focus {
background-color: var(--o-hb-select-bg-hover) !important;
color: var(--o-hb-select-color-hover);
}
&::before {
color: var(--o-hb-select-item-active-color, $o-we-sidebar-content-field-toggle-active-bg) !important;
}
}
.text-muted {
color: var(--o-hb-select-color, $white) !important;
}
.o-hb-select-dropdown-category {
background-color: $o-we-bg-darker;
color: var(--o-hb-select-color);
}
}
.o_builder_open {
&:has(.o-snippets-tabs [data-name='theme'].active) {
.o-hb-select-dropdown {
--o-hb-select-item-active-color: #{$o-we-color-global};
}
}
}

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderSelect">
<BuilderComponent>
<!-- Render the SelectItem(s) into an invisible node to ensure the label of the
button is being set. -->
<div t-ref="root" class="o-hb-select-wrapper">
<div inert="" class="h-0 w-0 overflow-hidden" t-att-class="props.className" t-ref="content"><WithIgnoreItem><t t-slot="default" /></WithIgnoreItem></div>
<Dropdown state="this.dropdown" menuClass="'o-hb-select-dropdown'">
<button class="o-hb-select-toggle o-hb-btn btn btn-secondary text-start o-dropdown-caret" t-ref="button" t-att-id="props.id">
<t t-slot="fixedButton"/>
</button>
<t t-set-slot="content">
<div t-att-class="props.dropdownContainerClass" data-prevent-closing-overlay="true">
<t t-slot="default" />
</div>
</t>
</Dropdown>
</div>
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,74 @@
import { Component, markup, onMounted, useRef } from "@odoo/owl";
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
import {
clickableBuilderComponentProps,
useActionInfo,
useSelectableItemComponent,
} from "../utils";
import { BuilderComponent } from "./builder_component";
export class BuilderSelectItem extends Component {
static template = "html_builder.BuilderSelectItem";
static props = {
...clickableBuilderComponentProps,
title: { type: String, optional: true },
label: { type: String, optional: true },
className: { type: String, optional: true },
slots: { type: Object, optional: true },
};
static defaultProps = {
className: "",
};
static components = { BuilderComponent };
setup() {
if (!this.env.selectableContext) {
throw new Error("BuilderSelectItem must be used inside a BuilderSelect component.");
}
this.info = useActionInfo();
const item = useRef("item");
let label = "";
const getLabel = () => {
// todo: it's not clear why the item.el?.innerHTML is not set at in
// some cases. We fallback on a previously set value to circumvent
// the problem, but it should be investigated.
label = this.props.label || (item.el ? markup(item.el.innerHTML) : "") || label || "";
return label;
};
onMounted(getLabel);
const { state, operation } = useSelectableItemComponent(this.props.id, {
getLabel,
});
this.state = state;
this.operation = operation;
this.onFocusin = this.operation.preview;
this.onFocusout = this.operation.revert;
}
onClick() {
this.env.onSelectItem();
this.operation.commit();
this.removeKeydown?.();
}
onKeydown(ev) {
const hotkey = getActiveHotkey(ev);
if (hotkey === "escape") {
this.operation.revert();
this.removeKeydown?.();
}
}
onPointerEnter() {
this.operation.preview();
const _onKeydown = this.onKeydown.bind(this);
document.addEventListener("keydown", _onKeydown);
this.removeKeydown = () => document.removeEventListener("keydown", _onKeydown);
}
onPointerLeave() {
this.operation.revert();
this.removeKeydown();
}
}

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderSelectItem">
<BuilderComponent>
<div
t-attf-class="o-hb-select-dropdown-item d-flex flex-column cursor-pointer o-dropdown-item dropdown-item o-navigable #{ props.className }"
t-att-class="{'active': this.state.isActive}"
t-att-data-action-id="info.actionId"
t-att-data-action-param="info.actionParam"
t-att-data-action-value="info.actionValue"
t-att-data-class-action="info.classAction"
t-att-data-style-action="info.styleAction"
t-att-data-style-action-value="info.styleActionValue"
t-att-data-attribute-action="info.attributeAction"
t-att-data-attribute-action-value="info.attributeActionValue"
t-att-title="props.title"
t-on-click="this.onClick"
t-on-pointerenter="this.onPointerEnter"
t-on-pointerleave="this.onPointerLeave"
t-on-focusin="() => this.onFocusin()"
t-on-focusout="() => this.onFocusout()"
t-ref="item"
role="menuitem"
tabindex="0">
<t t-slot="default" />
</div>
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,38 @@
import { Component } from "@odoo/owl";
import { pick } from "@web/core/utils/objects";
import { BuilderTextInputBase, textInputBasePassthroughProps } from "./builder_text_input_base";
import {
basicContainerBuilderComponentProps,
useInputBuilderComponent,
useBuilderComponent,
} from "../utils";
import { BuilderComponent } from "./builder_component";
export class BuilderTextInput extends Component {
static template = "html_builder.BuilderTextInput";
static props = {
...basicContainerBuilderComponentProps,
...textInputBasePassthroughProps,
prefix: { type: String, optional: true },
default: { type: String, optional: true },
};
static components = {
BuilderComponent,
BuilderTextInputBase,
};
setup() {
useBuilderComponent();
const { state, commit, preview } = useInputBuilderComponent({
id: this.props.id,
defaultValue: this.props.default,
});
this.commit = commit;
this.preview = preview;
this.state = state;
}
get textInputBaseProps() {
return pick(this.props, ...Object.keys(textInputBasePassthroughProps));
}
}

View file

@ -0,0 +1,12 @@
.o-hb-input-field-text {
@extend %o-hb-input-base;
.o-hb-input-text {
appearance: none;
border: none;
background: none;
color: inherit;
padding: 0;
margin-right: $o-we-sidebar-content-field-clickable-spacing * 0.5;
}
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderTextInput">
<BuilderComponent>
<BuilderTextInputBase
t-props="textInputBaseProps"
commit="commit"
preview="preview"
value="state.value"
classes="'o-hb-input-field-text'"
inputClasses="'o-hb-input-text'"
/>
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,5 @@
.o-hb-input-base {
.popover & {
--o-hb-field-input-bg-popover: #{$o-we-sidebar-content-field-input-bg};
}
}

View file

@ -0,0 +1,61 @@
import { Component, onWillUpdateProps, useState } from "@odoo/owl";
import { useForwardRefToParent } from "@web/core/utils/hooks";
import { useActionInfo } from "../utils";
// Props given to the builder input components that are then passed to the
// BuilderTextInputBase.
export const textInputBasePassthroughProps = {
action: { type: String, optional: true },
placeholder: { type: String, optional: true },
title: { type: String, optional: true },
style: { type: String, optional: true },
tooltip: { type: String, optional: true },
classes: { type: String, optional: true },
inputClasses: { type: String, optional: true },
prefix: { type: String, optional: true },
};
export class BuilderTextInputBase extends Component {
static template = "html_builder.BuilderTextInputBase";
static props = {
slots: { type: Object, optional: true },
inputRef: { type: Function, optional: true },
...textInputBasePassthroughProps,
commit: { type: Function },
preview: { type: Function },
onFocus: { type: Function, optional: true },
onKeydown: { type: Function, optional: true },
value: { type: [String, { value: null }], optional: true },
};
setup() {
this.isEditing = false;
this.info = useActionInfo();
this.inputRef = useForwardRefToParent("inputRef");
this.state = useState({ value: this.props.value });
onWillUpdateProps((nextProps) => {
if ("value" in nextProps) {
this.state.value = this.isEditing ? this.inputRef.el.value : nextProps.value;
}
});
}
onChange(ev) {
this.isEditing = false;
const normalizedDisplayValue = this.props.commit(ev.target.value);
ev.target.value = normalizedDisplayValue;
}
onInput(ev) {
this.isEditing = true;
this.props.preview(ev.target.value);
}
onFocus(ev) {
this.props.onFocus?.(ev);
}
onKeydown(ev) {
this.props.onKeydown?.(ev);
}
}

View file

@ -0,0 +1,24 @@
.o-hb-input-base {
.popover & {
--o-hb-field-input-bg: var(--o-hb-field-input-bg-popover, #{$o-we-fg-lighter});
}
@extend %o-hb-input-base;
}
.o-hb-input-prefix {
margin-left: -3px;
margin-right: 3px;
font-size: $o-we-font-size-xs;
color: var(--o-hb-field-color, #{$o-we-sidebar-content-field-color});
}
.o-hb-input-transparent {
appearance: none;
margin-right: $o-we-sidebar-content-field-clickable-spacing * 0.5;
padding: 0;
border: none;
background: none;
color: inherit;
}

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderTextInputBase">
<div class="d-flex flex-row flex-nowrap w-100 align-items-center"
t-attf-class="{{ props.classes + ( props.prefix ? ' o-hb-input-base' : '' ) }}"
t-att-data-action-id="info.actionId"
t-att-data-action-param="info.actionParam"
t-att-data-action-value="info.actionValue"
t-att-data-class-action="info.classAction"
t-att-data-style-action="info.styleAction"
t-att-data-style-action-value="info.styleActionValue"
t-att-data-attribute-action="info.attributeAction"
t-att-data-attribute-action-value="info.attributeActionValue">
<span t-if="props.prefix" class="o-hb-input-prefix" t-out="props.prefix"/>
<input
t-ref="inputRef"
type="text"
autocomplete="chrome-off"
t-attf-class="{{ props.inputClasses + ( props.prefix ? ' o-hb-input-transparent ' : ' o-hb-input-base ' ) }}"
t-att-placeholder="props.placeholder"
t-att-data-tooltip="props.tooltip"
t-att-aria-label="props.tooltip"
t-att-title="props.title"
t-on-change="onChange"
t-on-input="onInput"
t-on-focus="onFocus"
t-on-keydown="onKeydown"
t-att-value="state.value"
t-att-style="props.style"
/>
<t t-slot="default"/>
</div>
</t>
</templates>

View file

@ -0,0 +1,48 @@
import { BuilderComponent } from "@html_builder/core/building_blocks/builder_component";
import {
BuilderTextInputBase,
textInputBasePassthroughProps,
} from "@html_builder/core/building_blocks/builder_text_input_base";
import {
basicContainerBuilderComponentProps,
useBuilderComponent,
useInputBuilderComponent,
} from "@html_builder/core/utils";
import { Component } from "@odoo/owl";
import { useChildRef } from "@web/core/utils/hooks";
import { pick } from "@web/core/utils/objects";
export class BuilderUrlPicker extends Component {
static template = "html_builder.BuilderUrlPicker";
static props = {
...basicContainerBuilderComponentProps,
...textInputBasePassthroughProps,
default: { type: String, optional: true },
};
static components = {
BuilderComponent,
BuilderTextInputBase,
};
setup() {
this.inputRef = useChildRef();
useBuilderComponent();
const { state, commit, preview } = useInputBuilderComponent({
id: this.props.id,
defaultValue: this.props.default,
});
this.commit = commit;
this.preview = preview;
this.state = state;
}
get textInputBaseProps() {
return pick(this.props, ...Object.keys(textInputBasePassthroughProps));
}
openPreviewUrl() {
if (this.inputRef.el.value) {
window.open(this.inputRef.el.value, "_blank");
}
}
}

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.BuilderUrlPicker">
<BuilderComponent>
<BuilderTextInputBase
t-props="textInputBaseProps"
inputRef="inputRef"
commit="commit"
preview="preview"
value="state.value"
>
<button class="btn" title="Preview this URL in a new tab" t-on-click="openPreviewUrl">
<i class="fa fa-fw fa-external-link"/>
</button>
</BuilderTextInputBase>
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,27 @@
import { Component } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
class ColorPickerThemeTab extends Component {
static template = "html_builder.ColorPickerThemeTab";
static props = {
onColorClick: Function,
onColorPointerOver: Function,
onColorPointerOut: Function,
onColorPointerLeave: Function,
onFocusin: Function,
onFocusout: Function,
selectedColorCombination: { type: String, optional: true },
"*": { optional: true },
};
}
registry.category("color_picker_tabs").add(
"html_builder.theme",
{
id: "theme",
name: _t("Theme"),
component: ColorPickerThemeTab,
},
{ sequence: 10 }
);

View file

@ -0,0 +1,52 @@
@mixin preview-outline-button($type, $ccIndex) {
.btn-#{$type} {
background-color: transparent;
color: var(--hb-cp-o-cc#{$ccIndex}-btn-#{$type});
border-color: var(--hb-cp-o-cc#{$ccIndex}-btn-#{$type});
}
.btn-#{$type}:hover {
background-color: var(--hb-cp-o-cc#{$ccIndex}-btn-#{$type});
color: var(--hb-cp-o-cc#{$ccIndex}-btn-#{$type}-text);
}
}
.o_cc_preview_wrapper {
@for $index from 1 through 5 {
.o_cc#{$index} {
background-color: var(--hb-cp-o-cc#{$index}-bg);
background-image: var(--hb-cp-o-cc#{$index}-bg-gradient), url('/web/static/img/transparent.png');
color: var(--hb-cp-o-cc#{$index}-text);
h1 {
color: var(--hb-cp-o-cc#{$index}-headings);
}
.btn-primary {
background-color: var(--hb-cp-o-cc#{$index}-btn-primary);
color: var(--hb-cp-o-cc#{$index}-btn-primary-text);
border-color: var(--hb-cp-o-cc#{$index}-btn-primary-border);
}
.btn-secondary {
background-color: var(--hb-cp-o-cc#{$index}-btn-secondary);
color: var(--hb-cp-o-cc#{$index}-btn-secondary-text);
border-color: var(--hb-cp-o-cc#{$index}-btn-secondary-border);
}
}
}
&.o_we_has_btn_outline_primary {
.o_cc_preview_wrapper {
@for $index from 1 through 5 {
&.o_cc#{$index} {
@include preview-outline-button('primary', $index)
}
}
}
}
&.o_we_has_btn_outline_secondary {
.o_cc_preview_wrapper {
@for $index from 1 through 5 {
&.o_cc#{$index} {
@include preview-outline-button('secondary', $index)
}
}
}
}
}

View file

@ -0,0 +1,35 @@
<templates xml:space="preserve">
<t t-name="html_builder.ColorPickerThemeTab">
<div class="p-2 d-flex flex-column gap-1 o_cc_preview_wrapper"
t-on-click="props.onColorClick"
t-on-mouseover="props.onColorPointerOver"
t-on-mouseout="props.onColorPointerOut"
t-on-mouseleave="props.onColorPointerLeave"
t-on-focusin="props.onFocusin" t-on-focusout="props.onFocusout">
<!-- List all Presets -->
<t t-foreach="[1, 2, 3, 4, 5]" t-as="number" t-key="number">
<t t-set="className" t-value="'o_cc' + number"/>
<t t-set="activeClass" t-value="this.props.selectedColorCombination === className ? 'selected' : ''"/>
<div class="d-flex align-items-center">
<button type="button" class="w-100 p-0 border-0 color-combination-button"
t-att-class="[className, activeClass].join(' ')" t-att-data-color="className"
t-attf-title="Preset {{number}}">
<div inert="" class="p-2 d-flex justify-content-between align-items-center">
<h1 class="m-0 fs-4">Title</h1>
<p class="m-0 flex-grow-1">Text</p>
<span class="py-1 px-1 rounded-1 fs-6 btn btn-sm lh-1 btn btn-sm me-1 d-flex flex-column justify-content-center btn-primary">
<small>Button</small>
</span>
<span class="py-1 px-1 rounded-1 fs-6 btn btn-sm lh-1 btn btn-sm d-flex flex-column justify-content-center btn-secondary">
<small>Button</small>
</span>
</div>
</button>
<button t-on-click="() => this.props.editColorCombination(number)"
class="o-hb-edit-color-combination o_colorpicker_ignore fa fa-pencil btn ms-1 px-1"
title="Edit"/>
</div>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,100 @@
import { Component, useState, onWillStart, onWillUpdateProps } from "@odoo/owl";
import { uniqueId } from "@web/core/utils/functions";
import { useService } from "@web/core/utils/hooks";
import { useDomState } from "@html_builder/core/utils";
import { useCachedModel } from "@html_builder/core/cached_model_utils";
import { BuilderComponent } from "./builder_component";
import { BasicMany2Many } from "./basic_many2many";
export class ModelMany2Many extends Component {
static template = "html_builder.ModelMany2Many";
static props = {
//...basicContainerBuilderComponentProps,
baseModel: String,
recordId: Number,
m2oField: String,
fields: { type: Array, element: String, optional: true },
domain: { type: Array, optional: true },
limit: { type: Number, optional: true },
createAction: { type: String, optional: true },
id: { type: String, optional: true },
// currently always allowDelete
applyTo: { type: String, optional: true },
};
static defaultProps = {
fields: [],
domain: [],
limit: 10,
};
static components = { BuilderComponent, BasicMany2Many };
setup() {
this.fields = useService("field");
this.cachedModel = useCachedModel();
this.state = useState({
searchModel: undefined,
});
this.modelEdit = undefined;
// This `useDomState` is here to get update from history when undo/redo
this.domState = useDomState((el) => {
if (!this.modelEdit) {
return { selection: [] };
}
return {
selection: this.modelEdit.get(this.props.m2oField),
};
});
onWillStart(async () => {
await this.handleProps(this.props);
});
onWillUpdateProps(async (newProps) => {
await this.handleProps(newProps);
});
}
async handleProps(props) {
const [record] = await this.cachedModel.ormRead(
props.baseModel,
[props.recordId],
[props.m2oField]
);
const selectedRecordIds = record[props.m2oField];
// TODO: handle no record
const modelData = await this.fields.loadFields(props.baseModel, {
fieldNames: [props.m2oField],
});
// TODO: simultaneously fly both RPCs
this.state.searchModel = modelData[props.m2oField].relation;
this.modelEdit = this.cachedModel.useModelEdit({
model: this.props.baseModel,
recordId: props.recordId,
});
if (!this.modelEdit.has(props.m2oField)) {
const storedSelection = await this.cachedModel.ormRead(
this.state.searchModel,
selectedRecordIds,
["display_name"]
);
for (const item of storedSelection) {
item.name = item.display_name;
}
this.modelEdit.init(props.m2oField, [...storedSelection]);
}
this.domState.selection = this.modelEdit.get(props.m2oField);
}
setSelection(newSelection) {
this.modelEdit.set(this.props.m2oField, newSelection);
this.env.editor.shared.history.addStep();
}
create(name) {
// TODO maybe this can be in base layer
this.setSelection([
...this.domState.selection,
{
id: `new-${uniqueId()}`,
name: name,
display_name: name,
model: this.state.searchModel,
},
]);
}
}

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="html_builder.ModelMany2Many">
<BuilderComponent>
<BasicMany2Many
model="state.searchModel"
limit="props.limit"
domain="props.domain"
fields="props.fields"
selection="domState.selection"
setSelection="setSelection.bind(this)"
create="create.bind(this)"
/>
</BuilderComponent>
</t>
</templates>

View file

@ -0,0 +1,160 @@
import { Component, useState, onWillUpdateProps, onWillDestroy } from "@odoo/owl";
import { useChildRef, useService } from "@web/core/utils/hooks";
import { useCachedModel } from "@html_builder/core/cached_model_utils";
import { _t } from "@web/core/l10n/translation";
import { SelectMenu } from "@web/core/select_menu/select_menu";
import { useDropdownCloser } from "@web/core/dropdown/dropdown_hooks";
class SelectMany2XCreate extends Component {
static template = "html_builder.SelectMany2XCreate";
static props = {
name: String,
create: Function,
};
setup() {
this.dropdown = useDropdownCloser();
this.create = this.create.bind(this);
}
create() {
this.dropdown.close();
this.props.create(this.props.name);
}
}
export class SelectMany2X extends Component {
static template = "html_builder.SelectMany2X";
static props = {
model: String,
fields: { type: Array, element: String, optional: true },
domain: { type: Array, optional: true },
limit: { type: Number, optional: true },
selected: {
type: Array,
element: { type: Object, shape: { id: [Number, String], "*": true } },
},
select: Function,
preview: { type: Function, optional: true },
revert: { type: Function, optional: true },
closeOnEnterKey: { type: Boolean, optional: true },
message: { type: String, optional: true },
create: { type: Function, optional: true },
};
static defaultProps = {
fields: [],
domain: [],
limit: 5,
closeOnEnterKey: true,
message: _t("Choose a record..."),
};
static components = { SelectMenu, SelectMany2XCreate };
setup() {
this.orm = useService("orm");
this.cachedModel = useCachedModel();
this.state = useState({
nameToCreate: "",
searchResults: [],
limit: this.props.limit,
});
onWillUpdateProps(async (newProps) => {
if (this.searchInvalidationKey(this.props) !== this.searchInvalidationKey(newProps)) {
this.state.searchResults = [];
}
});
this.menuRef = useChildRef();
onWillDestroy(() => this.removeListeners?.());
}
onOpened() {
const menuEl = this.menuRef.el;
if (menuEl) {
this.removeListeners?.();
const onNavigatedAway = this.onNavigatedAway.bind(this);
const onNavigatedBack = this.onNavigatedBack.bind(this);
menuEl.addEventListener("pointerleave", onNavigatedAway);
menuEl.addEventListener("pointerenter", onNavigatedBack);
this.removeListeners = () => {
delete this.removeListeners;
menuEl.removeEventListener("pointerleave", onNavigatedAway);
menuEl.removeEventListener("pointerenter", onNavigatedBack);
};
}
}
onClosed() {
this.removeListeners?.();
this.onNavigatedAway();
}
searchInvalidationKey(props) {
return JSON.stringify([props.model, props.fields, props.domain]);
}
searchMore(searchValue) {
this.state.limit += this.props.limit;
this.search(searchValue);
}
async search(searchValue) {
const tuples = await this.orm.call(this.props.model, "name_search", [], {
name: searchValue,
domain: Object.values(this.props.domain).filter((item) => item !== null),
operator: "ilike",
limit: this.state.limit + 1,
});
this.state.hasMore = tuples.length > this.state.limit;
this.state.searchResults = await this.cachedModel.ormRead(
this.props.model,
tuples.slice(0, this.state.limit).map(([id, _name]) => id),
[...new Set(this.props.fields).add("display_name").add("name")]
);
}
filteredSearchResult() {
const selectedIds = new Set(this.props.selected.map((e) => e.id));
return this.state.searchResults.filter((entry) => !selectedIds.has(entry.id));
}
async canCreate(name) {
if (!this.props.create || !name.length) {
return false;
}
const allRecords = await this.cachedModel.ormSearchRead(
this.props.model,
[],
["id", "name"]
);
const usedNames = [
// Exclude existing names
...allRecords.map((item) => item.name),
// Exclude new names
...this.props.selected.map((item) => item.name),
];
return !usedNames.includes(name);
}
async onInput(searchValue) {
this.search(searchValue);
this.state.nameToCreate = (await this.canCreate(searchValue)) ? searchValue : "";
}
preview(value) {
if (this.previewed !== value) {
this.previewed = value;
this.props.preview?.(value);
}
}
revert() {
delete this.previewed;
this.props.revert?.();
}
onNavigated(choice) {
choice ? this.preview(choice.value) : this.revert();
delete this.lastPreviewed;
}
onNavigatedAway() {
if ("previewed" in this) {
this.lastPreviewed = this.previewed;
this.revert();
}
}
onNavigatedBack() {
if ("lastPreviewed" in this) {
this.preview(this.lastPreviewed);
}
}
}

View file

@ -0,0 +1,105 @@
.o-hb-selectMany2X-wrapper {
--border-color: var(--o-hb-border-color);
// TODO: the following rules essentially overrides utility classes
// "inherited" from the backend and apply BuilderSelect rules on top.
// To be improved by removing the unwanted classes and share relevant
// rules across the BuilderSelect AND SelectMany2X components.
border: 0 !important; // Override backend's `border`class
width: 100% !important; // Override backend's `w-auto`class
div:has(> &) {
// TODO: Add a class to this unnamed parent div
flex: 1 0 auto;
min-width: 7ch; // Approx 4 characters + caret
max-width: 100%;
display: flex;
}
.o_select_menu_toggler {
&, &.btn-light.bg-light {
--border-color: #{$o-we-bg-light};
--btn-padding-x: #{map-get($spacers , 2)};
@include button-variant(
$background: $o-we-item-clickable-bg,
$border: $o-we-bg-light,
$color: $o-we-item-clickable-color,
$hover-background: $o-we-item-clickable-hover-bg,
$hover-border: $o-we-bg-light,
$hover-color: $o-we-item-clickable-color,
$active-background: var(--o-hb-btn-secondary-active-bg, RGBA(#{to-rgb($o-we-color-accent)}, 0.4)),
$active-border: $o-we-bg-light,
$active-color: $o-we-fg-lighter,
$disabled-background: transparent,
$disabled-border: transparent,
$disabled-color: $o-we-fg-darker,
);
// Handle native o_select_menu_toggler_caret icon inconsistencies
> span.o_select_menu_toggler_caret {
display: none;
}
&:after {
@include o-position-absolute(50%, map-get($spacers, 1));
transform: translate(-50%, -50%);
font-size: .5rem;
content: "\f0d7";
font-family: FontAwesome;
}
// Tweak `bg-light` to inherit btn values
--background-color: var(--btn-bg);
&:hover, &:focus-visible {
--background-color: var(--btn-hover-bg);
}
&:active, &.active {
--background-color: var(--btn-active-bg);
}
&[disabled], &.disabled {
--background-color: var(--btn-disabled-bg);
}
// Override backend's `.show` to inherit btn values
&.show {
--background-color: var(--btn-hover-bg);
color: var(--btn-active-color);
border-color: var(--o-hb-btn-active-color, $o-we-color-accent) !important;
}
}
.o_select_menu_caret {
visibility: hidden !important;
}
}
}
.o-hb-selectMany2X-dropdown {
.o_select_menu_searchbox {
background-color: $o-we-sidebar-content-field-input-bg;
&:hover, &:focus-within {
--o-hb-select-bg-hover: #{$o-we-sidebar-content-field-input-bg};
}
&:focus-within {
box-shadow: 0 0 $o-we-border-width $o-we-border-width inset var(--o-hb-input-active-border, $o-we-sidebar-content-field-input-border-color);
}
&::before {
color: var(--o-hb-select-color);
}
input {
border: 0;
}
}
.o_select_menu_item {
--dropdown-item-padding-y: 6px;
line-height: 1.7;
}
}

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- TODO: support more features from the previous implementation: "callWith", "filterInModel", "filterInField", "nullText" -->
<t t-name="html_builder.SelectMany2X">
<SelectMenu
choices="this.filteredSearchResult().map(e => ({ value: e, label: e.display_name }))"
onSelect="props.select"
onNavigated.bind="onNavigated"
menuRef="menuRef"
onOpened.bind="onOpened"
onClosed.bind="onClosed"
searchPlaceholder.translate="Search for records..."
onInput.bind="onInput"
class="'o-hb-selectMany2X-wrapper min-w-0'"
menuClass="'o-hb-select-dropdown o-hb-selectMany2X-dropdown'"
togglerClass="'o-hb-selectMany2X-toggle btn-secondary'"
>
<t t-out="props.message"/>
<t t-set-slot="bottomArea" t-slot-scope="select">
<a
t-if="state.hasMore"
t-on-click="() => this.searchMore(select.data.searchValue)"
class="'o-dropdown-item dropdown-item o-navigable o_we_m2o_search_more'"
title="Search to show more records"
>
Search more...
</a>
<SelectMany2XCreate t-if="!!state.nameToCreate" name="state.nameToCreate" create="this.props.create"/>
</t>
</SelectMenu>
</t>
<t t-name="html_builder.SelectMany2XCreate">
<a t-on-click="create" class="o-dropdown-item dropdown-item o-navigable o_we_m2o_create">
Create "<t t-out="props.name"/>"
</a>
</t>
</templates>

View file

@ -0,0 +1,72 @@
import { Plugin } from "@html_editor/plugin";
import { Cache } from "@web/core/utils/cache";
import { ModelEdit } from "./cached_model_utils";
export class CachedModelPlugin extends Plugin {
static id = "cachedModel";
static shared = ["ormRead", "ormSearchRead", "useModelEdit"];
static dependencies = ["history"];
resources = {
save_handlers: this.savePendingRecords.bind(this),
};
setup() {
this.ormReadCache = new Cache(
({ model, ids, fields }) => this.services.orm.read(model, ids, fields),
JSON.stringify
);
this.ormSearchReadCache = new Cache(
({ model, domain, fields }) => this.services.orm.searchRead(model, domain, fields),
JSON.stringify
);
this.modelEditCache = new Cache(
({ model, recordId }) => new ModelEdit(this.dependencies.history, model, recordId),
JSON.stringify
);
}
destroy() {
this.ormReadCache.invalidate();
this.ormSearchReadCache.invalidate();
this.modelEditCache.invalidate();
}
ormRead(model, ids, fields) {
const SAFE_NULL = -1;
const newIds = ids.map((id) => (id === null ? SAFE_NULL : id));
return this.ormReadCache.read({ model, ids: newIds, fields });
}
ormSearchRead(model, domain, fields) {
return this.ormSearchReadCache.read({ model, domain, fields });
}
useModelEdit({ model, recordId, field }) {
const modelEdit = this.modelEditCache.read({ model, recordId, field });
// track el ?
return modelEdit;
}
async savePendingRecords() {
const inventory = {}; // model => { recordId => { field => value } }
for (const modelEdit of Object.values(this.modelEditCache.cache)) {
modelEdit.collect(inventory);
}
// Save inventoried changes.
for (const [model, records] of Object.entries(inventory)) {
for (const [recordId, record] of Object.entries(records)) {
for (const [field, value] of Object.entries(record)) {
// Currently only ids selection values are supported.
const proms = value
.filter((value) => typeof value.id === "string")
.map((value) =>
this.services.orm.create(value.model, [{ name: value.name }])
);
const createdIDs = (await Promise.all(proms)).flat();
const ids = value
.filter((value) => typeof value.id === "number")
.map((value) => value.id)
.concat(createdIDs);
await this.services.orm.write(model, [parseInt(recordId)], {
[field]: [[6, 0, ids]],
});
}
}
}
return !!inventory.length;
}
}

View file

@ -0,0 +1,47 @@
import { useEnv } from "@odoo/owl";
export function useCachedModel() {
return useEnv().editor.shared.cachedModel;
}
export class ModelEdit {
constructor(history, model, recordId) {
this.values = {};
this.history = history;
this.model = model;
this.recordId = recordId;
}
has(field) {
return field in this.values;
}
get(field) {
return JSON.parse(this.values[field].current);
}
init(field, value) {
value = JSON.stringify(value);
this.values[field] = { initial: value, current: value };
}
set(field, value) {
const previous = this.values[field].current;
value = JSON.stringify(value);
this.history.applyCustomMutation({
apply: () => {
this.values[field].current = value;
},
revert: () => {
this.values[field].current = previous;
},
});
}
collect(inventory) {
const records = inventory[this.model] || {};
const record = records[this.recordId] || {};
for (const field of Object.keys(this.values)) {
if (this.values[field].initial !== this.values[field].current) {
inventory[this.model] = records;
records[this.recordId] = record;
record[field] = JSON.parse(this.values[field].current);
}
}
}
}

View file

@ -0,0 +1,113 @@
import { Plugin } from "@html_editor/plugin";
import { withSequence } from "@html_editor/utils/resource";
import { _t } from "@web/core/l10n/translation";
import { isElementInViewport } from "@html_builder/utils/utils";
import { isRemovable } from "./remove_plugin";
import { BuilderAction } from "@html_builder/core/builder_action";
const clonableSelector = "a.btn:not(.oe_unremovable)";
export function isClonable(el) {
// TODO and isDraggable
return el.matches(clonableSelector) || isRemovable(el);
}
export class ClonePlugin extends Plugin {
static id = "clone";
static dependencies = ["history", "builderOptions", "dom"];
static shared = ["cloneElement"];
resources = {
builder_actions: {
// Maybe rename cloneItem ?
CloneItemAction,
},
get_overlay_buttons: withSequence(2, {
getButtons: this.getActiveOverlayButtons.bind(this),
}),
// Resource definitions:
on_will_clone_handlers: [
// ({ originalEl: el }) => {
// called on the original element before clone
// }
],
on_cloned_handlers: [
// async ({ cloneEl: cloneEl, originalEl: el }) => {
// called after an element was cloned and inserted in the DOM
// }
],
};
setup() {
this.overlayTarget = null;
}
getActiveOverlayButtons(target) {
if (!isClonable(target)) {
this.overlayTarget = null;
return [];
}
const buttons = [];
this.overlayTarget = target;
const disabledReason = this.dependencies["builderOptions"].getCloneDisabledReason(target);
buttons.push({
class: "o_snippet_clone fa fa-clone",
title: _t("Duplicate"),
disabledReason,
handler: async () => {
await this.cloneElement(this.overlayTarget, { activateClone: false });
this.dependencies.history.addStep();
},
});
return buttons;
}
/**
* Duplicates the given element and returns the created clone.
*
* @param {HTMLElement} el the element to clone
* @param {Object}
* - `position`: specifies where to position the clone (first parameter of
* the `insertAdjacentElement` function)
* - `scrollToClone`: true if the we should scroll to the clone (if not in
* the viewport), false otherwise
* - `activateClone`: true if the option containers of the clone should be
* the active ones, false otherwise
* @returns {HTMLElement}
*/
async cloneElement(
el,
{ position = "afterend", scrollToClone = false, activateClone = true } = {}
) {
this.dispatchTo("on_will_clone_handlers", { originalEl: el });
const cloneEl = el.cloneNode(true);
this.dependencies.dom.removeSystemProperties(cloneEl); // TODO check that
el.insertAdjacentElement(position, cloneEl);
// Update the containers if required.
if (activateClone) {
this.dependencies.builderOptions.setNextTarget(cloneEl);
}
// Scroll to the clone if required and if it is not visible.
if (scrollToClone && !isElementInViewport(cloneEl)) {
cloneEl.scrollIntoView({ behavior: "smooth", block: "center" });
}
for (const onCloned of this.getResource("on_cloned_handlers")) {
await onCloned({ cloneEl, originalEl: el });
}
return cloneEl;
}
}
export class CloneItemAction extends BuilderAction {
static id = "addItem";
static dependencies = ["clone", "history"];
async apply({ editingElement, params: { mainParam: itemSelector }, value: position }) {
const itemEl = editingElement.querySelector(itemSelector);
await this.dependencies.clone.cloneElement(itemEl, { position, scrollToClone: true });
this.dependencies.history.addStep();
}
}

View file

@ -0,0 +1,36 @@
import { Plugin } from "@html_editor/plugin";
import { registry } from "@web/core/registry";
import { applyNeededCss } from "@html_builder/utils/utils_css";
import { withSequence } from "@html_editor/utils/resource";
class ColorStylePlugin extends Plugin {
static id = "colorStyle";
static dependencies = ["color"];
resources = {
apply_color_style_overrides: withSequence(5, (element, cssProp, color) => {
applyNeededCss(element, cssProp, color);
return true;
}),
apply_custom_css_style: withSequence(20, this.applyColorStyle.bind(this)),
};
applyColorStyle({ editingElement, params: { mainParam: styleName = "" }, value }) {
if (styleName === "background-color") {
const match = value.match(/var\(--([a-zA-Z0-9-_]+)\)/);
if (match) {
value = `bg-${match[1]}`;
}
this.dependencies.color.colorElement(editingElement, value, "backgroundColor");
return true;
} else if (styleName === "color") {
const match = value.match(/var\(--([a-zA-Z0-9-_]+)\)/);
if (match) {
value = `text-${match[1]}`;
}
this.dependencies.color.colorElement(editingElement, value, "color");
return true;
}
return false;
}
}
registry.category("builder-plugins").add(ColorStylePlugin.id, ColorStylePlugin);

View file

@ -0,0 +1,14 @@
import { ColorUIPlugin as EditorColorUIPlugin } from "@html_editor/main/font/color_ui_plugin";
import { getAllUsedColors } from "@html_builder/utils/utils_css";
export class ColorUIPlugin extends EditorColorUIPlugin {
getUsedCustomColors(mode) {
return getAllUsedColors(this.editable);
}
getPropsForColorSelector(type) {
const props = { ...super.getPropsForColorSelector(type) };
props.cssVarColorPrefix = "hb-cp-";
return props;
}
}

View file

@ -0,0 +1,58 @@
import { Plugin } from "@html_editor/plugin";
import { registry } from "@web/core/registry";
import { withSequence } from "@html_editor/utils/resource";
import { CSS_SHORTHANDS } from "@html_builder/utils/utils_css";
/**
* Compatibility plugin to handle border-width and border-radius inline style
* removal (including from inner layers), as the calculation regarding radius of
* inner layers is now automatically done in CSS via the improved bootstrap
* rounded-X classes and CSS variables.
*/
const NEW_STYLES = ["--box-border-width", "--box-border-radius"];
const OLD_STYLES = ["border-width", "border-radius"];
export class CompatibilityInlineBorderRemovalPlugin extends Plugin {
static id = "compatibilityInlineBorderRemoval";
resources = {
apply_custom_css_style: withSequence(20, this.removeInlineBorderIfNecessary.bind(this)),
};
// The border-width/radius calculation using --box-border-width/radius
// variables is done at class level using "border" and "rounded" classes, so
// eventual border-width/radius styles must be removed because they would
// otherwise take precedence.
removeInlineBorderIfNecessary({ editingElement, params }) {
const newStyleBeingEdited = NEW_STYLES.find(style => {
return params.mainParam === style
|| CSS_SHORTHANDS[style].includes(params.mainParam);
});
if (newStyleBeingEdited && OLD_STYLES.some(style => editingElement.style[style])) {
// Remove all old inline styles related to border-width/radius as
// the new CSS rules + variables rely on both being right, i.e. not
// messed up by any inline style...
for (const oldStyle of OLD_STYLES) {
// TODO even though the part about inner layers below is pure
// compatibility, this here should actually be done as the
// main feature: editing a CSS variable which controls an unique
// property should really enforce removing that property if
// already forced as inline style. See CSS_VARIABLE_EDIT_TODO.
editingElement.style.setProperty(oldStyle, "");
}
// ... that is why we just always remove inner radius style from
// children with %o-we-background-layer classes too (note: the code
// that handled adding inline style on child nodes only handled
// those specific ones here after).
const compatLayerSelectors = [".o_we_bg_filter", ".o_bg_video_container", ".s_parallax_bg"];
const selector = `:scope > ${compatLayerSelectors.join(", :scope > ")}`;
for (const childNode of editingElement.querySelectorAll(selector)) {
childNode.style.setProperty("border-radius", "");
}
}
return false;
}
}
registry.category("builder-plugins")
.add(CompatibilityInlineBorderRemovalPlugin.id, CompatibilityInlineBorderRemovalPlugin);

View file

@ -0,0 +1,197 @@
import { convertParamToObject } from "@html_builder/core/utils";
import { Plugin } from "@html_editor/plugin";
import { BuilderAction } from "@html_builder/core/builder_action";
export class CompositeActionPlugin extends Plugin {
static id = "compositeAction";
static dependencies = ["builderActions"];
resources = {
builder_actions: {
CompositeAction,
// Do not use with actions that need a custom reload.
// TODO: a class approach to actions would be able to solve that
// limitation and would also remove the need to split
// `composite` and `reloadComposite`.
ReloadCompositeAction,
},
};
}
export class CompositeAction extends BuilderAction {
static id = "composite";
static dependencies = ["builderActions"];
loadOnClean = true;
async prepare({ actionParam: { mainParam: actions }, actionValue }) {
const proms = [];
for (const actionDef of actions) {
const action = this.dependencies.builderActions.getAction(actionDef.action);
if (action.has("prepare")) {
const actionDescr = { actionId: actionDef.action };
if (actionDef.actionParam) {
actionDescr.actionParam = convertParamToObject(actionDef.actionParam);
}
if (actionDef.actionValue || actionValue) {
actionDescr.actionValue = actionDef.actionValue || actionValue;
}
proms.push(action.prepare(actionDescr));
}
}
await Promise.all(proms);
}
getPriority({ params: { mainParam: actions }, value }) {
const results = [];
for (const actionDef of actions) {
const action = this.dependencies.builderActions.getAction(actionDef.action);
if (action.has("getPriority")) {
const actionDescr = this._getActionDescription({ ...actionDef, value });
results.push(action.getPriority(actionDescr));
}
}
// TODO: should this be the max or a sum?
return Math.max(...results);
}
// We arbitrarily keep the result of the 1st action, as we
// obviously cannot return more than one value.
getValue({ editingElement, params: { mainParam: actions } }) {
let actionGetValue;
const actionDef = actions.find((actionDef) => {
const action = this.dependencies.builderActions.getAction(actionDef.action);
if (action.has("getValue")) {
actionGetValue = action.getValue;
}
return !!action.getValue;
});
if (actionDef) {
const actionDescr = this._getActionDescription({
editingElement,
actionParam: actionDef.actionParam,
});
return actionGetValue(actionDescr);
}
}
isApplied({ editingElement, params: { mainParam: actions }, value }) {
const results = [];
for (const actionDef of actions) {
const action = this.dependencies.builderActions.getAction(actionDef.action);
if (action.has("isApplied")) {
const actionDescr = this._getActionDescription({
editingElement,
...actionDef,
value,
});
results.push(action.isApplied(actionDescr));
}
}
return !!results.length && results.every((result) => result);
}
async load({ editingElement, params: { mainParam: actions }, value }) {
const loadActions = [];
const loadResults = [];
for (const actionDef of actions) {
const action = this.dependencies.builderActions.getAction(actionDef.action);
if (action.has("load")) {
const actionDescr = this._getActionDescription({
editingElement,
...actionDef,
value,
});
loadActions.push(actionDef.action);
// We can't use Promise.all as unrelated loads could have
// overriding impacts (like updating/creating the same file)
// In such cases, this approach allows to define the order
// of actions and ensures predictable load results.
loadResults.push(await action.load(actionDescr));
}
}
return loadActions.reduce((acc, actionId, idx) => {
acc[actionId] = loadResults[idx];
return acc;
}, {});
}
async apply({
editingElement,
params: { mainParam: actions },
value,
loadResult,
dependencyManager,
selectableContext,
}) {
for (const actionDef of actions) {
const action = this.dependencies.builderActions.getAction(actionDef.action);
if (action.has("apply")) {
const actionDescr = this._getActionDescription({
editingElement,
value,
...actionDef,
loadResult,
dependencyManager,
selectableContext,
});
await action.apply(actionDescr);
}
}
}
clean({
editingElement,
params: { mainParam: actions },
value,
loadResult,
dependencyManager,
selectableContext,
nextAction,
}) {
for (const actionDef of actions) {
const action = this.dependencies.builderActions.getAction(actionDef.action);
const actionDescr = this._getActionDescription({
editingElement,
...actionDef,
value,
loadResult,
dependencyManager,
selectableContext,
nextAction,
});
if (action.has("clean")) {
action.clean(actionDescr);
} else if (action.has("apply")) {
if (loadResult && loadResult[actionDef.action]) {
actionDescr.loadResult = loadResult[actionDef.action];
}
action.apply(actionDescr);
}
}
}
_getActionDescription(action) {
const { action: actionId, actionParam, actionValue, value, loadResult } = action;
const actionDescr = {};
const forwardedSpecs = [
"editingElement",
"dependencyManager",
"selectableContext",
"nextAction",
];
for (const spec of forwardedSpecs) {
if (action[spec]) {
actionDescr[spec] = action[spec];
}
}
if (actionParam) {
actionDescr.params = convertParamToObject(actionParam);
}
if (actionValue || value) {
actionDescr.value = actionValue || value;
}
if (loadResult && loadResult[actionId]) {
actionDescr.loadResult = loadResult[actionId];
}
return actionDescr;
}
}
export class ReloadCompositeAction extends CompositeAction {
static id = "reloadComposite";
setup() {
this.reload = {};
}
}

View file

@ -0,0 +1,311 @@
import { Plugin } from "@html_editor/plugin";
import { getHtmlStyle } from "@html_editor/utils/formatting";
import {
CSS_SHORTHANDS,
applyNeededCss,
areCssValuesEqual,
normalizeColor,
} from "@html_builder/utils/utils_css";
import { BuilderAction } from "@html_builder/core/builder_action";
import { getValueFromVar } from "@html_builder/utils/utils";
export function withoutTransition(editingElement, callback) {
if (editingElement.classList.contains("o_we_force_no_transition")) {
return callback();
}
editingElement.classList.add("o_we_force_no_transition");
try {
return callback();
} finally {
editingElement.classList.remove("o_we_force_no_transition");
}
}
export class CoreBuilderActionPlugin extends Plugin {
static id = "coreBuilderAction";
resources = {
builder_actions: {
ClassAction,
AttributeAction,
StyleAction,
DataAttributeAction,
SetClassRangeAction,
},
system_classes: ["o_we_force_no_transition"],
};
}
function getStyleValue(el, styleName) {
const computedStyle = window.getComputedStyle(el);
const cssProps = CSS_SHORTHANDS[styleName] || [styleName];
const cssValues = cssProps.map((cssProp) => computedStyle.getPropertyValue(cssProp).trim());
if (
cssValues.length === 4 &&
areCssValuesEqual(cssValues[3], cssValues[1], styleName, computedStyle)
) {
cssValues.pop();
}
if (
cssValues.length === 3 &&
areCssValuesEqual(cssValues[2], cssValues[0], styleName, computedStyle)
) {
cssValues.pop();
}
if (
cssValues.length === 2 &&
areCssValuesEqual(cssValues[1], cssValues[0], styleName, computedStyle)
) {
cssValues.pop();
}
return cssValues.join(" ");
}
function setStyle(el, styleName, value, { extraClass, force = false, allowImportant = true } = {}) {
const computedStyle = window.getComputedStyle(el);
const cssProps = CSS_SHORTHANDS[styleName] || [styleName];
// Always reset the inline style first to not put inline style on an
// element which already has this style through css stylesheets.
for (const cssProp of cssProps) {
el.style.setProperty(cssProp, "");
}
el.classList.remove(extraClass);
// Replacing ', ' by ',' to prevent attributes with internal space separators from being split:
// eg: "rgba(55, 12, 47, 1.9) 47px" should be split as ["rgba(55,12,47,1.9)", "47px"]
const values = value.replace(/,\s/g, ",").split(/\s+/g);
// Compute missing values:
// "a" => "a a a a"
// "a b" => "a b a b"
// "a b c" => "a b c b"
// "a b c d" => "a b c d d d d"
while (values.length < cssProps.length) {
const len = values.length;
const index = len == 3 ? 1 : len == 1 || len == 2 ? 0 : len - 1;
values.push(values[index]);
}
let hasUserValue = false;
const applyAllCSS = (values) => {
for (let i = cssProps.length - 1; i > 0; i--) {
hasUserValue =
applyNeededCss(el, cssProps[i], values.pop(), computedStyle, {
force,
allowImportant,
}) || hasUserValue;
}
hasUserValue =
applyNeededCss(el, cssProps[0], values.join(" "), computedStyle, {
force,
allowImportant,
}) || hasUserValue;
};
applyAllCSS([...values]);
if (extraClass) {
el.classList.toggle(extraClass, hasUserValue);
if (hasUserValue) {
// Might have changed because of the class.
for (const cssProp of cssProps) {
el.style.removeProperty(cssProp);
}
applyAllCSS(values);
}
}
}
export class ClassAction extends BuilderAction {
static id = "classAction";
getPriority({ params: { mainParam: classNames } = {} }) {
return (classNames || "")?.trim().split(/\s+/).filter(Boolean).length || 0;
}
isApplied({ editingElement, params: { mainParam: classNames } = {} }) {
if (classNames === undefined || classNames === "") {
return true;
}
return classNames
.split(" ")
.every((className) => editingElement.classList.contains(className));
}
apply({ editingElement, params: { mainParam: classNames } = {} }) {
for (const className of (classNames || "").split(" ")) {
if (className !== "") {
editingElement.classList.add(className);
}
}
}
clean({ editingElement, params: { mainParam: classNames } = {} }) {
for (const className of (classNames || "").split(" ")) {
if (className !== "") {
editingElement.classList.remove(className);
}
}
}
}
class AttributeAction extends BuilderAction {
static id = "attributeAction";
getValue({ editingElement, params: { mainParam: attributeName } = {} }) {
return editingElement.getAttribute(attributeName);
}
isApplied({ editingElement, params: { mainParam: attributeName } = {}, value }) {
if (value) {
return (
editingElement.hasAttribute(attributeName) &&
editingElement.getAttribute(attributeName) === value
);
} else {
return !editingElement.hasAttribute(attributeName);
}
}
apply({ editingElement, params: { mainParam: attributeName } = {}, value }) {
if (value) {
editingElement.setAttribute(attributeName, value);
} else {
editingElement.removeAttribute(attributeName);
}
}
clean({ editingElement, params: { mainParam: attributeName } = {} }) {
editingElement.removeAttribute(attributeName);
}
}
class DataAttributeAction extends BuilderAction {
static id = "dataAttributeAction";
getValue({ editingElement, params: { mainParam: attributeName } = {} }) {
if (!/(^color|Color)($|(?=[A-Z]))/.test(attributeName)) {
return editingElement.dataset[attributeName];
}
const color = normalizeColor(
editingElement.dataset[attributeName],
getHtmlStyle(this.document)
);
return color;
}
isApplied({ editingElement, params: { mainParam: attributeName } = {}, value }) {
if (value) {
value = getValueFromVar(value.toString());
return editingElement.dataset[attributeName] === value;
} else {
return !(attributeName in editingElement.dataset);
}
}
apply({ editingElement, params: { mainParam: attributeName } = {}, value }) {
if (value) {
value = getValueFromVar(value.toString());
editingElement.dataset[attributeName] = value;
} else {
delete editingElement.dataset[attributeName];
}
}
clean({ editingElement, params: { mainParam: attributeName } = {} }) {
delete editingElement.dataset[attributeName];
}
}
// TODO maybe find a better place for this
class SetClassRangeAction extends BuilderAction {
static id = "setClassRange";
getValue({ editingElement, params: { mainParam: classNames } }) {
for (const index in classNames) {
const className = classNames[index];
if (editingElement.classList.contains(className)) {
return index;
}
}
}
apply({ editingElement, params: { mainParam: classNames }, value: index }) {
for (const className of classNames) {
if (editingElement.classList.contains(className)) {
editingElement.classList.remove(className);
}
}
editingElement.classList.add(classNames[index]);
}
}
export class StyleAction extends BuilderAction {
static id = "styleAction";
static dependencies = ["color"];
getValue({ editingElement: el, params: { mainParam: styleName } }) {
if (styleName === "--box-border-width"
|| CSS_SHORTHANDS["--box-border-width"].includes(styleName)
|| styleName === "--box-border-radius"
|| CSS_SHORTHANDS["--box-border-radius"].includes(styleName)) {
// When reading a CSS variable, we need to get the computed value
// of the actual property it controls, ideally. Not only because the
// panel should reflect what the user actually sees but also because
// the user could have forced its own inline style by himself. Also,
// by compatibility with how borders were edited in the past.
// See CSS_VARIABLE_EDIT_TODO.
//
// TODO this should probably be more generic. Note that this was
// also done as a fix where reading the actual CSS variable value
// was simply not working properly because getStyleValue checks the
// CSS_SHORTHANDS which obviously do not magically work.
styleName = styleName.substring("--box-".length);
}
if (styleName === "box-shadow") {
const value = getStyleValue(el, styleName);
const inset = value.includes("inset");
let values = value.replace(/,\s/g, ",").replace("inset", "").trim().split(/\s+/g);
const color = values.find((s) => !s.match(/^\d/));
values = values.join(" ").replace(color, "").trim();
return `${color} ${values}${inset ? " inset" : ""}`;
} else if (
styleName === "border-width" ||
CSS_SHORTHANDS["border-width"].includes(styleName)
) {
let value = getStyleValue(el, styleName);
if (value.endsWith("px")) {
value = value
.split(/\s+/g)
.map(
(singleValue) =>
// Rounding value up avoids zoom-in issues.
// Zoom-out issues are not an expected use case.
`${Math.ceil(parseFloat(singleValue))}px`
)
.join(" ");
}
return value;
} else if (styleName === "row-gap" || styleName === "column-gap") {
return parseInt(getStyleValue(el, styleName)) || 0;
} else if (styleName === "width") {
return el.style.width;
} else if (styleName === "background-color") {
return this.dependencies.color.getElementColors(el)["backgroundColor"];
} else if (styleName === "color") {
return this.dependencies.color.getElementColors(el)["color"];
}
return this._getValueWithoutTransition(el, styleName);
}
isApplied({ editingElement: el, params: { mainParam: styleName }, value }) {
const currentValue = this.getValue({
editingElement: el,
params: { mainParam: styleName },
});
return currentValue === value;
}
apply({ editingElement, params = {}, value }) {
if (!this.delegateTo("apply_custom_css_style", { editingElement, params, value })) {
this.applyCssStyle({ editingElement, params, value });
}
}
applyCssStyle({ editingElement, params = {}, value }) {
params = { ...params };
const styleName = params.mainParam;
delete params.mainParam;
// Disable all transitions for the duration of the method as many
// comparisons will be done on the element to know if applying a
// property has an effect or not. Also, changing a css property via the
// editor should not show any transition as previews would not be done
// immediately, which is not good for the user experience.
withoutTransition(editingElement, () => {
setStyle(editingElement, styleName, value, params);
});
}
_getValueWithoutTransition(el, styleName) {
return withoutTransition(el, () => getStyleValue(el, styleName));
}
}

View file

@ -0,0 +1,88 @@
import {
MAIN_PLUGINS as MAIN_EDITOR_PLUGINS,
NO_EMBEDDED_COMPONENTS_FALLBACK_PLUGINS,
} from "@html_editor/plugin_sets";
import { removePlugins } from "@html_builder/utils/utils";
import { AnchorPlugin } from "./anchor/anchor_plugin";
import { BuilderActionsPlugin } from "./builder_actions_plugin";
import { BuilderComponentPlugin } from "./builder_component_plugin";
import { BuilderOptionsPlugin } from "./builder_options_plugin";
import { BuilderOverlayPlugin } from "./builder_overlay/builder_overlay_plugin";
import { CachedModelPlugin } from "./cached_model_plugin";
import { ClonePlugin } from "./clone_plugin";
import { ColorUIPlugin } from "./color_ui_plugin";
import { CoreBuilderActionPlugin } from "./core_builder_action_plugin";
import { CompositeActionPlugin } from "./composite_action_plugin";
import { CustomizeTabPlugin } from "./customize_tab_plugin";
import { DisableSnippetsPlugin } from "./disable_snippets_plugin";
import { DragAndDropPlugin } from "./drag_and_drop_plugin";
import { DropZonePlugin } from "./drop_zone_plugin";
import { DropZoneSelectorPlugin } from "./dropzone_selector_plugin";
import { GridLayoutPlugin } from "./grid_layout/grid_layout_plugin";
import { MediaWebsitePlugin } from "./media_website_plugin";
import { MovePlugin } from "./move_plugin";
import { OperationPlugin } from "./operation_plugin";
import { OverlayButtonsPlugin } from "./overlay_buttons/overlay_buttons_plugin";
import { RemovePlugin } from "./remove_plugin";
import { SavePlugin } from "./save_plugin";
import { SaveSnippetPlugin } from "./save_snippet_plugin";
import { SetupEditorPlugin } from "./setup_editor_plugin";
import { VisibilityPlugin } from "./visibility_plugin";
import { FieldChangeReplicationPlugin } from "./field_change_replication_plugin";
import { BuilderContentEditablePlugin } from "./builder_content_editable_plugin";
import { ImageFieldPlugin } from "@html_builder/plugins/image_field_plugin";
import { MonetaryFieldPlugin } from "@html_builder/plugins/monetary_field_plugin";
import { Many2OneOptionPlugin } from "@html_builder/plugins/many2one_option_plugin";
const mainEditorPluginsToRemove = [
"PowerButtonsPlugin",
"DoubleClickImagePreviewPlugin",
"SeparatorPlugin",
"StarPlugin",
"BannerPlugin",
"MoveNodePlugin",
"FontFamilyPlugin",
// Replaced plugins:
"ColorUIPlugin",
];
export const MAIN_PLUGINS = [
...removePlugins(
[...MAIN_EDITOR_PLUGINS, ...NO_EMBEDDED_COMPONENTS_FALLBACK_PLUGINS],
mainEditorPluginsToRemove
),
ColorUIPlugin,
];
export const CORE_PLUGINS = [
...MAIN_PLUGINS,
BuilderOptionsPlugin,
BuilderActionsPlugin,
BuilderComponentPlugin,
OperationPlugin,
BuilderOverlayPlugin,
OverlayButtonsPlugin,
MovePlugin,
GridLayoutPlugin,
DragAndDropPlugin,
RemovePlugin,
ClonePlugin,
SaveSnippetPlugin,
AnchorPlugin,
DropZonePlugin,
DisableSnippetsPlugin,
MediaWebsitePlugin,
SetupEditorPlugin,
SavePlugin,
VisibilityPlugin,
DropZoneSelectorPlugin,
CachedModelPlugin,
CoreBuilderActionPlugin,
CompositeActionPlugin,
CustomizeTabPlugin,
FieldChangeReplicationPlugin,
BuilderContentEditablePlugin,
ImageFieldPlugin,
MonetaryFieldPlugin,
Many2OneOptionPlugin,
];

View file

@ -0,0 +1,42 @@
import { Plugin } from "@html_editor/plugin";
import { reactive } from "@odoo/owl";
import { registry } from "@web/core/registry";
export class CustomizeTabPlugin extends Plugin {
static id = "customizeTab";
static shared = ["getCustomizeComponent", "openCustomizeComponent", "closeCustomizeComponent"];
resources = {
post_redo_handlers: () => this.closeCustomizeComponent(),
post_undo_handlers: () => this.closeCustomizeComponent(),
change_current_options_containers_listeners: () => this.closeCustomizeComponent(),
};
setup() {
this.customizeComponent = reactive({
component: null,
props: {},
editingEls: null,
});
this.closeCustomizeComponent = this.closeCustomizeComponent.bind(this);
}
getCustomizeComponent() {
return this.customizeComponent;
}
openCustomizeComponent(component, editingEls, props = {}) {
this.customizeComponent.component = component;
this.customizeComponent.editingEls = editingEls;
this.customizeComponent.props = {
...props,
onClose: this.closeCustomizeComponent,
};
}
closeCustomizeComponent() {
if (this.customizeComponent) {
this.customizeComponent.component = null;
this.customizeComponent.editingEls = null;
this.customizeComponent.props = {};
}
}
}
registry.category("builder-plugins").add(CustomizeTabPlugin.id, CustomizeTabPlugin);

View file

@ -0,0 +1,48 @@
import { EventBus } from "@odoo/owl";
import { batched } from "@web/core/utils/timing";
export class DependencyManager extends EventBus {
constructor() {
super();
this.dependencies = [];
this.dependenciesMap = {};
this.count = 0;
this.dirty = false;
this.triggerDependencyUpdated = batched(() => {
this.trigger("dependency-updated");
});
}
update() {
this.dependenciesMap = {};
for (const [id, value, ignored] of this.dependencies.slice().reverse()) {
if (ignored && id in this.dependenciesMap) {
continue;
}
this.dependenciesMap[id] = value;
}
this.dirty = false;
}
add(id, value, ignored = false) {
// In case the dependency is added after a dependent try to get it
// an event is scheduled to notify the dependent about it.
if (!ignored || !(id in this.dependenciesMap)) {
this.triggerDependencyUpdated();
}
this.dependencies.push([id, value, ignored]);
this.dirty = true;
}
get(id) {
if (this.dirty) {
this.update();
}
return this.dependenciesMap[id];
}
removeByValue(value) {
this.dependencies = this.dependencies.filter(([, v]) => v !== value);
this.dirty = true;
this.triggerDependencyUpdated();
}
}

View file

@ -0,0 +1,157 @@
import { omit } from "@web/core/utils/objects";
import { Plugin } from "@html_editor/plugin";
import { withSequence } from "@html_editor/utils/resource";
export class DisableSnippetsPlugin extends Plugin {
static id = "disableSnippets";
static dependencies = ["setup_editor_plugin", "dropzone", "dropzone_selector"];
static shared = ["disableUndroppableSnippets"];
resources = {
on_removed_handlers: this.disableUndroppableSnippets.bind(this),
post_undo_handlers: this.disableUndroppableSnippets.bind(this),
post_redo_handlers: this.disableUndroppableSnippets.bind(this),
on_mobile_preview_clicked: withSequence(20, this.disableUndroppableSnippets.bind(this)),
};
setup() {
this.snippetModel = this.config.snippetModel;
this._disableSnippets = this.disableUndroppableSnippets.bind(this);
// TODO only for website ?
// TODO improve to add case when "+" menu appears (resize event ?)
const editableDropdownEls = this.editable.querySelectorAll(".dropdown-menu.o_editable");
editableDropdownEls.forEach((dropdownEl) => {
const dropdownToggleEl = dropdownEl.parentNode.querySelector(".dropdown-toggle");
this.addDomListener(dropdownToggleEl, "shown.bs.dropdown", this._disableSnippets);
this.addDomListener(dropdownToggleEl, "hidden.bs.dropdown", this._disableSnippets);
});
const offcanvasEls = this.editable.querySelectorAll(".offcanvas");
offcanvasEls.forEach((offcanvasEl) => {
this.addDomListener(offcanvasEl, "shown.bs.offcanvas", this._disableSnippets);
this.addDomListener(offcanvasEl, "hidden.bs.offcanvas", this._disableSnippets);
});
this.disableUndroppableSnippets();
}
/**
* Makes the snippet that cannot be dropped anywhere appear disabled.
* TODO: trigger the computation in the situation that needs it.
*/
disableUndroppableSnippets() {
const editableAreaEls = this.dependencies["setup_editor_plugin"].getEditableAreas();
const rootEl = this.dependencies.dropzone.getDropRootElement();
const dropAreasBySelector = this.getDropAreas(editableAreaEls, rootEl);
// A snippet can only be dropped next/inside elements that are editable
// and that do not explicitely block them.
const checkSanitize = (el, snippetEl) => {
let forbidSanitize = false;
// Check if the snippet is sanitized/contains such snippets.
for (const el of [snippetEl, ...snippetEl.querySelectorAll("[data-snippet")]) {
const snippet = this.snippetModel.getOriginalSnippet(el.dataset.snippet);
if (snippet && snippet.forbidSanitize) {
forbidSanitize = snippet.forbidSanitize;
if (forbidSanitize === true) {
break;
}
}
}
if (forbidSanitize === "form") {
return !el.closest('[data-oe-sanitize]:not([data-oe-sanitize="allow_form"])');
} else {
return forbidSanitize ? !el.closest("[data-oe-sanitize]") : true;
}
};
const canDrop = (snippet) => {
const snippetEl = snippet.content;
return !!dropAreasBySelector.find(
({ selector, exclude, dropAreaEls }) =>
snippetEl.matches(selector) &&
!snippetEl.matches(exclude) &&
dropAreaEls.some((el) => checkSanitize(el, snippetEl))
);
};
// Disable the snippets that cannot be dropped.
const snippetGroups = this.snippetModel.snippetsByCategory["snippet_groups"];
let areGroupsDisabled = false;
if (snippetGroups.length && !canDrop(snippetGroups[0])) {
snippetGroups.forEach((snippetGroup) => (snippetGroup.isDisabled = true));
areGroupsDisabled = true;
}
const snippets = [];
const ignoredCategories = ["snippet_groups"];
if (areGroupsDisabled) {
ignoredCategories.push(...["snippet_structure", "snippet_custom"]);
}
for (const category in omit(this.snippetModel.snippetsByCategory, ...ignoredCategories)) {
snippets.push(...this.snippetModel.snippetsByCategory[category]);
}
snippets.forEach((snippet) => {
snippet.isDisabled = !canDrop(snippet);
});
// Disable the groups containing only disabled snippets.
if (!areGroupsDisabled) {
snippetGroups.forEach((snippetGroup) => {
if (snippetGroup.groupName !== "custom") {
snippetGroup.isDisabled = !snippets.find(
(snippet) =>
snippet.groupName === snippetGroup.groupName && !snippet.isDisabled
);
} else {
const customSnippets = this.snippetModel.snippetsByCategory["snippet_custom"];
snippetGroup.isDisabled = !customSnippets.find(
(snippet) => !snippet.isDisabled
);
}
});
}
}
/**
* Stores the selector/exclude that will make dropzones appear inside the
* editable elements, as well as the droppable zones (to compute them only
* once).
*
* @param {Array<HTMLElement>} editableAreaEls
* @param {HTMLElement} rootEl
* @returns {Array<Object>}
*/
getDropAreas(editableAreaEls, rootEl) {
const dropAreasBySelector = [];
this.getResource("dropzone_selector").forEach((dropzoneSelector) => {
const {
selector,
exclude = false,
dropIn,
dropNear,
excludeNearParent,
} = dropzoneSelector;
const dropAreaEls = [];
if (dropNear) {
dropAreaEls.push(
...this.dependencies.dropzone.getSelectorSiblings(editableAreaEls, rootEl, {
selector: dropNear,
excludeNearParent,
})
);
}
if (dropIn) {
dropAreaEls.push(
...this.dependencies.dropzone.getSelectorChildren(editableAreaEls, rootEl, {
selector: dropIn,
})
);
}
if (dropAreaEls.length) {
dropAreasBySelector.push({ selector, exclude, dropAreaEls });
}
});
return dropAreasBySelector;
}
}

View file

@ -0,0 +1,8 @@
import { Plugin } from "@html_editor/plugin";
export class DisableSnippetsPlugin extends Plugin {
static id = "disableSnippets";
static shared = ["disableUndroppableSnippets"];
disableUndroppableSnippets() {}
}

View file

@ -0,0 +1,17 @@
import { Component, onMounted } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
export class DragAndDropMoveHandle extends Component {
static template = "html_builder.DragAndDropMoveHandle";
static props = {
onRenderedCallback: { type: Function },
};
setup() {
this.title = _t("Drag and move");
onMounted(() => {
this.props.onRenderedCallback();
});
}
}

View file

@ -0,0 +1,8 @@
<templates xml:space="preserve">
<t t-name="html_builder.DragAndDropMoveHandle">
<button class="btn o_move_handle fa fa-arrows"
t-att-title="title" t-att-aria-label="title"/>
</t>
</templates>

View file

@ -0,0 +1,397 @@
import { Plugin } from "@html_editor/plugin";
import { withSequence } from "@html_editor/utils/resource";
import { useDragAndDrop } from "@html_editor/utils/drag_and_drop";
import { closestScrollableY, getScrollingElement, isScrollableY } from "@web/core/utils/scrolling";
import { closest, touching } from "@web/core/utils/ui";
import { clamp } from "@web/core/utils/numbers";
import { rowSize } from "@html_builder/utils/grid_layout_utils";
import { isEditable, isVisible } from "@html_builder/utils/utils";
import { DragAndDropMoveHandle } from "./drag_and_drop_move_handle";
export class DragAndDropPlugin extends Plugin {
static id = "dragAndDrop";
static dependencies = ["dropzone", "history", "operation", "builderOptions"];
resources = {
has_overlay_options: { hasOption: (el) => this.isDraggable(el) },
get_overlay_buttons: withSequence(1, {
getButtons: this.getActiveOverlayButtons.bind(this),
}),
system_classes: ["o_draggable"],
clean_for_save_handlers: this.cleanForSave.bind(this),
};
setup() {
this.dropzoneSelectors = this.getResource("dropzone_selector");
this.overlayTarget = null;
this.iframe = this.document.defaultView.frameElement;
this.isRtl = this.config.isEditableRTL;
}
destroy() {
this.draggableComponent?.destroy();
this.draggableComponentImgs?.destroy();
}
cleanForSave({ root }) {
[root, ...root.querySelectorAll(".o_draggable")].forEach((el) => {
el.classList.remove("o_draggable");
});
}
isDraggable(el) {
const isDraggable =
isEditable(el.parentNode) &&
!el.matches(".oe_unmovable") &&
!!this.dropzoneSelectors.find(
({ selector, exclude = false }) => el.matches(selector) && !el.matches(exclude)
);
if (!isDraggable) {
return false;
}
for (const isDraggable of this.getResource("is_draggable_handlers")) {
if (!isDraggable(el)) {
return false;
}
}
return true;
}
getActiveOverlayButtons(target) {
if (!this.isDraggable(target)) {
this.overlayTarget = null;
this.draggableComponent?.destroy();
this.draggableComponentImgs?.destroy();
return [];
}
const buttons = [];
this.overlayTarget = target;
buttons.push({
Component: DragAndDropMoveHandle,
props: {
onRenderedCallback: () => {
this.draggableComponent?.destroy();
this.draggableComponentImgs?.destroy();
this.draggableComponent = this.initDragAndDrop(
".o_move_handle",
".o_overlay_options",
document.querySelector(".o_move_handle")
);
if (!this.overlayTarget.matches("section")) {
this.draggableComponentImgs = this.initDragAndDrop(
"img",
".o_draggable",
this.overlayTarget,
true
);
}
},
},
});
return buttons;
}
/**
* Initializes the drag and drop handles.
*
* @param {String} handleSelector a selector targeting the handle to drag
* @param {String} elementsSelector a selector targeting the element that
* will be dragged
* @param {HTMLElement} element the element to listen for drag events
* @param {Boolean} [fromIframe=false] true if the dragged element is in the
* iframe
* @returns {Object}
*/
initDragAndDrop(handleSelector, elementsSelector, element, fromIframe = false) {
let dropzoneEls = [];
let dragAndDropResolve;
const iframeWindow =
this.document.defaultView !== window ? this.document.defaultView : false;
const scrollingElement = () => {
let scrollingElement =
this.dependencies.dropzone.getDropRootElement() ||
getScrollingElement(this.document);
if (!isScrollableY(scrollingElement)) {
scrollingElement = closestScrollableY(this.iframe) ?? scrollingElement;
}
return scrollingElement;
};
const dragAndDropOptions = {
ref: { el: element },
iframeWindow,
cursor: "move",
elements: elementsSelector,
scrollingElement,
handle: handleSelector,
enable: () => !!document.querySelector(".o_move_handle") || this.dragStarted, // Still needed ?
dropzones: () => dropzoneEls,
helper: ({ helperOffset }) => {
const draggedEl = document.createElement("div");
draggedEl.classList.add("o_drag_move_helper");
Object.assign(draggedEl.style, {
width: "24px",
height: "24px",
});
document.body.append(draggedEl);
const iframeRect = this.document.defaultView.frameElement.getBoundingClientRect();
helperOffset.x = 12 - (fromIframe ? iframeRect.x : 0);
helperOffset.y = 12;
return draggedEl;
},
onDragStart: ({ x, y }) => {
const dragAndDropProm = new Promise(
(resolve) => (dragAndDropResolve = () => resolve())
);
this.dependencies.operation.next(async () => await dragAndDropProm, {
withLoadingEffect: false,
});
const restoreDragSavePoint = this.dependencies.history.makeSavePoint();
this.cancelDragAndDrop = () => {
this.dependencies.dropzone.removeDropzones();
// Undo the changes needed to ease the drag and drop.
this.dragState.restoreCallbacks?.forEach((restore) => restore());
restoreDragSavePoint();
dragAndDropResolve();
this.dependencies["builderOptions"].updateContainers(this.overlayTarget);
};
this.dragStarted = true;
this.dragState = {};
dropzoneEls = [];
// Bound the mouse for the case where we drag from an image.
// Bound the Y mouse position to not escape the grid too easily.
let targetRect = this.overlayTarget.getBoundingClientRect();
const gridRowSize = rowSize;
const boundedYMousePosition = clamp(
y,
targetRect.top + 12, // helper offset
targetRect.bottom - gridRowSize // height minus one grid row
);
this.dragState.mousePositionYOnElement = boundedYMousePosition - targetRect.y;
this.dragState.mousePositionXOnElement = (x - targetRect.x) * (this.isRtl ? -1 : 1);
// Stop marking the elements with mutations as dirty and make
// some changes on the page to ease the drag and drop.
const restoreCallbacks = [];
for (const prepareDrag of this.getResource("on_prepare_drag_handlers")) {
const restore = prepareDrag();
restoreCallbacks.unshift(restore);
}
this.dragState.restoreCallbacks = restoreCallbacks;
this.dispatchTo("on_element_dragged_handlers", {
draggedEl: this.overlayTarget,
dragState: this.dragState,
});
// Storing the element starting top and middle position.
targetRect = this.overlayTarget.getBoundingClientRect();
this.dragState.startTop = targetRect.top;
this.dragState.startMiddle = targetRect.left + targetRect.width / 2;
this.dragState.overFirstDropzone = true;
// Check if the element is inline.
const targetStyle = window.getComputedStyle(this.overlayTarget);
const toInsertInline = targetStyle.display.includes("inline");
// Store the parent and siblings.
const parentEl = this.overlayTarget.parentElement;
this.dragState.startParentEl = parentEl;
this.dragState.startPreviousEl = this.overlayTarget.previousElementSibling;
this.dragState.startNextEl = this.overlayTarget.nextElementSibling;
// Add a clone, to allow to drop where it started.
const visibleSiblingEl = [...parentEl.children].find(
(el) => el !== this.overlayTarget && isVisible(el)
);
if (parentEl.children.length === 1 || !visibleSiblingEl) {
const dropCloneEl = this.overlayTarget.cloneNode();
dropCloneEl.classList.add("oe_drop_clone");
dropCloneEl.style.visibility = "hidden";
dropCloneEl.style.height = `${targetRect.height}px`;
dropCloneEl.style.width = `${targetRect.width}px`;
this.overlayTarget.after(dropCloneEl);
this.dragState.dropCloneEl = dropCloneEl;
}
// Get the dropzone selectors.
const isColumn = parentEl.classList.contains("row");
const withGrids = isColumn || "filterOnly";
const selectors = this.dependencies.dropzone.getSelectors(
this.overlayTarget,
true,
withGrids
);
// Remove the dragged element and deactivate the options.
this.overlayTarget.remove();
this.dependencies["builderOptions"].deactivateContainers();
// Add the dropzones.
dropzoneEls = this.dependencies.dropzone.activateDropzones(selectors, {
toInsertInline,
});
},
dropzoneOver: ({ dropzone }) => {
const dropzoneEl = dropzone.el;
// Prevent the element to be trapped in an upper dropzone at the
// start of the drag.
if (this.dragState.overFirstDropzone) {
this.dragState.overFirstDropzone = false;
const { startTop, startMiddle } = this.dragState;
// The element is considered as glued to the dropzone if the
// dropzone is above and if it is touching the initial
// helper position.
const helperRect = {
x: startMiddle - 12,
y: startTop - 24,
width: 24,
height: 24,
};
const dropzoneRect = dropzoneEl.getBoundingClientRect();
const dropzoneBottom = dropzoneRect.bottom;
const isGluedToDropzone =
startTop >= dropzoneBottom && !!touching([dropzoneEl], helperRect).length;
if (isGluedToDropzone) {
return;
}
}
dropzoneEl.classList.add("invisible");
dropzoneEl.after(this.overlayTarget);
this.dragState.currentDropzoneEl = dropzoneEl;
this.dispatchTo("on_element_over_dropzone_handlers", {
draggedEl: this.overlayTarget,
dragState: this.dragState,
});
},
onDrag: ({ x, y }) => {
if (!this.dragState.currentDropzoneEl) {
return;
}
this.dispatchTo("on_element_move_handlers", {
draggedEl: this.overlayTarget,
dragState: this.dragState,
x,
y,
});
},
dropzoneOut: () => {
const dropzoneEl = this.dragState.currentDropzoneEl;
if (!dropzoneEl) {
return;
}
this.dispatchTo("on_element_out_dropzone_handlers", {
draggedEl: this.overlayTarget,
dragState: this.dragState,
});
this.overlayTarget.remove();
dropzoneEl.classList.remove("invisible");
this.dragState.currentDropzoneEl = null;
},
onDragEnd: async ({ x, y }) => {
this.dragStarted = false;
let currentDropzoneEl = this.dragState.currentDropzoneEl;
const isDroppedOver = !!currentDropzoneEl;
// If the snippet was dropped outside of a dropzone, find the
// dropzone that is the nearest to the dropping point.
if (!currentDropzoneEl) {
const closestDropzoneEl = closest(dropzoneEls, { x, y });
if (!closestDropzoneEl) {
this.cancelDragAndDrop();
return;
}
currentDropzoneEl = closestDropzoneEl;
}
if (isDroppedOver) {
this.dispatchTo("on_element_dropped_over_handlers", {
droppedEl: this.overlayTarget,
dragState: this.dragState,
});
} else {
currentDropzoneEl.after(this.overlayTarget);
this.dispatchTo("on_element_dropped_near_handlers", {
droppedEl: this.overlayTarget,
dropzoneEl: currentDropzoneEl,
dragState: this.dragState,
});
}
// In order to mark only the concerned elements as dirty, place
// the element back where it started. The move will then be
// replayed after re-allowing to mark dirty.
const { startPreviousEl, startNextEl, startParentEl } = this.dragState;
if (startPreviousEl) {
startPreviousEl.after(this.overlayTarget);
} else if (startNextEl) {
startNextEl.before(this.overlayTarget);
} else {
startParentEl.prepend(this.overlayTarget);
}
// Undo the changes needed to ease the drag and drop and
// re-allow to mark dirty.
this.dragState.restoreCallbacks.forEach((restore) => restore());
this.dragState.restoreCallbacks = null;
// Replay the move.
currentDropzoneEl.after(this.overlayTarget);
this.dependencies.dropzone.removeDropzones();
this.dragState.dropCloneEl?.remove();
// Process the dropped element.
for (const onElementDropped of this.getResource("on_element_dropped_handlers")) {
const cancel = await onElementDropped({
droppedEl: this.overlayTarget,
dragState: this.dragState,
});
// Cancel everything if the resource asked to.
if (cancel) {
this.cancelDragAndDrop();
return;
}
}
// Add a history step only if the element was not dropped where
// it was before, otherwise cancel everything.
let hasSamePositionAsStart;
if ("hasSamePositionAsStart" in this.dragState) {
hasSamePositionAsStart = this.dragState.hasSamePositionAsStart();
} else {
const previousEl = this.overlayTarget.previousElementSibling;
const nextEl = this.overlayTarget.nextElementSibling;
const parentEl = this.overlayTarget.parentElement;
hasSamePositionAsStart =
startPreviousEl === previousEl &&
startNextEl === nextEl &&
startParentEl === parentEl;
}
if (!hasSamePositionAsStart) {
this.dependencies.history.addStep();
} else {
this.cancelDragAndDrop();
return;
}
dragAndDropResolve();
this.dependencies["builderOptions"].updateContainers(this.overlayTarget);
},
};
return useDragAndDrop(dragAndDropOptions);
}
}

View file

@ -0,0 +1,61 @@
.oe_drop_zone {
background: $o-we-dropzone-bg-color;
animation: dropZoneInsert 1s linear 0s infinite alternate;
&.oe_insert {
position: relative;
width: 100%;
border-radius: $border-radius-lg;
outline: $o-we-dropzone-border-width dashed $o-we-dropzone-accent-color;
outline-offset: -$o-we-dropzone-border-width;
z-index: 2000; // TODO use $o-we-overlay-zindex instead
}
&.o_dropzone_highlighted {
filter: brightness(1.5);
transition: 200ms;
}
}
.oe_drop_zone:not(.oe_grid_zone) {
&.oe_insert {
min-width: $o-we-dropzone-size;
height: $o-we-dropzone-size;
min-height: $o-we-dropzone-size;
margin: (-$o-we-dropzone-size/2) 0;
padding: 0;
&.oe_vertical {
width: $o-we-dropzone-size;
float: left;
margin: 0 (-$o-we-dropzone-size/2);
}
}
&.oe_sanitized_drop_zone {
position: absolute;
top: 0px;
height: 100%;
padding: 15px;
margin: 0px;
backdrop-filter: blur(15px);
background-color: rgba($o-we-bg-lighter, 0.15);
color: white;
outline-color: $o-we-bg-lighter;
z-index: 1999; // TODO
> p {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: calc(100% - 30px);
text-shadow: 0px 0px 4px black;
font-size: 20px;
}
}
}
// TODO for mass_mailing only ?
body.oe_dropzone_active .note-editable {
overflow: hidden;
}

View file

@ -0,0 +1,520 @@
import { isVisible } from "@html_builder/utils/utils";
import { Plugin } from "@html_editor/plugin";
import { isElement } from "@html_editor/utils/dom_info";
import { _t } from "@web/core/l10n/translation";
export class DropZonePlugin extends Plugin {
static id = "dropzone";
static dependencies = ["history", "setup_editor_plugin"];
static shared = [
"activateDropzones",
"removeDropzones",
"getDropRootElement",
"getSelectorSiblings",
"getSelectorChildren",
"getSelectors",
];
resources = {
savable_mutation_record_predicates: (record) => {
if (record.type === "childList") {
const addedOrRemovedNode = (record.addedTrees[0] || record.removedTrees[0]).node;
// Do not record the addition/removal of the dropzones.
if (isElement(addedOrRemovedNode) && addedOrRemovedNode.matches(".oe_drop_zone")) {
return false;
}
}
return true;
},
};
setup() {
this.snippetModel = this.config.snippetModel;
this.dropzoneSelectors = this.getResource("dropzone_selector");
this.iframe = this.document.defaultView.frameElement;
}
/**
* Returns the root element in which the elements can be dropped.
* (e.g. if a modal or a dropdown is open, the snippets must be dropped only
* in this element)
*
* @returns {HTMLElement|undefined}
*/
getDropRootElement() {
const openModalEl = this.editable.querySelector(".modal.show");
if (openModalEl && isVisible(openModalEl)) {
return openModalEl;
}
const openDropdownEl = this.editable.querySelector(
".o_editable.dropdown-menu.show, .dropdown-menu.show .o_editable.dropdown-menu"
);
if (openDropdownEl) {
return openDropdownEl;
}
const openOffcanvasEl = this.editable.querySelector(".offcanvas.show");
if (openOffcanvasEl) {
return openOffcanvasEl.querySelector(".offcanvas-body");
}
}
/**
* Gets the selectors that determine where the given element can be placed.
*
* @param {HTMLElement} snippetEl the element
* @param {Boolean} [checkLockedWithin=false] true if the selectors should
* be filtered based on the `dropLockWithin` selectors
* @param {Boolean|String} [withGrids=false]
* - `true` if the elements in grid mode are considered,
* - `"filterOnly"` if the grids should only be filtered out,
* - `false`
* @returns {Object} [selectorChildren, selectorSiblings]
*/
getSelectors(snippetEl, checkLockedWithin = false, withGrids = false) {
let selectorChildren = [];
let selectorSiblings = [];
const selectorExcludeAncestor = [];
const selectorLockedWithin = [];
const editableAreaEls = this.dependencies.setup_editor_plugin.getEditableAreas();
const rootEl = this.getDropRootElement();
this.dropzoneSelectors.forEach((dropzoneSelector) => {
const {
selector,
exclude = false,
dropIn,
dropNear,
dropLockWithin,
excludeAncestor,
excludeNearParent,
} = dropzoneSelector;
if (snippetEl.matches(selector) && !snippetEl.matches(exclude)) {
if (dropNear) {
selectorSiblings.push(
...this.getSelectorSiblings(editableAreaEls, rootEl, {
selector: dropNear,
excludeNearParent,
})
);
}
if (dropIn) {
selectorChildren.push(
...this.getSelectorChildren(editableAreaEls, rootEl, { selector: dropIn })
);
}
if (dropLockWithin) {
selectorLockedWithin.push(dropLockWithin);
}
if (excludeAncestor) {
selectorExcludeAncestor.push(excludeAncestor);
}
}
});
// Remove the dragged element from the selectors.
selectorSiblings = selectorSiblings.filter((el) => !snippetEl.contains(el));
selectorChildren = selectorChildren.filter((el) => !snippetEl.contains(el));
// Prevent dropping an element into another one.
// (e.g. ToC inside another ToC)
if (selectorExcludeAncestor.length) {
const excludeAncestor = selectorExcludeAncestor.join(",");
selectorSiblings = selectorSiblings.filter((el) => !el.closest(excludeAncestor));
selectorChildren = selectorChildren.filter((el) => !el.closest(excludeAncestor));
}
// Prevent dropping an element outside a given direct or indirect parent
// (e.g. form field must remain within its own form)
if (checkLockedWithin && selectorLockedWithin.length) {
const lockedAncestorsSelector = selectorLockedWithin.join(",");
const closestLockedAncestorEl = snippetEl.closest(lockedAncestorsSelector);
const filterFct = (el) =>
el.closest(lockedAncestorsSelector) === closestLockedAncestorEl;
selectorSiblings = selectorSiblings.filter(filterFct);
selectorChildren = selectorChildren.filter(filterFct);
}
// Prevent dropping sanitized elements in sanitized zones.
let forbidSanitize = false;
// Check if the element is sanitized or if it contains such elements.
for (const el of [snippetEl, ...snippetEl.querySelectorAll("[data-snippet")]) {
const snippet = this.snippetModel.getOriginalSnippet(el.dataset.snippet);
if (snippet && snippet.forbidSanitize) {
forbidSanitize = snippet.forbidSanitize;
if (forbidSanitize === true) {
break;
}
}
}
const selectorSanitized = new Set();
const filterSanitized = (el) => {
if (el.closest('[data-oe-sanitize="no_block"]')) {
return false;
}
let sanitizedZoneEl;
if (forbidSanitize === "form") {
sanitizedZoneEl = el.closest(
'[data-oe-sanitize]:not([data-oe-sanitize="allow_form"]):not([data-oe-sanitize="no_block"])'
);
} else if (forbidSanitize) {
sanitizedZoneEl = el.closest(
'[data-oe-sanitize]:not([data-oe-sanitize="no_block"])'
);
}
if (sanitizedZoneEl) {
selectorSanitized.add(sanitizedZoneEl);
return false;
}
return true;
};
selectorSiblings = selectorSiblings.filter((el) => filterSanitized(el));
selectorChildren = selectorChildren.filter((el) => filterSanitized(el));
// Remove the siblings/children that would add a dropzone as a direct
// child of a grid and make a dedicated set out of the identified grids.
let selectorGrids = new Set();
if (withGrids) {
const filterGrids = (potentialGridEl) => {
if (potentialGridEl.matches(".o_grid_mode")) {
selectorGrids.add(potentialGridEl);
return false;
}
return true;
};
selectorSiblings = selectorSiblings.filter((el) => filterGrids(el.parentElement));
selectorChildren = selectorChildren.filter((el) => filterGrids(el));
// If specified, only filter out the grids.
if (withGrids === "filterOnly") {
selectorGrids = new Set();
}
}
return {
selectorSiblings: new Set(selectorSiblings),
selectorChildren: new Set(selectorChildren),
selectorSanitized,
selectorGrids,
};
}
/**
* Checks the condition for a sibling/children to be valid.
*
* @param {HTMLElement} el A selectorSibling or selectorChildren element
* @param {HTMLElement} rootEl the root element in which we can drop
* @returns {Boolean}
*/
checkSelectors(el, rootEl) {
if (rootEl && !rootEl.contains(el)) {
return false;
}
// Drop only in visible elements.
if (!isVisible(el)) {
return false;
}
// Drop only in open dropdown and offcanvas elements.
if (
(el.closest(".dropdown-menu") && !el.closest(".dropdown-menu.show")) ||
(el.closest(".offcanvas") && !el.closest(".offcanvas.show"))
) {
return false;
}
return true;
}
/**
* Returns all the elements matching the `dropNear` selector, that are
* contained in editable elements. They correspond to elements next to which
* an element can be dropped (= siblings).
*
* @param {Array<HTMLElement>} editableAreaEls the editable elements
* @param {HTMLElement} rootEl the root element in which we can drop
* @param {String} selector `dropNear` selector
* @param {String} excludeParent selector allowing to exclude the siblings
* with a parent matching it.
* @returns {Array<HTMLElement>}
*/
getSelectorSiblings(editableAreaEls, rootEl, { selector, excludeParent = false }) {
const filterFct = (el) =>
this.checkSelectors(el, rootEl) &&
// Do not drop blocks into an image field.
!el.parentNode.closest("[data-oe-type=image]") &&
!el.matches(".o_not_editable *") &&
!el.matches(".o_we_no_overlay") &&
!this.delegateTo("filter_for_sibling_dropzone_predicates", el) &&
(excludeParent ? !el.parentNode.matches(excludeParent) : true);
const dropAreaEls = [];
editableAreaEls.forEach((el) => {
const areaEls = [...el.querySelectorAll(selector)].filter(filterFct);
dropAreaEls.push(...areaEls);
});
return dropAreaEls;
}
/**
* Returns all the elements matching the `dropIn` selector, that are
* contained in editable elements. They correspond to the elements in which
* elements can be dropped as children.
*
* @param {Array<HTMLElement>} editableAreaEls the editable elements
* @param {HTMLElement} rootEl the root element in which we can drop
* @param {String} selector `dropIn` selector
* @returns {Array<HTMLElement>}
*/
getSelectorChildren(editableAreaEls, rootEl, { selector }) {
const filterFct = (el) =>
this.checkSelectors(el, rootEl) &&
// Do not drop blocks into an image field.
!el.closest("[data-oe-type=image]") &&
!el.matches('.o_not_editable :not([contenteditable="true"]), .o_not_editable');
const dropAreaEls = [];
editableAreaEls.forEach((el) => {
const areaEls = el.matches(selector) ? [el] : [];
areaEls.push(...el.querySelectorAll(selector));
dropAreaEls.push(...areaEls.filter(filterFct));
});
return dropAreaEls;
}
/**
* Creates a dropzone and adapts it depending on the hook environment.
*
* @param {HTMLElement} parentEl the dropzone parent
* @param {Boolean} isVertical true if the dropzone should be vertical
* @param {Object} style the style to assign to the dropzone
* @returns {HTMLElement}
*/
createDropzone(parentEl, isVertical, style) {
const dropzoneEl = this.document.createElement("div");
dropzoneEl.classList.add("oe_drop_zone", "oe_insert");
// Set the messages to display in the dropzone.
const editorMessagesAttributes = [
"data-editor-message-default",
"data-editor-message",
"data-editor-sub-message",
];
for (const messageAttribute of editorMessagesAttributes) {
const message = parentEl.getAttribute(messageAttribute);
if (message) {
dropzoneEl.setAttribute(messageAttribute, message);
}
}
if (isVertical) {
dropzoneEl.classList.add("oe_vertical");
}
Object.assign(dropzoneEl.style, style);
return dropzoneEl;
}
/**
* Creates a dropzone covering the whole sanitized element in which we
* cannot drop.
*
* @returns {HTMLElement}
*/
createSanitizedDropzone() {
const dropzoneEl = this.document.createElement("div");
dropzoneEl.classList.add(
"oe_drop_zone",
"oe_insert",
"oe_sanitized_drop_zone",
"text-center",
"text-uppercase"
);
const messageEl = this.document.createElement("p");
messageEl.textContent = _t("For technical reasons, this block cannot be dropped here");
dropzoneEl.prepend(messageEl);
return dropzoneEl;
}
/**
* Creates a dropzone taking the entire area of the given row in grid mode.
* It will allow to place the elements dragged over it inside the grid it
* belongs to.
*
* @param {Element} rowEl
* @returns {HTMLElement}
*/
createGridDropzone(rowEl) {
const columnCount = 12;
const rowCount = parseInt(rowEl.dataset.rowCount);
const dropzoneEl = this.document.createElement("div");
dropzoneEl.classList.add("oe_drop_zone", "oe_insert", "oe_grid_zone");
Object.assign(dropzoneEl.style, {
gridArea: 1 + "/" + 1 + "/" + (rowCount + 1) + "/" + (columnCount + 1),
minHeight: window.getComputedStyle(rowEl).height,
width: window.getComputedStyle(rowEl).width,
});
return dropzoneEl;
}
/**
* Checks whether the dropzone to insert should be horizontal or vertical.
*
* @param {HTMLElement} hookEl the element before/after which the dropzone
* will be inserted
* @param {HTMLElement} parentEl the parent element of `hookEl`
* @param {Boolean} toInsertInline true if the dragged element is inline
* @returns {Object} - `vertical[Boolean]`: true if the dropzone is vertical
* - `style[Object]`: the style to add to the dropzone
*/
setDropzoneDirection(hookEl, parentEl, toInsertInline) {
let vertical = false;
const style = {};
const hookStyle = window.getComputedStyle(hookEl);
const parentStyle = window.getComputedStyle(parentEl);
const float = hookStyle.float || hookStyle.cssFloat;
const { display, flexDirection } = parentStyle;
if (
toInsertInline ||
float === "left" ||
float === "right" ||
(display === "flex" && flexDirection === "row")
) {
if (!toInsertInline) {
style.float = float;
}
// Compute the parent content width and the element outer width.
const parentPaddingX =
parseFloat(parentStyle.paddingLeft) + parseFloat(parentStyle.paddingRight);
const parentBorderX =
parseFloat(parentStyle.borderLeft) + parseFloat(parentStyle.borderRight);
const hookMarginX =
parseFloat(hookStyle.marginLeft) + parseFloat(hookStyle.marginRight);
const parentContentWidth =
parentEl.getBoundingClientRect().width - parentPaddingX - parentBorderX;
const hookOuterWidth = hookEl.getBoundingClientRect().width + hookMarginX;
if (parseInt(parentContentWidth) !== parseInt(hookOuterWidth)) {
vertical = true;
const hookOuterHeight = hookEl.getBoundingClientRect().height;
style.height = Math.max(hookOuterHeight, 30) + "px";
if (toInsertInline) {
style.display = "inline-block";
style.verticalAlign = "middle";
style.float = "none";
}
}
}
return { vertical, style };
}
/**
* @typedef Selectors
* @property {Set<HTMLElement>} selectorSiblings elements which must have
* siblings dropzones
* @property {Set<HTMLElement>} selectorChildren elements which must have
* child dropzones between each existing child
* @property {Set<HTMLElement>} selectorSanitized sanitized elements in
* which an indicative dropzone preventing the drop must be inserted
* @property {Set<HTMLElement>} selectorGrids elements which are in grid
* mode and for which a grid dropzone must be inserted
*/
/**
* @typedef Options
* @property {Boolean} toInsertInline true if the dragged element is inline
* @property {Boolean}isContentInIframe true if the content is inside an
* iframe
*/
/**
* Creates dropzones in the DOM (= locations where dragged elements may be
* dropped).
*
* @param {Selectors} selectors
* @param {Options} options
* @returns
*/
activateDropzones(
{ selectorSiblings, selectorChildren, selectorSanitized, selectorGrids },
{ toInsertInline, isContentInIframe = true } = {}
) {
const isIgnored = (el) => el.matches(".o_we_no_overlay") || !isVisible(el);
const hookEls = [];
for (const parentEl of selectorChildren) {
const validChildrenEls = [...parentEl.children].filter((el) => !isIgnored(el));
hookEls.push(...validChildrenEls);
parentEl.prepend(this.createDropzone(parentEl));
}
hookEls.push(...selectorSiblings);
// Inserting the normal dropzones.
for (const hookEl of hookEls) {
const parentEl = hookEl.parentElement;
const { vertical, style } = this.setDropzoneDirection(hookEl, parentEl, toInsertInline);
let previousEl = hookEl.previousElementSibling;
while (previousEl && isIgnored(previousEl)) {
previousEl = previousEl.previousElementSibling;
}
if (!previousEl || !previousEl.classList.contains("oe_drop_zone")) {
hookEl.before(this.createDropzone(parentEl, vertical, style));
}
if (hookEl.classList.contains("oe_drop_clone")) {
continue;
}
let nextEl = hookEl.nextElementSibling;
while (nextEl && isIgnored(nextEl)) {
nextEl = nextEl.nextElementSibling;
}
if (!nextEl || !nextEl.classList.contains("oe_drop_zone")) {
hookEl.after(this.createDropzone(parentEl, vertical, style));
}
}
// Inserting a sanitized dropzone for each sanitized area.
for (const sanitizedZoneEl of selectorSanitized) {
sanitizedZoneEl.style.position = "relative";
sanitizedZoneEl.prepend(this.createSanitizedDropzone());
}
this.sanitizedZoneEls = selectorSanitized;
// Inserting a grid dropzone for each row in grid mode.
for (const rowEl of selectorGrids) {
rowEl.append(this.createGridDropzone(rowEl));
}
// In the case where the editable content is in an iframe, take the
// iframe offset into account to compute the dropzones.
if (isContentInIframe) {
const dropzoneEls = [...this.editable.querySelectorAll(".oe_drop_zone")];
dropzoneEls.forEach((dropzoneEl) => {
dropzoneEl.oldGetBoundingRect = dropzoneEl.getBoundingClientRect;
dropzoneEl.getBoundingClientRect = () => {
// iframeRect should be re-computed every time in case
// the iframe is inside a scrollable element which can
// be scrolled during the drag&drop operation.
const iframeRect = this.iframe.getBoundingClientRect();
const rect = dropzoneEl.oldGetBoundingRect();
rect.x += iframeRect.x;
rect.y += iframeRect.y;
return rect;
};
});
}
return [...this.editable.querySelectorAll(".oe_drop_zone:not(.oe_sanitized_drop_zone)")];
}
/**
* Removes all the dropzones.
*/
removeDropzones() {
this.editable.querySelectorAll(".oe_drop_zone").forEach((dropzoneEl) => {
dropzoneEl.remove();
});
this.sanitizedZoneEls.forEach((sanitizedZoneEl) =>
sanitizedZoneEl.style.removeProperty("position")
);
this.sanitizedZoneEls = [];
}
}

Some files were not shown because too many files have changed in this diff Show more