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;