mirror of
https://github.com/bringout/oca-ocb-web.git
synced 2026-04-19 15:12:01 +02:00
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:
parent
4b94f0abc5
commit
f866779561
1513 changed files with 396049 additions and 358525 deletions
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 & 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>
|
||||
|
|
@ -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, "-"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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() }));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o-hb-m2m-table:not(:empty) {
|
||||
margin-bottom: $o-hb-row-spacing;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.o-hidden-font-family-picker + .o-hb-select-toggle {
|
||||
--btn-padding-x: #{map-get($spacers , 2)};
|
||||
line-height: 1;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
.bl-dropdown-toggle:disabled {
|
||||
cursor: default;
|
||||
border: 1px solid #00000088;
|
||||
|
||||
&:active {
|
||||
background-color: unset;
|
||||
color: unset;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.popover .o-hb-range {
|
||||
--o-hb-range-thumb-color-popover: #{$o-we-fg-lighter};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.o-hb-input-base {
|
||||
.popover & {
|
||||
--o-hb-field-input-bg-popover: #{$o-we-sidebar-content-field-input-bg};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 }
|
||||
);
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 = {};
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Plugin } from "@html_editor/plugin";
|
||||
|
||||
export class DisableSnippetsPlugin extends Plugin {
|
||||
static id = "disableSnippets";
|
||||
static shared = ["disableUndroppableSnippets"];
|
||||
|
||||
disableUndroppableSnippets() {}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue