diff --git a/addons/html_builder/static/src/core/building_blocks/builder_number_input.js b/addons/html_builder/static/src/core/building_blocks/builder_number_input.js
index 25f6623d0f3c7..45dfd82bae9fc 100644
--- a/addons/html_builder/static/src/core/building_blocks/builder_number_input.js
+++ b/addons/html_builder/static/src/core/building_blocks/builder_number_input.js
@@ -27,10 +27,12 @@ export class BuilderNumberInput extends Component {
max: { type: Number, optional: true },
id: { type: String, optional: true },
composable: { type: Boolean, optional: true },
+ applyapplyWithUnit: { type: Boolean, optional: true },
};
static components = { BuilderComponent, BuilderTextInputBase };
static defaultProps = {
composable: false,
+ applyWithUnit: true,
};
setup() {
@@ -122,6 +124,7 @@ export class BuilderNumberInput extends Component {
}
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(
@@ -131,7 +134,7 @@ export class BuilderNumberInput extends Component {
getHtmlStyle(this.env.getEditingElement().ownerDocument)
);
}
- if (unit) {
+ if (unit && applyWithUnit) {
if (saveUnit || saveUnit === "") {
value = value + saveUnit;
} else {
diff --git a/addons/html_builder/static/src/interactions/carousel.edit.js b/addons/html_builder/static/src/interactions/carousel.edit.js
new file mode 100644
index 0000000000000..861975b2e79ac
--- /dev/null
+++ b/addons/html_builder/static/src/interactions/carousel.edit.js
@@ -0,0 +1,68 @@
+import { Interaction } from "@web/public/interaction";
+import { registry } from "@web/core/registry";
+
+export class CarouselEdit extends Interaction {
+ static selector =
+ "section:not(.s_carousel_intro_wrapper, .s_carousel_cards_wrapper) > .carousel";
+ // Prevent enabling the carousel overlay when clicking on the carousel
+ // controls (indeed we want it to change the carousel slide then enable
+ // the slide overlay) + See "CarouselItem" option.
+ dynamicContent = {
+ ".carousel-control-prev, .carousel-control-next, .carousel-indicators": {
+ "t-on-click": this.throttled(this.onControlClick),
+ "t-on-keydown": this.onControlKeyDown,
+ "t-att-class": () => ({ o_we_no_overlay: true }),
+ },
+ };
+ /**
+ * Slides the carousel when clicking on the carousel controls. This handler
+ * allows to put the sliding in the mutex, to avoid race conditions.
+ *
+ * @param {Event} ev
+ */
+ async onControlClick(ev) {
+ // Compute to which slide the carousel will slide.
+ const controlEl = ev.currentTarget;
+ let direction;
+ if (controlEl.classList.contains("carousel-control-prev")) {
+ direction = "prev";
+ } else if (controlEl.classList.contains("carousel-control-next")) {
+ direction = "next";
+ } else {
+ const indicatorEl = ev.target;
+ if (
+ !indicatorEl.matches(".carousel-indicators > *") ||
+ indicatorEl.classList.contains("active")
+ ) {
+ return;
+ }
+ direction = [...controlEl.children].indexOf(indicatorEl);
+ }
+
+ // Slide the carousel
+ const editingCarousel = this.el;
+ const applySpec = { editingElement: editingCarousel, direction: direction };
+
+ if (this.services["website_edit"].applyAction) {
+ this.services["website_edit"].applyAction("slideCarousel", applySpec);
+ }
+ }
+
+ /**
+ * Since carousel controls are disabled in edit mode because slides are
+ * handled manually, we disable the left and right keydown events to prevent
+ * sliding this way.
+ *
+ * @param {Event} ev
+ */
+ onControlKeyDown(ev) {
+ if (["ArrowLeft", "ArrowRight"].includes(ev.code)) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ }
+ }
+}
+
+registry.category("public.interactions.edit").add("html_builder.carousel_edit", {
+ Interaction: CarouselEdit,
+});
diff --git a/addons/html_builder/static/src/plugins/carousel_option.xml b/addons/html_builder/static/src/plugins/carousel_option.xml
new file mode 100644
index 0000000000000..aa8d3099d7171
--- /dev/null
+++ b/addons/html_builder/static/src/plugins/carousel_option.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+ Add Slide
+
+
+
+ Classic
+ Indicators outside
+
+
+
+
+
+
+
+
+
+ Default
+ Boxed
+ Rounded
+ Hidden
+
+
+
+
+
+ Bars
+ Dots
+ Numbers
+ Hidden
+
+
+
+
+
+ Slide
+ Fade
+ None
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/html_builder/static/src/plugins/carousel_option_plugin.js b/addons/html_builder/static/src/plugins/carousel_option_plugin.js
new file mode 100644
index 0000000000000..0d14a9e0e2f50
--- /dev/null
+++ b/addons/html_builder/static/src/plugins/carousel_option_plugin.js
@@ -0,0 +1,163 @@
+import { Plugin } from "@html_editor/plugin";
+import { _t } from "@web/core/l10n/translation";
+import { registry } from "@web/core/registry";
+
+export class CarouselOptionPlugin extends Plugin {
+ static id = "carouselOption";
+ static dependencies = ["clone", "builder-options"];
+
+ resources = {
+ builder_options: [
+ {
+ template: "html_builder.CarouselOption",
+ selector: "section",
+ exclude: ".s_carousel_intro_wrapper, .s_carousel_cards_wrapper",
+ applyTo: ":scope > .carousel",
+ },
+ ],
+ builder_actions: this.getActions(),
+ on_cloned_handlers: this.onCloned.bind(this),
+ on_will_clone_handlers: this.onWillClone.bind(this),
+ on_add_element_handlers: this.onAddElement.bind(this),
+ };
+
+ getActions() {
+ return {
+ addSlide: {
+ load: async ({ editingElement }) => this.addSlide(editingElement),
+ apply: () => {},
+ },
+ slideCarousel: {
+ load: async ({ editingElement, direction: direction }) =>
+ this.slide(direction, editingElement),
+ apply: () => {},
+ },
+ };
+ }
+
+ async addSlide(editingElement) {
+ const activeCarouselItem = editingElement.querySelector(".carousel-item.active");
+ this.dependencies.clone.cloneElement(activeCarouselItem);
+
+ await this.slide("next", editingElement);
+ }
+
+ /**
+ * Slides the carousel in the given direction.
+ *
+ * @param {String|Number} direction the direction in which to slide:
+ * - "prev": the previous slide;
+ * - "next": the next slide;
+ * - number: a slide number.
+ * @param {Element} editingElement the carousel element.
+ * @returns {Promise}
+ */
+ slide(direction, editingElement) {
+ editingElement.addEventListener("slide.bs.carousel", () => {
+ this.slideTimestamp = window.performance.now();
+ });
+
+ return new Promise((resolve) => {
+ editingElement.addEventListener("slid.bs.carousel", () => {
+ // slid.bs.carousel is most of the time fired too soon by bootstrap
+ // since it emulates the transitionEnd with a setTimeout. We wait
+ // here an extra 20% of the time before retargeting edition, which
+ // should be enough...
+ const slideDuration = window.performance.now() - this.slideTimestamp;
+ setTimeout(() => {
+ // Setting the active indicator manually, as Bootstrap could
+ // not do it because the `data-bs-slide-to` attribute is not
+ // here in edit mode anymore.
+ const activeSlide = editingElement.querySelector(".carousel-item.active");
+ const indicatorsEl = editingElement.querySelector(".carousel-indicators");
+ const activeIndex = [...activeSlide.parentElement.children].indexOf(
+ activeSlide
+ );
+ const activeIndicatorEl = [...indicatorsEl.children][activeIndex];
+ activeIndicatorEl.classList.add("active");
+ activeIndicatorEl.setAttribute("aria-current", "true");
+
+ resolve();
+ }, 0.2 * slideDuration);
+
+ this.dependencies["builder-options"].updateContainers(
+ editingElement.querySelector(".carousel-item.active")
+ );
+ });
+
+ const carouselInstance = window.Carousel.getOrCreateInstance(editingElement, {
+ ride: false,
+ pause: true,
+ });
+ if (typeof direction === "number") {
+ carouselInstance.to(direction);
+ } else {
+ carouselInstance[direction]();
+ }
+ });
+ }
+
+ onWillClone({ originalEl }) {
+ if (
+ originalEl.matches(
+ ".s_carousel_wrapper:not(.s_carousel_intro_wrapper, .s_carousel_cards_wrapper) .carousel-item"
+ )
+ ) {
+ const editingCarousel = originalEl.closest(".carousel");
+
+ const indicatorsEl = editingCarousel.querySelector(".carousel-indicators");
+ this.controlEls = editingCarousel.querySelectorAll(
+ ".carousel-control-prev, .carousel-control-next, .carousel-indicators"
+ );
+ this.controlEls.forEach((control) => {
+ control.classList.remove("d-none");
+ });
+
+ const newIndicatorEl = this.document.createElement("button");
+ newIndicatorEl.setAttribute("data-bs-target", "#" + editingCarousel.id);
+ newIndicatorEl.setAttribute("aria-label", _t("Carousel indicator"));
+ indicatorsEl.appendChild(newIndicatorEl);
+ }
+ }
+
+ onCloned({ cloneEl }) {
+ if (
+ cloneEl.matches(
+ ".s_carousel_wrapper:not(.s_carousel_intro_wrapper, .s_carousel_cards_wrapper)"
+ )
+ ) {
+ this.assignUniqueID(cloneEl);
+ }
+ if (
+ cloneEl.matches(
+ ".s_carousel_wrapper:not(.s_carousel_intro_wrapper, .s_carousel_cards_wrapper) .carousel-item"
+ )
+ ) {
+ // Need to remove editor data from the clone so it gets its own.
+ cloneEl.classList.remove("active");
+ }
+ }
+
+ onAddElement({ elementToAdd }) {
+ if (elementToAdd.matches(".s_carousel_wrapper")) {
+ this.assignUniqueID(elementToAdd);
+ }
+ }
+
+ assignUniqueID(editingElement) {
+ const id = "myCarousel" + Date.now();
+ editingElement.querySelector(".s_carousel").setAttribute("id", id);
+ editingElement.querySelectorAll("[data-bs-target]").forEach((el) => {
+ el.setAttribute("data-bs-target", "#" + id);
+ });
+ editingElement.querySelectorAll("[data-bs-slide], [data-bs-slide-to]").forEach((el) => {
+ if (el.hasAttribute("data-bs-target")) {
+ el.setAttribute("data-bs-target", "#" + id);
+ } else if (el.hasAttribute("href")) {
+ el.setAttribute("href", "#" + id);
+ }
+ });
+ }
+}
+
+registry.category("website-plugins").add(CarouselOptionPlugin.id, CarouselOptionPlugin);
diff --git a/addons/html_editor/static/src/core/history_plugin.js b/addons/html_editor/static/src/core/history_plugin.js
index 15552b69b00dd..2ab4ac0292e7e 100644
--- a/addons/html_editor/static/src/core/history_plugin.js
+++ b/addons/html_editor/static/src/core/history_plugin.js
@@ -330,16 +330,16 @@ export class HistoryPlugin extends Plugin {
}
}
- handleObserverRecords() {
- this.handleNewRecords(this.observer.takeRecords());
+ handleObserverRecords(filter = true) {
+ this.handleNewRecords(this.observer.takeRecords(), filter);
}
/**
* @param { MutationRecord[] } records
* @returns { MutationRecord[] } processed records
*/
- processNewRecords(records) {
- records = this.filterMutationRecords(records);
+ processNewRecords(records, filter = true) {
+ records = filter ? this.filterMutationRecords(records) : records;
if (!records.length) {
return [];
}
@@ -364,8 +364,8 @@ export class HistoryPlugin extends Plugin {
/**
* @param { MutationRecord[] } records
*/
- handleNewRecords(records) {
- const filteredRecords = this.processNewRecords(records);
+ handleNewRecords(records, filter = true) {
+ const filteredRecords = this.processNewRecords(records, filter);
if (filteredRecords.length) {
// TODO modify `handleMutations` of web_studio to handle
// `undoOperation`
@@ -662,7 +662,7 @@ export class HistoryPlugin extends Plugin {
if (stepState) {
this.stepsStates.set(currentStep.id, stepState);
}
- this.handleObserverRecords();
+ this.handleObserverRecords(stepState !== "undo");
const currentMutationsCount = currentStep.mutations.length;
if (currentMutationsCount === 0) {
return false;