From 5f8d3cbe4d548f2beaef6c5dd864ea8e689d1ea4 Mon Sep 17 00:00:00 2001 From: fdardenne Date: Wed, 9 Oct 2024 11:28:04 +0200 Subject: [PATCH 001/153] [ADD] create mysterious_egg module --- addons/html_builder/__init__.py | 1 + addons/html_builder/__manifest__.py | 62 ++ .../static/image_shapes/brushed/brush_1.svg | 13 + .../static/image_shapes/brushed/brush_2.svg | 13 + .../static/image_shapes/brushed/brush_3.svg | 13 + .../static/image_shapes/brushed/brush_4.svg | 13 + .../composite/composite_cut_circle.svg | 13 + .../composite/composite_double_pill.svg | 13 + .../composite/composite_half_circle.svg | 13 + .../composite/composite_sonar.svg | 13 + .../composite/composite_triple_pill.svg | 13 + .../composition/composition_line_1.svg | 31 + .../composition/composition_line_2.svg | 40 + .../composition/composition_line_3.svg | 38 + .../composition/composition_mixed_1.svg | 49 ++ .../composition/composition_mixed_2.svg | 63 ++ .../composition/composition_organic_line.svg | 19 + .../composition/composition_oval_line.svg | 20 + .../composition/composition_planet_1.svg | 106 +++ .../composition/composition_planet_2.svg | 106 +++ .../composition/composition_square_1.svg | 61 ++ .../composition/composition_square_2.svg | 50 ++ .../composition/composition_square_3.svg | 57 ++ .../composition/composition_square_4.svg | 69 ++ .../composition/composition_square_line.svg | 32 + .../composition/composition_triangle_line.svg | 58 ++ .../image_shapes/convert-to-percentages.html | 17 + .../image_shapes/convert-to-percentages.js | 159 ++++ .../image_shapes/devices/browser_01.svg | 22 + .../image_shapes/devices/browser_02.svg | 59 ++ .../image_shapes/devices/browser_03.svg | 52 ++ .../devices/galaxy_3d_landscape_01.svg | 178 +++++ .../devices/galaxy_3d_landscape_02.svg | 207 ++++++ .../devices/galaxy_3d_portrait_01.svg | 98 +++ .../devices/galaxy_3d_portrait_02.svg | 130 ++++ .../devices/galaxy_front_landscape.svg | 118 +++ .../devices/galaxy_front_portrait.svg | 118 +++ .../devices/galaxy_front_portrait_half.svg | 119 +++ .../image_shapes/devices/imac_3d_01.svg | 78 ++ .../image_shapes/devices/imac_3d_02.svg | 78 ++ .../image_shapes/devices/imac_front.svg | 87 +++ .../devices/ipad_3d_landscape_01.svg | 294 ++++++++ .../devices/ipad_3d_landscape_02.svg | 278 +++++++ .../devices/ipad_3d_portrait_01.svg | 281 +++++++ .../devices/ipad_3d_portrait_02.svg | 289 ++++++++ .../devices/ipad_front_landscape.svg | 58 ++ .../devices/ipad_front_portrait.svg | 58 ++ .../devices/iphone_3d_landscape_01.svg | 173 +++++ .../devices/iphone_3d_landscape_02.svg | 159 ++++ .../devices/iphone_3d_portrait_01.svg | 186 +++++ .../devices/iphone_3d_portrait_02.svg | 171 +++++ .../devices/iphone_front_landscape.svg | 63 ++ .../devices/iphone_front_portrait.svg | 64 ++ .../image_shapes/devices/macbook_3d_01.svg | 178 +++++ .../image_shapes/devices/macbook_3d_02.svg | 160 ++++ .../image_shapes/devices/macbook_front.svg | 100 +++ .../geometric/geo_cornered_triangle.svg | 12 + .../image_shapes/geometric/geo_diamond.svg | 13 + .../image_shapes/geometric/geo_door.svg | 13 + .../image_shapes/geometric/geo_emerald.svg | 13 + .../static/image_shapes/geometric/geo_gem.svg | 13 + .../image_shapes/geometric/geo_heptagon.svg | 13 + .../image_shapes/geometric/geo_hexagon.svg | 13 + .../image_shapes/geometric/geo_kayak.svg | 13 + .../image_shapes/geometric/geo_pentagon.svg | 13 + .../image_shapes/geometric/geo_shuriken.svg | 13 + .../image_shapes/geometric/geo_slanted.svg | 10 + .../image_shapes/geometric/geo_sonar.svg | 13 + .../image_shapes/geometric/geo_square.svg | 13 + .../image_shapes/geometric/geo_square_1.svg | 21 + .../image_shapes/geometric/geo_square_2.svg | 21 + .../image_shapes/geometric/geo_square_3.svg | 22 + .../image_shapes/geometric/geo_square_4.svg | 21 + .../image_shapes/geometric/geo_square_5.svg | 20 + .../image_shapes/geometric/geo_square_6.svg | 20 + .../image_shapes/geometric/geo_star.svg | 13 + .../image_shapes/geometric/geo_star_16pin.svg | 13 + .../image_shapes/geometric/geo_star_8pin.svg | 13 + .../image_shapes/geometric/geo_tear.svg | 13 + .../image_shapes/geometric/geo_tetris.svg | 13 + .../image_shapes/geometric/geo_triangle.svg | 13 + .../geometric/geo_triangle_corner.svg | 11 + .../geometric_round/geo_round_blob_hard.svg | 23 + .../geometric_round/geo_round_blob_medium.svg | 23 + .../geometric_round/geo_round_blob_soft.svg | 21 + .../geometric_round/geo_round_bread.svg | 13 + .../geometric_round/geo_round_circle.svg | 12 + .../geometric_round/geo_round_clover.svg | 13 + .../geometric_round/geo_round_cornered.svg | 13 + .../geometric_round/geo_round_diamond.svg | 13 + .../geometric_round/geo_round_door.svg | 13 + .../geometric_round/geo_round_emerald.svg | 13 + .../geometric_round/geo_round_gem.svg | 13 + .../geometric_round/geo_round_heptagon.svg | 13 + .../geometric_round/geo_round_hexagon.svg | 13 + .../geometric_round/geo_round_lemon.svg | 13 + .../geometric_round/geo_round_pentagon.svg | 13 + .../geometric_round/geo_round_pill.svg | 13 + .../geometric_round/geo_round_shuriken.svg | 13 + .../geometric_round/geo_round_sonar.svg | 13 + .../geometric_round/geo_round_square.svg | 13 + .../geometric_round/geo_round_square_1.svg | 21 + .../geometric_round/geo_round_square_2.svg | 20 + .../geometric_round/geo_round_star.svg | 13 + .../geometric_round/geo_round_star_16pin.svg | 13 + .../geometric_round/geo_round_star_7pin.svg | 13 + .../geometric_round/geo_round_star_8pin.svg | 13 + .../geometric_round/geo_round_tear.svg | 13 + .../geometric_round/geo_round_triangle.svg | 13 + .../static/image_shapes/panel/panel_duo.svg | 13 + .../static/image_shapes/panel/panel_duo_r.svg | 13 + .../image_shapes/panel/panel_duo_step.svg | 13 + .../panel/panel_duo_step_pill.svg | 13 + .../image_shapes/panel/panel_trio_in_r.svg | 13 + .../image_shapes/panel/panel_trio_out_r.svg | 13 + .../image_shapes/panel/panel_window.svg | 13 + .../image_shapes/pattern/pattern_circuit.svg | 122 +++ .../pattern/pattern_labyrinth.svg | 86 +++ .../pattern/pattern_line_star.svg | 45 ++ .../image_shapes/pattern/pattern_line_sun.svg | 69 ++ .../pattern/pattern_organic_caps.svg | 168 +++++ .../pattern/pattern_organic_cross.svg | 87 +++ .../pattern/pattern_organic_dot.svg | 662 +++++++++++++++++ .../pattern/pattern_oval_zebra.svg | 53 ++ .../image_shapes/pattern/pattern_point.svg | 38 + .../image_shapes/pattern/pattern_wave_1.svg | 31 + .../image_shapes/pattern/pattern_wave_2.svg | 42 ++ .../image_shapes/pattern/pattern_wave_3.svg | 65 ++ .../image_shapes/pattern/pattern_wave_4.svg | 42 ++ .../image_shapes/solid/solid_blob_1.svg | 26 + .../image_shapes/solid/solid_blob_2.svg | 18 + .../image_shapes/solid/solid_blob_3.svg | 20 + .../image_shapes/solid/solid_blob_4.svg | 19 + .../image_shapes/solid/solid_blob_5.svg | 37 + .../solid/solid_blob_shadow_1.svg | 37 + .../solid/solid_blob_shadow_2.svg | 59 ++ .../image_shapes/solid/solid_square_1.svg | 20 + .../image_shapes/solid/solid_square_2.svg | 20 + .../image_shapes/solid/solid_square_3.svg | 37 + .../image_shapes/special/special_filter.svg | 35 + .../image_shapes/special/special_flag.svg | 21 + .../image_shapes/special/special_layered.svg | 40 + .../image_shapes/special/special_organic.svg | 20 + .../image_shapes/special/special_rain.svg | 100 +++ .../image_shapes/special/special_snow.svg | 316 ++++++++ .../image_shapes/special/special_speed.svg | 263 +++++++ .../static/img/options/bg_shape.svg | 11 + .../static/img/options/desktop_invisible.svg | 5 + .../static/img/options/mobile_invisible.svg | 7 + .../static/img/options/size_large.svg | 10 + .../static/img/options/size_medium.svg | 10 + .../static/img/options/size_small.svg | 10 + addons/html_builder/static/img/phone.png | Bin 0 -> 20595 bytes .../static/src/bootstrap_overriden.scss | 96 +++ addons/html_builder/static/src/builder.js | 266 +++++++ addons/html_builder/static/src/builder.scss | 210 ++++++ .../static/src/builder.variables.scss | 684 +++++++++++++++++ addons/html_builder/static/src/builder.xml | 43 ++ .../core/building_blocks/basic_many2many.js | 124 ++++ .../core/building_blocks/basic_many2many.xml | 47 ++ .../core/building_blocks/builder_button.js | 56 ++ .../core/building_blocks/builder_button.xml | 28 + .../building_blocks/builder_button_group.js | 24 + .../building_blocks/builder_button_group.xml | 12 + .../core/building_blocks/builder_checkbox.js | 33 + .../core/building_blocks/builder_checkbox.xml | 12 + .../building_blocks/builder_colorpicker.js | 110 +++ .../building_blocks/builder_colorpicker.xml | 10 + .../core/building_blocks/builder_component.js | 15 + .../core/building_blocks/builder_context.js | 22 + .../building_blocks/builder_datetimepicker.js | 115 +++ .../builder_datetimepicker.xml | 18 + .../core/building_blocks/builder_many2many.js | 89 +++ .../building_blocks/builder_many2many.xml | 17 + .../building_blocks/builder_number_input.js | 51 ++ .../building_blocks/builder_number_input.xml | 25 + .../src/core/building_blocks/builder_range.js | 54 ++ .../core/building_blocks/builder_range.xml | 20 + .../src/core/building_blocks/builder_row.js | 51 ++ .../src/core/building_blocks/builder_row.scss | 35 + .../src/core/building_blocks/builder_row.xml | 26 + .../core/building_blocks/builder_select.js | 50 ++ .../core/building_blocks/builder_select.xml | 20 + .../building_blocks/builder_select_item.js | 42 ++ .../building_blocks/builder_select_item.xml | 27 + .../building_blocks/builder_text_input.js | 38 + .../building_blocks/builder_text_input.xml | 15 + .../builder_text_input_base.js | 36 + .../builder_text_input_base.xml | 22 + .../core/building_blocks/model_many2many.js | 99 +++ .../core/building_blocks/model_many2many.xml | 18 + .../static/src/core/building_blocks/utils.js | 692 ++++++++++++++++++ .../static/src/core/core_plugins.js | 45 ++ .../src/core/default_builder_components.js | 37 + .../src/core/plugins/anchor/anchor_dialog.js | 38 + .../src/core/plugins/anchor/anchor_dialog.xml | 28 + .../src/core/plugins/anchor/anchor_plugin.js | 131 ++++ .../core/plugins/builder_actions_plugin.js | 42 ++ .../core/plugins/builder_options_plugin.js | 171 +++++ .../builder_overlay/builder_overlay.js | 612 ++++++++++++++++ .../builder_overlay/builder_overlay.scss | 252 +++++++ .../builder_overlay/builder_overlay.xml | 50 ++ .../builder_overlay/builder_overlay_plugin.js | 157 ++++ .../src/core/plugins/cached_model_plugin.js | 70 ++ .../src/core/plugins/cached_model_utils.js | 74 ++ .../src/core/plugins/clone/clone_plugin.js | 72 ++ .../src/core/plugins/color_style_plugin.js | 38 + .../plugins/core_builder_action_plugin.js | 203 +++++ .../src/core/plugins/dependency_manager.js | 43 ++ .../drag_and_drop/drag_and_drop_plugin.js | 42 ++ .../core/plugins/drop_zone_plugin.inside.scss | 35 + .../src/core/plugins/drop_zone_plugin.js | 165 +++++ .../core/plugins/dropzone_selector_plugin.js | 72 ++ .../src/core/plugins/editor.inside.scss | 63 ++ .../grid_layout/grid_layout.inside.scss | 77 ++ .../core/plugins/grid_layout/grid_layout.xml | 19 + .../plugins/grid_layout/grid_layout_plugin.js | 69 ++ .../src/core/plugins/media_website_plugin.js | 67 ++ .../src/core/plugins/move/move_plugin.js | 222 ++++++ .../static/src/core/plugins/operation.js | 44 ++ .../src/core/plugins/operation_plugin.js | 26 + .../overlay_buttons/overlay_buttons.js | 12 + .../overlay_buttons/overlay_buttons.scss | 30 + .../overlay_buttons/overlay_buttons.xml | 13 + .../overlay_buttons/overlay_buttons_plugin.js | 160 ++++ .../src/core/plugins/remove/remove_plugin.js | 183 +++++ .../core/plugins/replace/replace_plugin.js | 55 ++ .../static/src/core/plugins/save_plugin.js | 119 +++ .../save_snippet/save_snippet_plugin.js | 54 ++ .../src/core/plugins/setup_editor_plugin.js | 77 ++ .../src/core/plugins/visibility_plugin.js | 83 +++ .../static/src/plugins/accordion_option.js | 21 + .../static/src/plugins/accordion_option.xml | 45 ++ .../static/src/plugins/add_element_option.js | 22 + .../static/src/plugins/add_element_option.xml | 14 + .../static/src/plugins/alert_option.js | 45 ++ .../static/src/plugins/alert_option.xml | 19 + .../static/src/plugins/alignment_option.js | 34 + .../static/src/plugins/alignment_option.xml | 26 + .../static/src/plugins/animate_option.js | 384 ++++++++++ .../static/src/plugins/animate_option.xml | 82 +++ .../background_option/background_image.js | 194 +++++ .../background_option/background_image.xml | 15 + .../background_option/background_option.js | 260 +++++++ .../background_option/background_option.xml | 53 ++ .../background_option/background_position.js | 124 ++++ .../background_option/background_position.xml | 24 + .../background_position_component.js | 183 +++++ .../background_position_component.scss | 35 + .../background_position_component.xml | 23 + .../background_option/background_shape.js | 455 ++++++++++++ .../background_option/background_shape.xml | 41 ++ .../background_shape_component.js | 44 ++ .../background_shape_component.xml | 60 ++ .../static/src/plugins/badge_option.js | 16 + .../static/src/plugins/badge_option.xml | 19 + .../src/plugins/block_alignment_option.js | 17 + .../src/plugins/block_alignment_option.xml | 14 + .../static/src/plugins/blockquote_option.js | 17 + .../static/src/plugins/blockquote_option.xml | 45 ++ .../static/src/plugins/border_configurator.js | 36 + .../src/plugins/border_configurator.xml | 20 + .../static/src/plugins/border_option.js | 17 + .../static/src/plugins/border_option.xml | 9 + .../html_builder/static/src/plugins/button.js | 13 + .../static/src/plugins/button.xml | 14 + .../static/src/plugins/card_option.js | 50 ++ .../static/src/plugins/card_option.xml | 25 + .../src/plugins/content_width_option.js | 18 + .../src/plugins/content_width_option.xml | 14 + .../static/src/plugins/cta_badge_option.js | 15 + .../static/src/plugins/cta_badge_option.xml | 9 + .../static/src/plugins/dot_option.xml | 16 + .../dynamic_snippet_carousel_option.js | 31 + .../dynamic_snippet_carousel_option.xml | 11 + .../src/plugins/dynamic_snippet_option.js | 209 ++++++ .../src/plugins/dynamic_snippet_option.xml | 43 ++ .../static/src/plugins/embed_code_option.js | 79 ++ .../static/src/plugins/embed_code_option.xml | 19 + .../src/plugins/embed_code_option_dialog.js | 32 + .../src/plugins/embed_code_option_dialog.xml | 22 + .../src/plugins/faq_horizontal_option.js | 17 + .../src/plugins/faq_horizontal_option.xml | 16 + .../static/src/plugins/font_awesome_option.js | 30 + .../src/plugins/font_awesome_option.xml | 24 + .../static/src/plugins/image/image_helpers.js | 18 + .../src/plugins/image/image_shape_option.js | 217 ++++++ .../src/plugins/image/image_shape_option.xml | 10 + .../src/plugins/image/image_shape_selector.js | 688 +++++++++++++++++ .../plugins/image/image_shape_selector.xml | 46 ++ .../src/plugins/image/image_tool_option.js | 188 +++++ .../src/plugins/image/image_tool_option.xml | 74 ++ .../src/plugins/image_gallery_option.js | 336 +++++++++ .../src/plugins/image_gallery_option.xml | 17 + .../static/src/plugins/layout_option.js | 87 +++ .../static/src/plugins/layout_option.xml | 40 + .../static/src/plugins/masonry_item_option.js | 18 + .../src/plugins/masonry_item_option.xml | 18 + .../static/src/plugins/media_list_option.js | 45 ++ .../static/src/plugins/media_list_option.xml | 69 ++ .../src/plugins/navtabs_header_buttons.js | 131 ++++ .../src/plugins/navtabs_header_buttons.xml | 20 + .../static/src/plugins/navtabs_style.js | 128 ++++ .../static/src/plugins/navtabs_style.xml | 53 ++ .../pricelist_boxed_option.js | 39 + .../pricelist_boxed_option.xml | 15 + .../pricelist_option/pricelist_cafe.js | 44 ++ .../pricelist_option/pricelist_cafe.xml | 15 + .../pricelist_option/pricelist_option.js | 62 ++ .../pricelist_option/pricelist_option.xml | 17 + .../product_catalog_option.js | 39 + .../product_catalog_option.xml | 15 + .../src/plugins/process_steps_option.js | 264 +++++++ .../src/plugins/process_steps_option.xml | 29 + .../static/src/plugins/progress_bar_option.js | 90 +++ .../src/plugins/progress_bar_option.xml | 27 + .../static/src/plugins/rating_option.js | 152 ++++ .../static/src/plugins/rating_option.xml | 44 ++ .../static/src/plugins/searchbar_option.js | 161 ++++ .../static/src/plugins/searchbar_option.xml | 44 ++ .../src/plugins/section_background_option.xml | 10 + .../static/src/plugins/separator_option.js | 20 + .../static/src/plugins/separator_option.xml | 23 + .../static/src/plugins/shadow_option.js | 109 +++ .../static/src/plugins/shadow_option.xml | 32 + .../static/src/plugins/size_option.js | 16 + .../static/src/plugins/size_option.xml | 14 + .../static/src/plugins/social_media_option.js | 15 + .../src/plugins/social_media_option.xml | 35 + .../static/src/plugins/spacing_option.js | 35 + .../static/src/plugins/spacing_option.xml | 15 + .../src/plugins/table_of_content_option.js | 218 ++++++ .../src/plugins/table_of_content_option.xml | 29 + .../src/plugins/timeline_list_option.js | 29 + .../src/plugins/timeline_list_option.xml | 25 + .../static/src/plugins/timeline_option.js | 65 ++ .../static/src/plugins/timeline_option.xml | 18 + .../html_builder/static/src/plugins/utils.js | 10 + .../static/src/plugins/width_option.js | 16 + .../static/src/plugins/width_option.xml | 15 + .../static/src/sidebar/block_tab.js | 106 +++ .../static/src/sidebar/block_tab.scss | 109 +++ .../static/src/sidebar/block_tab.xml | 43 ++ .../src/sidebar/custom_inner_snippet.js | 27 + .../src/sidebar/custom_inner_snippet.xml | 34 + .../static/src/sidebar/customize_component.js | 15 + .../src/sidebar/customize_component.xml | 8 + .../static/src/sidebar/customize_tab.js | 41 ++ .../static/src/sidebar/customize_tab.xml | 32 + .../sidebar/invisible_elements.inside.scss | 30 + .../src/sidebar/invisible_elements_panel.js | 98 +++ .../src/sidebar/invisible_elements_panel.xml | 32 + .../static/src/sidebar/option_container.js | 85 +++ .../static/src/sidebar/option_container.scss | 29 + .../static/src/sidebar/option_container.xml | 53 ++ .../static/src/snippets/add_snippet_dialog.js | 82 +++ .../src/snippets/add_snippet_dialog.scss | 40 + .../src/snippets/add_snippet_dialog.xml | 41 ++ .../src/snippets/input_confirmation_dialog.js | 23 + .../snippets/input_confirmation_dialog.xml | 20 + .../static/src/snippets/snippet_service.js | 403 ++++++++++ .../static/src/snippets/snippet_viewer.js | 79 ++ .../static/src/snippets/snippet_viewer.scss | 139 ++++ .../static/src/snippets/snippet_viewer.xml | 28 + .../static/src/translate.inside.scss | 11 + .../static/src/utils/column_layout_utils.js | 118 +++ .../static/src/utils/grid_layout_utils.js | 377 ++++++++++ addons/html_builder/static/src/utils/utils.js | 125 ++++ .../static/src/utils/utils_css.js | 579 +++++++++++++++ .../plugins/edit_interaction_plugin.js | 44 ++ .../plugins/visibility_option.js | 262 +++++++ .../plugins/visibility_option.xml | 93 +++ .../plugins/website_session_plugin.js | 13 + .../edit_website_systray_item.js | 83 +++ .../edit_website_systray_item.xml | 35 + .../website_preview/website_builder_action.js | 221 ++++++ .../website_builder_action.scss | 65 ++ .../website_builder_action.xml | 21 + .../website_preview/website_systray_item.js | 28 + .../website_preview/website_systray_item.xml | 11 + .../tests/block_tab/snippet_content.test.js | 99 +++ .../tests/block_tab/snippet_groups.test.js | 398 ++++++++++ .../static/tests/builder_action.test.js | 30 + .../static/tests/builder_overlay.test.js | 169 +++++ .../tests/clean_for_save_options.test.js | 61 ++ .../basic_many2many.test.js | 72 ++ .../builder_components/builder_button.test.js | 634 ++++++++++++++++ .../builder_button_group.test.js | 171 +++++ .../builder_checkbox.test.js | 75 ++ .../builder_colorpicker.test.js | 109 +++ .../builder_context.test.js | 34 + .../builder_datetimepicker.test.js | 113 +++ .../builder_many2many.test.js | 59 ++ .../builder_number_input.test.js | 387 ++++++++++ .../builder_components/builder_range.test.js | 47 ++ .../builder_components/builder_row.test.js | 225 ++++++ .../builder_select_item.test.js | 230 ++++++ .../builder_text_input.test.js | 46 ++ .../model_many2many.test.js | 82 +++ .../builder_shorthand_action.test.js | 79 ++ .../custom_tab/container_buttons.test.js | 329 +++++++++ .../custom_tab/invisibily_options.test.js | 145 ++++ .../static/tests/custom_tab/misc.test.js | 590 +++++++++++++++ .../static/tests/drop_zone.test.js | 30 + .../static/tests/edit_interaction.test.js | 112 +++ .../html_builder/static/tests/editor.test.js | 55 ++ addons/html_builder/static/tests/helpers.js | 174 +++++ .../static/tests/image_shape.test.js | 64 ++ .../html_builder/static/tests/images.test.js | 38 + .../static/tests/invisible_elements.test.js | 120 +++ .../static/tests/operation.test.js | 76 ++ .../tests/options/animate_option.test.js | 315 ++++++++ .../tests/options/background_option.test.js | 202 +++++ .../static/tests/options/card_option.test.js | 51 ++ .../tests/options/image_gallery.test.js | 154 ++++ .../options/pricelist_boxed_option.test.js | 29 + .../tests/options/rating_option.test.js | 73 ++ .../tests/options/searchbar_option.test.js | 91 +++ .../tests/options/separator_options.test.js | 18 + .../tests/options/shadow_option.test.js | 67 ++ .../tests/options/steps_options.test.js | 18 + .../options/table_of_content_option.test.js | 148 ++++ .../tests/options/timeline_option.test.js | 38 + .../static/tests/overlay_buttons.test.js | 297 ++++++++ addons/html_builder/static/tests/save.test.js | 87 +++ .../static/tests/setup_html_builder.test.js | 56 ++ .../static/tests/snippets_getter.hoot.js | 14 + .../static/tests/snippets_menu.test.js | 94 +++ .../static/tests/translation.test.js | 134 ++++ .../static/tests/website_helpers.js | 409 +++++++++++ addons/html_builder/views/views.xml | 15 + addons/html_editor/__manifest__.py | 1 + .../static/lib/webgl-image-filter/LICENSE | 21 + .../webgl-image-filter/webgl-image-filter.js | 650 ++++++++++++++++ .../static/src/core/delete_plugin.js | 15 +- .../static/src/core/history_plugin.js | 43 +- addons/html_editor/static/src/core/overlay.js | 7 +- addons/html_editor/static/src/editor.js | 2 + .../static/src/local_overlay_container.js | 11 +- .../static/src/main/chatgpt/chatgpt_plugin.js | 1 + .../static/src/main/font/color_plugin.js | 32 +- .../src/main/font/font_family_selector.js | 1 - .../static/src/main/font/font_plugin.js | 2 + .../static/src/main/font/font_selector.js | 3 +- .../static/src/main/media/image_crop.js | 119 +-- .../src/main/media/image_crop_plugin.js | 5 +- .../src/main/media/image_description.js | 3 +- .../static/src/main/media/image_plugin.js | 2 + .../src/main/media/image_transform_button.js | 1 + .../main/media/media_dialog/file_selector.js | 6 +- .../static/src/main/media/media_plugin.js | 35 +- .../static/src/main/toolbar/toolbar.js | 1 + .../static/src/main/toolbar/toolbar_plugin.js | 8 +- .../static/src/others/qweb_plugin.js | 7 +- .../html_editor/static/src/utils/dom_info.js | 2 + .../static/src/utils/image_processing.js | 31 +- .../static/tests/_helpers/selection.js | 6 +- .../html_editor/static/tests/toolbar.test.js | 11 + addons/web/static/src/core/assets.js | 8 +- .../src/core/color_picker/color_picker.js | 1 + .../src/core/color_picker/color_picker.xml | 3 +- .../gradient_picker/gradient_picker.js | 1 + .../gradient_picker/gradient_picker.xml | 4 +- addons/web/static/src/core/position/utils.js | 12 +- addons/web/static/src/core/utils/scrolling.js | 5 + .../src/webclient/actions/action_service.js | 4 + .../tests/_framework/module_set.hoot.js | 4 +- .../static/tests/public/interaction.test.js | 6 - addons/web/tests/test_js.py | 5 +- addons/web/tooling/_eslintignore | 4 + addons/web/tooling/_jsconfig.json | 1 + addons/web_editor/models/ir_ui_view.py | 1 + .../static/src/js/editor/snippets.editor.js | 1 + .../static/src/components/navbar/navbar.js | 2 +- .../static/src/core/website_edit_service.js | 68 +- .../static/src/js/content/website_root.js | 3 + .../static/src/services/website_service.js | 9 +- .../src/snippets/s_dynamic_snippet/000.scss | 3 +- .../views/snippets/s_pricelist_cafe.xml | 5 + addons/website_blog/__manifest__.py | 5 +- .../src/plugins/blog_post_tags_option.js | 38 + .../src/plugins/blog_post_tags_option.xml | 18 + .../static/src/plugins/blog_posts_option.js | 57 ++ .../static/src/plugins/blog_posts_option.xml | 43 ++ addons/website_event/__manifest__.py | 4 + .../plugins/dynamic_snippet_events_option.js | 35 + .../plugins/dynamic_snippet_events_option.xml | 24 + addons/website_sale/__manifest__.py | 5 +- .../dynamic_snippet_products_option.js | 61 ++ .../dynamic_snippet_products_option.xml | 35 + 490 files changed, 34927 insertions(+), 176 deletions(-) create mode 100644 addons/html_builder/__init__.py create mode 100644 addons/html_builder/__manifest__.py create mode 100644 addons/html_builder/static/image_shapes/brushed/brush_1.svg create mode 100644 addons/html_builder/static/image_shapes/brushed/brush_2.svg create mode 100644 addons/html_builder/static/image_shapes/brushed/brush_3.svg create mode 100644 addons/html_builder/static/image_shapes/brushed/brush_4.svg create mode 100644 addons/html_builder/static/image_shapes/composite/composite_cut_circle.svg create mode 100644 addons/html_builder/static/image_shapes/composite/composite_double_pill.svg create mode 100644 addons/html_builder/static/image_shapes/composite/composite_half_circle.svg create mode 100644 addons/html_builder/static/image_shapes/composite/composite_sonar.svg create mode 100644 addons/html_builder/static/image_shapes/composite/composite_triple_pill.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_line_1.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_line_2.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_line_3.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_mixed_1.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_mixed_2.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_organic_line.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_oval_line.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_planet_1.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_planet_2.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_square_1.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_square_2.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_square_3.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_square_4.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_square_line.svg create mode 100644 addons/html_builder/static/image_shapes/composition/composition_triangle_line.svg create mode 100644 addons/html_builder/static/image_shapes/convert-to-percentages.html create mode 100644 addons/html_builder/static/image_shapes/convert-to-percentages.js create mode 100644 addons/html_builder/static/image_shapes/devices/browser_01.svg create mode 100644 addons/html_builder/static/image_shapes/devices/browser_02.svg create mode 100644 addons/html_builder/static/image_shapes/devices/browser_03.svg create mode 100644 addons/html_builder/static/image_shapes/devices/galaxy_3d_landscape_01.svg create mode 100644 addons/html_builder/static/image_shapes/devices/galaxy_3d_landscape_02.svg create mode 100644 addons/html_builder/static/image_shapes/devices/galaxy_3d_portrait_01.svg create mode 100644 addons/html_builder/static/image_shapes/devices/galaxy_3d_portrait_02.svg create mode 100644 addons/html_builder/static/image_shapes/devices/galaxy_front_landscape.svg create mode 100644 addons/html_builder/static/image_shapes/devices/galaxy_front_portrait.svg create mode 100644 addons/html_builder/static/image_shapes/devices/galaxy_front_portrait_half.svg create mode 100644 addons/html_builder/static/image_shapes/devices/imac_3d_01.svg create mode 100644 addons/html_builder/static/image_shapes/devices/imac_3d_02.svg create mode 100644 addons/html_builder/static/image_shapes/devices/imac_front.svg create mode 100644 addons/html_builder/static/image_shapes/devices/ipad_3d_landscape_01.svg create mode 100644 addons/html_builder/static/image_shapes/devices/ipad_3d_landscape_02.svg create mode 100644 addons/html_builder/static/image_shapes/devices/ipad_3d_portrait_01.svg create mode 100644 addons/html_builder/static/image_shapes/devices/ipad_3d_portrait_02.svg create mode 100644 addons/html_builder/static/image_shapes/devices/ipad_front_landscape.svg create mode 100644 addons/html_builder/static/image_shapes/devices/ipad_front_portrait.svg create mode 100644 addons/html_builder/static/image_shapes/devices/iphone_3d_landscape_01.svg create mode 100644 addons/html_builder/static/image_shapes/devices/iphone_3d_landscape_02.svg create mode 100644 addons/html_builder/static/image_shapes/devices/iphone_3d_portrait_01.svg create mode 100644 addons/html_builder/static/image_shapes/devices/iphone_3d_portrait_02.svg create mode 100644 addons/html_builder/static/image_shapes/devices/iphone_front_landscape.svg create mode 100644 addons/html_builder/static/image_shapes/devices/iphone_front_portrait.svg create mode 100644 addons/html_builder/static/image_shapes/devices/macbook_3d_01.svg create mode 100644 addons/html_builder/static/image_shapes/devices/macbook_3d_02.svg create mode 100644 addons/html_builder/static/image_shapes/devices/macbook_front.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_cornered_triangle.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_diamond.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_door.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_emerald.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_gem.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_heptagon.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_hexagon.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_kayak.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_pentagon.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_shuriken.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_slanted.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_sonar.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_square.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_square_1.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_square_2.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_square_3.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_square_4.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_square_5.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_square_6.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_star.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_star_16pin.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_star_8pin.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_tear.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_tetris.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_triangle.svg create mode 100644 addons/html_builder/static/image_shapes/geometric/geo_triangle_corner.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_blob_hard.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_blob_medium.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_blob_soft.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_bread.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_circle.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_clover.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_cornered.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_diamond.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_door.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_emerald.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_gem.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_heptagon.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_hexagon.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_lemon.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_pentagon.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_pill.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_shuriken.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_sonar.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_square.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_square_1.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_square_2.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_star.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_star_16pin.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_star_7pin.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_star_8pin.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_tear.svg create mode 100644 addons/html_builder/static/image_shapes/geometric_round/geo_round_triangle.svg create mode 100644 addons/html_builder/static/image_shapes/panel/panel_duo.svg create mode 100644 addons/html_builder/static/image_shapes/panel/panel_duo_r.svg create mode 100644 addons/html_builder/static/image_shapes/panel/panel_duo_step.svg create mode 100644 addons/html_builder/static/image_shapes/panel/panel_duo_step_pill.svg create mode 100644 addons/html_builder/static/image_shapes/panel/panel_trio_in_r.svg create mode 100644 addons/html_builder/static/image_shapes/panel/panel_trio_out_r.svg create mode 100644 addons/html_builder/static/image_shapes/panel/panel_window.svg create mode 100644 addons/html_builder/static/image_shapes/pattern/pattern_circuit.svg create mode 100644 addons/html_builder/static/image_shapes/pattern/pattern_labyrinth.svg create mode 100644 addons/html_builder/static/image_shapes/pattern/pattern_line_star.svg create mode 100644 addons/html_builder/static/image_shapes/pattern/pattern_line_sun.svg create mode 100644 addons/html_builder/static/image_shapes/pattern/pattern_organic_caps.svg create mode 100644 addons/html_builder/static/image_shapes/pattern/pattern_organic_cross.svg create mode 100644 addons/html_builder/static/image_shapes/pattern/pattern_organic_dot.svg create mode 100644 addons/html_builder/static/image_shapes/pattern/pattern_oval_zebra.svg create mode 100644 addons/html_builder/static/image_shapes/pattern/pattern_point.svg create mode 100644 addons/html_builder/static/image_shapes/pattern/pattern_wave_1.svg create mode 100644 addons/html_builder/static/image_shapes/pattern/pattern_wave_2.svg create mode 100644 addons/html_builder/static/image_shapes/pattern/pattern_wave_3.svg create mode 100644 addons/html_builder/static/image_shapes/pattern/pattern_wave_4.svg create mode 100644 addons/html_builder/static/image_shapes/solid/solid_blob_1.svg create mode 100644 addons/html_builder/static/image_shapes/solid/solid_blob_2.svg create mode 100644 addons/html_builder/static/image_shapes/solid/solid_blob_3.svg create mode 100644 addons/html_builder/static/image_shapes/solid/solid_blob_4.svg create mode 100644 addons/html_builder/static/image_shapes/solid/solid_blob_5.svg create mode 100644 addons/html_builder/static/image_shapes/solid/solid_blob_shadow_1.svg create mode 100644 addons/html_builder/static/image_shapes/solid/solid_blob_shadow_2.svg create mode 100644 addons/html_builder/static/image_shapes/solid/solid_square_1.svg create mode 100644 addons/html_builder/static/image_shapes/solid/solid_square_2.svg create mode 100644 addons/html_builder/static/image_shapes/solid/solid_square_3.svg create mode 100644 addons/html_builder/static/image_shapes/special/special_filter.svg create mode 100644 addons/html_builder/static/image_shapes/special/special_flag.svg create mode 100644 addons/html_builder/static/image_shapes/special/special_layered.svg create mode 100644 addons/html_builder/static/image_shapes/special/special_organic.svg create mode 100644 addons/html_builder/static/image_shapes/special/special_rain.svg create mode 100644 addons/html_builder/static/image_shapes/special/special_snow.svg create mode 100644 addons/html_builder/static/image_shapes/special/special_speed.svg create mode 100644 addons/html_builder/static/img/options/bg_shape.svg create mode 100644 addons/html_builder/static/img/options/desktop_invisible.svg create mode 100644 addons/html_builder/static/img/options/mobile_invisible.svg create mode 100644 addons/html_builder/static/img/options/size_large.svg create mode 100644 addons/html_builder/static/img/options/size_medium.svg create mode 100644 addons/html_builder/static/img/options/size_small.svg create mode 100644 addons/html_builder/static/img/phone.png create mode 100644 addons/html_builder/static/src/bootstrap_overriden.scss create mode 100644 addons/html_builder/static/src/builder.js create mode 100644 addons/html_builder/static/src/builder.scss create mode 100644 addons/html_builder/static/src/builder.variables.scss create mode 100644 addons/html_builder/static/src/builder.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/basic_many2many.js create mode 100644 addons/html_builder/static/src/core/building_blocks/basic_many2many.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_button.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_button.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_button_group.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_button_group.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_checkbox.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_checkbox.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_colorpicker.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_colorpicker.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_component.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_context.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_datetimepicker.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_datetimepicker.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_many2many.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_many2many.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_number_input.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_number_input.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_range.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_range.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_row.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_row.scss create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_row.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_select.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_select.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_select_item.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_select_item.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_text_input.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_text_input.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_text_input_base.js create mode 100644 addons/html_builder/static/src/core/building_blocks/builder_text_input_base.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/model_many2many.js create mode 100644 addons/html_builder/static/src/core/building_blocks/model_many2many.xml create mode 100644 addons/html_builder/static/src/core/building_blocks/utils.js create mode 100644 addons/html_builder/static/src/core/core_plugins.js create mode 100644 addons/html_builder/static/src/core/default_builder_components.js create mode 100644 addons/html_builder/static/src/core/plugins/anchor/anchor_dialog.js create mode 100644 addons/html_builder/static/src/core/plugins/anchor/anchor_dialog.xml create mode 100644 addons/html_builder/static/src/core/plugins/anchor/anchor_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/builder_actions_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/builder_options_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay.js create mode 100644 addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay.scss create mode 100644 addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay.xml create mode 100644 addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/cached_model_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/cached_model_utils.js create mode 100644 addons/html_builder/static/src/core/plugins/clone/clone_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/color_style_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/core_builder_action_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/dependency_manager.js create mode 100644 addons/html_builder/static/src/core/plugins/drag_and_drop/drag_and_drop_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/drop_zone_plugin.inside.scss create mode 100644 addons/html_builder/static/src/core/plugins/drop_zone_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/dropzone_selector_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/editor.inside.scss create mode 100644 addons/html_builder/static/src/core/plugins/grid_layout/grid_layout.inside.scss create mode 100644 addons/html_builder/static/src/core/plugins/grid_layout/grid_layout.xml create mode 100644 addons/html_builder/static/src/core/plugins/grid_layout/grid_layout_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/media_website_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/move/move_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/operation.js create mode 100644 addons/html_builder/static/src/core/plugins/operation_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons.js create mode 100644 addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons.scss create mode 100644 addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons.xml create mode 100644 addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/remove/remove_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/replace/replace_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/save_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/save_snippet/save_snippet_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/setup_editor_plugin.js create mode 100644 addons/html_builder/static/src/core/plugins/visibility_plugin.js create mode 100644 addons/html_builder/static/src/plugins/accordion_option.js create mode 100644 addons/html_builder/static/src/plugins/accordion_option.xml create mode 100644 addons/html_builder/static/src/plugins/add_element_option.js create mode 100644 addons/html_builder/static/src/plugins/add_element_option.xml create mode 100644 addons/html_builder/static/src/plugins/alert_option.js create mode 100644 addons/html_builder/static/src/plugins/alert_option.xml create mode 100644 addons/html_builder/static/src/plugins/alignment_option.js create mode 100644 addons/html_builder/static/src/plugins/alignment_option.xml create mode 100644 addons/html_builder/static/src/plugins/animate_option.js create mode 100644 addons/html_builder/static/src/plugins/animate_option.xml create mode 100644 addons/html_builder/static/src/plugins/background_option/background_image.js create mode 100644 addons/html_builder/static/src/plugins/background_option/background_image.xml create mode 100644 addons/html_builder/static/src/plugins/background_option/background_option.js create mode 100644 addons/html_builder/static/src/plugins/background_option/background_option.xml create mode 100644 addons/html_builder/static/src/plugins/background_option/background_position.js create mode 100644 addons/html_builder/static/src/plugins/background_option/background_position.xml create mode 100644 addons/html_builder/static/src/plugins/background_option/background_position_component.js create mode 100644 addons/html_builder/static/src/plugins/background_option/background_position_component.scss create mode 100644 addons/html_builder/static/src/plugins/background_option/background_position_component.xml create mode 100644 addons/html_builder/static/src/plugins/background_option/background_shape.js create mode 100644 addons/html_builder/static/src/plugins/background_option/background_shape.xml create mode 100644 addons/html_builder/static/src/plugins/background_option/background_shape_component.js create mode 100644 addons/html_builder/static/src/plugins/background_option/background_shape_component.xml create mode 100644 addons/html_builder/static/src/plugins/badge_option.js create mode 100644 addons/html_builder/static/src/plugins/badge_option.xml create mode 100644 addons/html_builder/static/src/plugins/block_alignment_option.js create mode 100644 addons/html_builder/static/src/plugins/block_alignment_option.xml create mode 100644 addons/html_builder/static/src/plugins/blockquote_option.js create mode 100644 addons/html_builder/static/src/plugins/blockquote_option.xml create mode 100644 addons/html_builder/static/src/plugins/border_configurator.js create mode 100644 addons/html_builder/static/src/plugins/border_configurator.xml create mode 100644 addons/html_builder/static/src/plugins/border_option.js create mode 100644 addons/html_builder/static/src/plugins/border_option.xml create mode 100644 addons/html_builder/static/src/plugins/button.js create mode 100644 addons/html_builder/static/src/plugins/button.xml create mode 100644 addons/html_builder/static/src/plugins/card_option.js create mode 100644 addons/html_builder/static/src/plugins/card_option.xml create mode 100644 addons/html_builder/static/src/plugins/content_width_option.js create mode 100644 addons/html_builder/static/src/plugins/content_width_option.xml create mode 100644 addons/html_builder/static/src/plugins/cta_badge_option.js create mode 100644 addons/html_builder/static/src/plugins/cta_badge_option.xml create mode 100644 addons/html_builder/static/src/plugins/dot_option.xml create mode 100644 addons/html_builder/static/src/plugins/dynamic_snippet_carousel_option.js create mode 100644 addons/html_builder/static/src/plugins/dynamic_snippet_carousel_option.xml create mode 100644 addons/html_builder/static/src/plugins/dynamic_snippet_option.js create mode 100644 addons/html_builder/static/src/plugins/dynamic_snippet_option.xml create mode 100644 addons/html_builder/static/src/plugins/embed_code_option.js create mode 100644 addons/html_builder/static/src/plugins/embed_code_option.xml create mode 100644 addons/html_builder/static/src/plugins/embed_code_option_dialog.js create mode 100644 addons/html_builder/static/src/plugins/embed_code_option_dialog.xml create mode 100644 addons/html_builder/static/src/plugins/faq_horizontal_option.js create mode 100644 addons/html_builder/static/src/plugins/faq_horizontal_option.xml create mode 100644 addons/html_builder/static/src/plugins/font_awesome_option.js create mode 100644 addons/html_builder/static/src/plugins/font_awesome_option.xml create mode 100644 addons/html_builder/static/src/plugins/image/image_helpers.js create mode 100644 addons/html_builder/static/src/plugins/image/image_shape_option.js create mode 100644 addons/html_builder/static/src/plugins/image/image_shape_option.xml create mode 100644 addons/html_builder/static/src/plugins/image/image_shape_selector.js create mode 100644 addons/html_builder/static/src/plugins/image/image_shape_selector.xml create mode 100644 addons/html_builder/static/src/plugins/image/image_tool_option.js create mode 100644 addons/html_builder/static/src/plugins/image/image_tool_option.xml create mode 100644 addons/html_builder/static/src/plugins/image_gallery_option.js create mode 100644 addons/html_builder/static/src/plugins/image_gallery_option.xml create mode 100644 addons/html_builder/static/src/plugins/layout_option.js create mode 100644 addons/html_builder/static/src/plugins/layout_option.xml create mode 100644 addons/html_builder/static/src/plugins/masonry_item_option.js create mode 100644 addons/html_builder/static/src/plugins/masonry_item_option.xml create mode 100644 addons/html_builder/static/src/plugins/media_list_option.js create mode 100644 addons/html_builder/static/src/plugins/media_list_option.xml create mode 100644 addons/html_builder/static/src/plugins/navtabs_header_buttons.js create mode 100644 addons/html_builder/static/src/plugins/navtabs_header_buttons.xml create mode 100644 addons/html_builder/static/src/plugins/navtabs_style.js create mode 100644 addons/html_builder/static/src/plugins/navtabs_style.xml create mode 100644 addons/html_builder/static/src/plugins/pricelist_option/pricelist_boxed_option.js create mode 100644 addons/html_builder/static/src/plugins/pricelist_option/pricelist_boxed_option.xml create mode 100644 addons/html_builder/static/src/plugins/pricelist_option/pricelist_cafe.js create mode 100644 addons/html_builder/static/src/plugins/pricelist_option/pricelist_cafe.xml create mode 100644 addons/html_builder/static/src/plugins/pricelist_option/pricelist_option.js create mode 100644 addons/html_builder/static/src/plugins/pricelist_option/pricelist_option.xml create mode 100644 addons/html_builder/static/src/plugins/pricelist_option/product_catalog_option.js create mode 100644 addons/html_builder/static/src/plugins/pricelist_option/product_catalog_option.xml create mode 100644 addons/html_builder/static/src/plugins/process_steps_option.js create mode 100644 addons/html_builder/static/src/plugins/process_steps_option.xml create mode 100644 addons/html_builder/static/src/plugins/progress_bar_option.js create mode 100644 addons/html_builder/static/src/plugins/progress_bar_option.xml create mode 100644 addons/html_builder/static/src/plugins/rating_option.js create mode 100644 addons/html_builder/static/src/plugins/rating_option.xml create mode 100644 addons/html_builder/static/src/plugins/searchbar_option.js create mode 100644 addons/html_builder/static/src/plugins/searchbar_option.xml create mode 100644 addons/html_builder/static/src/plugins/section_background_option.xml create mode 100644 addons/html_builder/static/src/plugins/separator_option.js create mode 100644 addons/html_builder/static/src/plugins/separator_option.xml create mode 100644 addons/html_builder/static/src/plugins/shadow_option.js create mode 100644 addons/html_builder/static/src/plugins/shadow_option.xml create mode 100644 addons/html_builder/static/src/plugins/size_option.js create mode 100644 addons/html_builder/static/src/plugins/size_option.xml create mode 100644 addons/html_builder/static/src/plugins/social_media_option.js create mode 100644 addons/html_builder/static/src/plugins/social_media_option.xml create mode 100644 addons/html_builder/static/src/plugins/spacing_option.js create mode 100644 addons/html_builder/static/src/plugins/spacing_option.xml create mode 100644 addons/html_builder/static/src/plugins/table_of_content_option.js create mode 100644 addons/html_builder/static/src/plugins/table_of_content_option.xml create mode 100644 addons/html_builder/static/src/plugins/timeline_list_option.js create mode 100644 addons/html_builder/static/src/plugins/timeline_list_option.xml create mode 100644 addons/html_builder/static/src/plugins/timeline_option.js create mode 100644 addons/html_builder/static/src/plugins/timeline_option.xml create mode 100644 addons/html_builder/static/src/plugins/utils.js create mode 100644 addons/html_builder/static/src/plugins/width_option.js create mode 100644 addons/html_builder/static/src/plugins/width_option.xml create mode 100644 addons/html_builder/static/src/sidebar/block_tab.js create mode 100644 addons/html_builder/static/src/sidebar/block_tab.scss create mode 100644 addons/html_builder/static/src/sidebar/block_tab.xml create mode 100644 addons/html_builder/static/src/sidebar/custom_inner_snippet.js create mode 100644 addons/html_builder/static/src/sidebar/custom_inner_snippet.xml create mode 100644 addons/html_builder/static/src/sidebar/customize_component.js create mode 100644 addons/html_builder/static/src/sidebar/customize_component.xml create mode 100644 addons/html_builder/static/src/sidebar/customize_tab.js create mode 100644 addons/html_builder/static/src/sidebar/customize_tab.xml create mode 100644 addons/html_builder/static/src/sidebar/invisible_elements.inside.scss create mode 100644 addons/html_builder/static/src/sidebar/invisible_elements_panel.js create mode 100644 addons/html_builder/static/src/sidebar/invisible_elements_panel.xml create mode 100644 addons/html_builder/static/src/sidebar/option_container.js create mode 100644 addons/html_builder/static/src/sidebar/option_container.scss create mode 100644 addons/html_builder/static/src/sidebar/option_container.xml create mode 100644 addons/html_builder/static/src/snippets/add_snippet_dialog.js create mode 100644 addons/html_builder/static/src/snippets/add_snippet_dialog.scss create mode 100644 addons/html_builder/static/src/snippets/add_snippet_dialog.xml create mode 100644 addons/html_builder/static/src/snippets/input_confirmation_dialog.js create mode 100644 addons/html_builder/static/src/snippets/input_confirmation_dialog.xml create mode 100644 addons/html_builder/static/src/snippets/snippet_service.js create mode 100644 addons/html_builder/static/src/snippets/snippet_viewer.js create mode 100644 addons/html_builder/static/src/snippets/snippet_viewer.scss create mode 100644 addons/html_builder/static/src/snippets/snippet_viewer.xml create mode 100644 addons/html_builder/static/src/translate.inside.scss create mode 100644 addons/html_builder/static/src/utils/column_layout_utils.js create mode 100644 addons/html_builder/static/src/utils/grid_layout_utils.js create mode 100644 addons/html_builder/static/src/utils/utils.js create mode 100644 addons/html_builder/static/src/utils/utils_css.js create mode 100644 addons/html_builder/static/src/website_builder/plugins/edit_interaction_plugin.js create mode 100644 addons/html_builder/static/src/website_builder/plugins/visibility_option.js create mode 100644 addons/html_builder/static/src/website_builder/plugins/visibility_option.xml create mode 100644 addons/html_builder/static/src/website_builder/plugins/website_session_plugin.js create mode 100644 addons/html_builder/static/src/website_preview/edit_website_systray_item.js create mode 100644 addons/html_builder/static/src/website_preview/edit_website_systray_item.xml create mode 100644 addons/html_builder/static/src/website_preview/website_builder_action.js create mode 100644 addons/html_builder/static/src/website_preview/website_builder_action.scss create mode 100644 addons/html_builder/static/src/website_preview/website_builder_action.xml create mode 100644 addons/html_builder/static/src/website_preview/website_systray_item.js create mode 100644 addons/html_builder/static/src/website_preview/website_systray_item.xml create mode 100644 addons/html_builder/static/tests/block_tab/snippet_content.test.js create mode 100644 addons/html_builder/static/tests/block_tab/snippet_groups.test.js create mode 100644 addons/html_builder/static/tests/builder_action.test.js create mode 100644 addons/html_builder/static/tests/builder_overlay.test.js create mode 100644 addons/html_builder/static/tests/clean_for_save_options.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_components/basic_many2many.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_components/builder_button.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_components/builder_button_group.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_components/builder_checkbox.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_components/builder_colorpicker.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_components/builder_context.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_components/builder_datetimepicker.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_components/builder_many2many.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_components/builder_number_input.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_components/builder_range.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_components/builder_row.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_components/builder_select_item.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_components/builder_text_input.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_components/model_many2many.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/builder_shorthand_action.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/container_buttons.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/invisibily_options.test.js create mode 100644 addons/html_builder/static/tests/custom_tab/misc.test.js create mode 100644 addons/html_builder/static/tests/drop_zone.test.js create mode 100644 addons/html_builder/static/tests/edit_interaction.test.js create mode 100644 addons/html_builder/static/tests/editor.test.js create mode 100644 addons/html_builder/static/tests/helpers.js create mode 100644 addons/html_builder/static/tests/image_shape.test.js create mode 100644 addons/html_builder/static/tests/images.test.js create mode 100644 addons/html_builder/static/tests/invisible_elements.test.js create mode 100644 addons/html_builder/static/tests/operation.test.js create mode 100644 addons/html_builder/static/tests/options/animate_option.test.js create mode 100644 addons/html_builder/static/tests/options/background_option.test.js create mode 100644 addons/html_builder/static/tests/options/card_option.test.js create mode 100644 addons/html_builder/static/tests/options/image_gallery.test.js create mode 100644 addons/html_builder/static/tests/options/pricelist_boxed_option.test.js create mode 100644 addons/html_builder/static/tests/options/rating_option.test.js create mode 100644 addons/html_builder/static/tests/options/searchbar_option.test.js create mode 100644 addons/html_builder/static/tests/options/separator_options.test.js create mode 100644 addons/html_builder/static/tests/options/shadow_option.test.js create mode 100644 addons/html_builder/static/tests/options/steps_options.test.js create mode 100644 addons/html_builder/static/tests/options/table_of_content_option.test.js create mode 100644 addons/html_builder/static/tests/options/timeline_option.test.js create mode 100644 addons/html_builder/static/tests/overlay_buttons.test.js create mode 100644 addons/html_builder/static/tests/save.test.js create mode 100644 addons/html_builder/static/tests/setup_html_builder.test.js create mode 100644 addons/html_builder/static/tests/snippets_getter.hoot.js create mode 100644 addons/html_builder/static/tests/snippets_menu.test.js create mode 100644 addons/html_builder/static/tests/translation.test.js create mode 100644 addons/html_builder/static/tests/website_helpers.js create mode 100644 addons/html_builder/views/views.xml create mode 100644 addons/html_editor/static/lib/webgl-image-filter/LICENSE create mode 100644 addons/html_editor/static/lib/webgl-image-filter/webgl-image-filter.js create mode 100644 addons/website_blog/static/src/plugins/blog_post_tags_option.js create mode 100644 addons/website_blog/static/src/plugins/blog_post_tags_option.xml create mode 100644 addons/website_blog/static/src/plugins/blog_posts_option.js create mode 100644 addons/website_blog/static/src/plugins/blog_posts_option.xml create mode 100644 addons/website_event/static/src/plugins/dynamic_snippet_events_option.js create mode 100644 addons/website_event/static/src/plugins/dynamic_snippet_events_option.xml create mode 100644 addons/website_sale/static/src/plugins/dynamic_snippet_products_option.js create mode 100644 addons/website_sale/static/src/plugins/dynamic_snippet_products_option.xml diff --git a/addons/html_builder/__init__.py b/addons/html_builder/__init__.py new file mode 100644 index 0000000000000..40a96afc6ff09 --- /dev/null +++ b/addons/html_builder/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/addons/html_builder/__manifest__.py b/addons/html_builder/__manifest__.py new file mode 100644 index 0000000000000..a9d34df216f35 --- /dev/null +++ b/addons/html_builder/__manifest__.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +{ + 'name': "HTML Builder", + 'summary': "Generic html builder", + 'description': """ + This addon contains a generic html builder application. It is designed to be + used by the website builder and mass mailing editor. + """, + + 'author': "Odoo", + 'website': "https://www.odoo.com", + + # Categories can be used to filter modules in modules listing + # Check https://github.com/odoo/odoo/blob/15.0/odoo/addons/base/data/ir_module_category_data.xml + # for the full list + 'category': 'Uncategorized', + 'version': '0.1', + 'auto_install': True, + + # any module necessary for this one to work correctly + 'depends': ['base', 'html_editor', 'website'], + + # always loaded + 'data': [ + # 'security/ir.model.access.csv', + 'views/views.xml', + ], + + 'assets': { + 'web.assets_backend': [ + 'html_builder/static/src/website_preview/**/*', + ], + # this bundle is lazy loaded when the editor is ready + 'html_builder.assets': [ + ('include', 'web._assets_helpers'), + + 'html_builder/static/src/bootstrap_overriden.scss', + 'web/static/src/scss/pre_variables.scss', + 'web/static/lib/bootstrap/scss/_variables.scss', + 'web/static/lib/bootstrap/scss/_variables-dark.scss', + 'web/static/lib/bootstrap/scss/_maps.scss', + + 'html_builder/static/src/**/*', + ('remove', 'html_builder/static/src/website_preview/**/*'), + ], + 'html_builder.inside_builder_style': [ + ('include', 'web._assets_helpers'), + ('include', 'web._assets_primary_variables'), + 'web/static/src/scss/bootstrap_overridden.scss', + 'html_builder/static/src/**/*.inside.scss', + ], + 'html_builder.iframe_add_dialog': [ + ('include', 'web.assets_frontend'), + 'html_builder/static/src/snippets/snippet_viewer.scss' + ], + 'web.assets_unit_tests': [ + 'html_builder/static/tests/**/*', + ('include', 'html_builder.assets'), + ], + }, + 'license': 'LGPL-3', +} diff --git a/addons/html_builder/static/image_shapes/brushed/brush_1.svg b/addons/html_builder/static/image_shapes/brushed/brush_1.svg new file mode 100644 index 0000000000000..e678941e21b0d --- /dev/null +++ b/addons/html_builder/static/image_shapes/brushed/brush_1.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/brushed/brush_2.svg b/addons/html_builder/static/image_shapes/brushed/brush_2.svg new file mode 100644 index 0000000000000..bd3c076dfabd6 --- /dev/null +++ b/addons/html_builder/static/image_shapes/brushed/brush_2.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/brushed/brush_3.svg b/addons/html_builder/static/image_shapes/brushed/brush_3.svg new file mode 100644 index 0000000000000..25afa96887c3b --- /dev/null +++ b/addons/html_builder/static/image_shapes/brushed/brush_3.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/brushed/brush_4.svg b/addons/html_builder/static/image_shapes/brushed/brush_4.svg new file mode 100644 index 0000000000000..40276420a66ae --- /dev/null +++ b/addons/html_builder/static/image_shapes/brushed/brush_4.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composite/composite_cut_circle.svg b/addons/html_builder/static/image_shapes/composite/composite_cut_circle.svg new file mode 100644 index 0000000000000..217e9d89475f3 --- /dev/null +++ b/addons/html_builder/static/image_shapes/composite/composite_cut_circle.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composite/composite_double_pill.svg b/addons/html_builder/static/image_shapes/composite/composite_double_pill.svg new file mode 100644 index 0000000000000..2552cbab95ccd --- /dev/null +++ b/addons/html_builder/static/image_shapes/composite/composite_double_pill.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composite/composite_half_circle.svg b/addons/html_builder/static/image_shapes/composite/composite_half_circle.svg new file mode 100644 index 0000000000000..66cf7e842dc5a --- /dev/null +++ b/addons/html_builder/static/image_shapes/composite/composite_half_circle.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composite/composite_sonar.svg b/addons/html_builder/static/image_shapes/composite/composite_sonar.svg new file mode 100644 index 0000000000000..9a0cafc392900 --- /dev/null +++ b/addons/html_builder/static/image_shapes/composite/composite_sonar.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composite/composite_triple_pill.svg b/addons/html_builder/static/image_shapes/composite/composite_triple_pill.svg new file mode 100644 index 0000000000000..5af22bbf8ddec --- /dev/null +++ b/addons/html_builder/static/image_shapes/composite/composite_triple_pill.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_line_1.svg b/addons/html_builder/static/image_shapes/composition/composition_line_1.svg new file mode 100644 index 0000000000000..80f30dfeb7746 --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_line_1.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_line_2.svg b/addons/html_builder/static/image_shapes/composition/composition_line_2.svg new file mode 100644 index 0000000000000..96354a04bb619 --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_line_2.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_line_3.svg b/addons/html_builder/static/image_shapes/composition/composition_line_3.svg new file mode 100644 index 0000000000000..51eedb256b90f --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_line_3.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_mixed_1.svg b/addons/html_builder/static/image_shapes/composition/composition_mixed_1.svg new file mode 100644 index 0000000000000..7556a4fb3ec4d --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_mixed_1.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_mixed_2.svg b/addons/html_builder/static/image_shapes/composition/composition_mixed_2.svg new file mode 100644 index 0000000000000..f0cc8cdff9382 --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_mixed_2.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_organic_line.svg b/addons/html_builder/static/image_shapes/composition/composition_organic_line.svg new file mode 100644 index 0000000000000..25e1115da4efb --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_organic_line.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_oval_line.svg b/addons/html_builder/static/image_shapes/composition/composition_oval_line.svg new file mode 100644 index 0000000000000..c96baf591a47b --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_oval_line.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_planet_1.svg b/addons/html_builder/static/image_shapes/composition/composition_planet_1.svg new file mode 100644 index 0000000000000..ab8eae8a9ece2 --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_planet_1.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_planet_2.svg b/addons/html_builder/static/image_shapes/composition/composition_planet_2.svg new file mode 100644 index 0000000000000..afd938782d1a7 --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_planet_2.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_square_1.svg b/addons/html_builder/static/image_shapes/composition/composition_square_1.svg new file mode 100644 index 0000000000000..105162da79f8a --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_square_1.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_square_2.svg b/addons/html_builder/static/image_shapes/composition/composition_square_2.svg new file mode 100644 index 0000000000000..aa679f2067b5f --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_square_2.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_square_3.svg b/addons/html_builder/static/image_shapes/composition/composition_square_3.svg new file mode 100644 index 0000000000000..358c75ab6729a --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_square_3.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_square_4.svg b/addons/html_builder/static/image_shapes/composition/composition_square_4.svg new file mode 100644 index 0000000000000..2a5758f9ead9c --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_square_4.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_square_line.svg b/addons/html_builder/static/image_shapes/composition/composition_square_line.svg new file mode 100644 index 0000000000000..b27aeda7560eb --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_square_line.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/composition/composition_triangle_line.svg b/addons/html_builder/static/image_shapes/composition/composition_triangle_line.svg new file mode 100644 index 0000000000000..e0c5283475da0 --- /dev/null +++ b/addons/html_builder/static/image_shapes/composition/composition_triangle_line.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/convert-to-percentages.html b/addons/html_builder/static/image_shapes/convert-to-percentages.html new file mode 100644 index 0000000000000..cf93a00616bd6 --- /dev/null +++ b/addons/html_builder/static/image_shapes/convert-to-percentages.html @@ -0,0 +1,17 @@ + + + + + SVGs to Clip Path converter + + +

This tool is made to help designers import shapes that have a clip path component.

+

The shape must have at least one path set with an id="filterPath" and a maximum of 5 background colors

+ + +
+

Your download link will appear here

+
+ + + diff --git a/addons/html_builder/static/image_shapes/convert-to-percentages.js b/addons/html_builder/static/image_shapes/convert-to-percentages.js new file mode 100644 index 0000000000000..acf8af91e7f8a --- /dev/null +++ b/addons/html_builder/static/image_shapes/convert-to-percentages.js @@ -0,0 +1,159 @@ +// The goal of this script is to have a shape ready for use with the +// "Shape on Image" feature of Odoo. +// Therefor we need to rearrange the file a little. +// Marks which axis each parameter of a command belongs to, as well as whether +// It's a positional measurement (x/y), a distance (dx/dy) or none (angles, flags) +const commandAxes = { + 'M': ['x', 'y'], + 'm': ['dx', 'dy'], + 'L': ['x', 'y'], + 'l': ['dx', 'dy'], + 'H': ['x'], + 'h': ['dx'], + 'V': ['y'], + 'v': ['dy'], + 'Z': [], + 'z': [], + 'C': ['x', 'y', 'x', 'y', 'x', 'y'], + 'c': ['dx', 'dy', 'dx', 'dy', 'dx', 'dy'], + 'S': ['x', 'y', 'x', 'y'], + 's': ['dx', 'dy', 'dx', 'dy'], + 'Q': ['x', 'y', 'x', 'y'], + 'q': ['dx', 'dy', 'dx', 'dy'], + 'T': ['x', 'y'], + 't': ['dx', 'dy'], + 'A': ['dx', 'dy', 'none', 'none', 'none', 'x', 'y'], + 'a': ['dx', 'dy', 'none', 'none', 'none', 'dx', 'dy'], +}; + +const toUserSpace = (x, y, width, height, precision = 4) => ({ + x: val => +((parseFloat(val) - x) / width).toFixed(precision), + dx: val => +(parseFloat(val) / width).toFixed(precision), + y: val => +((parseFloat(val) - y) / height).toFixed(precision), + dy: val => +(parseFloat(val) / height).toFixed(precision), + none: val => val, +}); + +const filePicker = document.getElementById('svgPicker'); +const submitButton = document.getElementById('submitButton'); +submitButton.addEventListener('click', async (ev) => { + if (!filePicker.files.length > 0) { + alert('Please select files using the file picker first'); + return; + } + Array.from(filePicker.files).forEach(async file => { + const fileReader = new FileReader(); + const readerPromise = new Promise((resolve, reject) => { + fileReader.addEventListener('load', () => resolve(fileReader.result)); + fileReader.addEventListener('error', () => reject(fileReader.error)); + }); + fileReader.readAsText(file, 'utf-8'); + const svgString = await readerPromise; + const parser = new DOMParser(); + const svg = parser.parseFromString(svgString, 'image/svg+xml'); + const path = svg.getElementById('filterPath'); + const svgDocumentElement = svg.documentElement; + // Some SVGs come without xlink + svgDocumentElement.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); + // We add the SVG to the body so we can take measurements of its + // original size + document.body.appendChild(svg.documentElement); + const { x, y, width, height } = svgDocumentElement.getBBox(); + const scalers = toUserSpace(x, y, width, height); + + // Converts the clipPath in values between 0 and 1 so we can use + // object bounding box as clip path units. It will make the clip path + // always adapt to the size of the picture. + const commands = path.getAttribute('d').match(/[a-z][^a-z]*/ig).map(c => { + return c.split(/[, ]|(?=-)|(?<=[a-z])(?=[0-9])/i).filter(part => !!part.length); + }); + const relSpaceCommands = commands.map(([command, ...nums]) => { + const axes = commandAxes[command]; + const relSpaceNums = nums.map((n, i) => { + const scaler = scalers[axes[i % axes.length]]; + return scaler(n); + }); + return `${command}${relSpaceNums.join(',')}`.replace(/,-/g, '-'); + }); + path.setAttribute('d', relSpaceCommands.join('')); + path.removeAttribute('fill'); + svgDocumentElement.removeAttribute('viewBox'); + + let defsEl = svgDocumentElement.querySelector('defs'); + if (!defsEl) { + defsEl = svg.createElementNS('http://www.w3.org/2000/svg', 'defs'); + svgDocumentElement.appendChild(defsEl); + } + + let clipPathEl = svgDocumentElement.querySelector('clipPath'); + if (!clipPathEl) { + clipPathEl = svg.createElementNS('http://www.w3.org/2000/svg', 'clipPath'); + clipPathEl.setAttribute('id', 'clip-path'); + defsEl.appendChild(clipPathEl); + } + + clipPathEl.setAttribute('clipPathUnits', 'objectBoundingBox'); + const backgroundEls = svgDocumentElement.getElementsByClassName('background'); + // We set the BG elements into their own svg so that when the total + // space gets stretched out, so does the backgrounds elements + Array.from(backgroundEls).forEach(el => { + const bgBbox = el.getBBox(); + const svgBackground = document.createElement('svg'); + const strokeWidth = el.getAttribute('stroke-width'); + // If the background has a strokeWidth, the viewBox need to take it + // into account + if (strokeWidth) { + const adj = parseFloat(strokeWidth) / 2; + svgBackground.setAttributeNS('http://www.w3.org/2000/svg', 'viewBox', + `${bgBbox.x - adj} ${bgBbox.y - adj} ${bgBbox.width + (adj * 2)} ${bgBbox.height + (adj * 2)}`); + } else { + svgBackground.setAttributeNS('http://www.w3.org/2000/svg', 'viewBox', `${bgBbox.x} ${bgBbox.y} ${bgBbox.width} ${bgBbox.height}`); + } + svgBackground.setAttributeNS('http://www.w3.org/2000/svg', 'preserveAspectRatio', 'none'); + svgBackground.appendChild(el); + svgDocumentElement.appendChild(svgBackground); + }); + + defsEl.appendChild(path); + // Setting the clip path for use and for preview + const useClipPathEl = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + useClipPathEl.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#filterPath'); + useClipPathEl.setAttribute('fill', 'none'); + clipPathEl.appendChild(useClipPathEl); + + const svgPreviewEl = svg.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svgPreviewEl.setAttributeNS('http://www.w3.org/2000/svg', 'viewBox', '0 0 1 1'); + svgPreviewEl.setAttribute('width', '600'); + svgPreviewEl.setAttribute('height', '600'); + svgPreviewEl.setAttribute('id', 'preview'); + svgPreviewEl.setAttributeNS('http://www.w3.org/2000/svg', 'preserveAspectRatio', 'none'); + const previewUseEl = useClipPathEl.cloneNode(true); + previewUseEl.setAttribute('fill', 'darkgrey'); + svgPreviewEl.appendChild(previewUseEl); + svgDocumentElement.appendChild(svgPreviewEl); + + const imageEl = document.createElement('image'); + imageEl.setAttribute('xlink:href', ''); + imageEl.setAttribute('clip-path', 'url(#clip-path)'); + svgDocumentElement.appendChild(imageEl); + // Give a default size to the SVGs for an easier preview on disk + svgDocumentElement.setAttribute('width', '600'); + svgDocumentElement.setAttribute('height', '600'); + + const outFile = new File([svgDocumentElement.outerHTML], filePicker.files[0].name, { type: 'image/svg+xml' }); + const outFileReader = new FileReader(); + const outReaderPromise = new Promise((resolve, reject) => { + outFileReader.addEventListener('load', () => resolve(outFileReader.result)); + outFileReader.addEventListener('error', () => reject(outFileReader.error)); + }); + outFileReader.readAsDataURL(outFile); + const dataURL = await outReaderPromise; + + const downloadLinkEl = document.createElement('a'); + downloadLinkEl.href = dataURL; + downloadLinkEl.innerText = 'Download'; + downloadLinkEl.setAttribute('download', file.name); + downloadLinkEl.classList.add('dl_link'); + document.getElementById('downloadArea').appendChild(downloadLinkEl); + }); +}); diff --git a/addons/html_builder/static/image_shapes/devices/browser_01.svg b/addons/html_builder/static/image_shapes/devices/browser_01.svg new file mode 100644 index 0000000000000..f88dffbfa0c73 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/browser_01.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/browser_02.svg b/addons/html_builder/static/image_shapes/devices/browser_02.svg new file mode 100644 index 0000000000000..58e512be16d32 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/browser_02.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/browser_03.svg b/addons/html_builder/static/image_shapes/devices/browser_03.svg new file mode 100644 index 0000000000000..b430618b36ac2 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/browser_03.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/galaxy_3d_landscape_01.svg b/addons/html_builder/static/image_shapes/devices/galaxy_3d_landscape_01.svg new file mode 100644 index 0000000000000..32c140629256c --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/galaxy_3d_landscape_01.svg @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/galaxy_3d_landscape_02.svg b/addons/html_builder/static/image_shapes/devices/galaxy_3d_landscape_02.svg new file mode 100644 index 0000000000000..2aedd8a7d9ebf --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/galaxy_3d_landscape_02.svg @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/galaxy_3d_portrait_01.svg b/addons/html_builder/static/image_shapes/devices/galaxy_3d_portrait_01.svg new file mode 100644 index 0000000000000..0b0546889314e --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/galaxy_3d_portrait_01.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/galaxy_3d_portrait_02.svg b/addons/html_builder/static/image_shapes/devices/galaxy_3d_portrait_02.svg new file mode 100644 index 0000000000000..d28807ab406d0 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/galaxy_3d_portrait_02.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/galaxy_front_landscape.svg b/addons/html_builder/static/image_shapes/devices/galaxy_front_landscape.svg new file mode 100644 index 0000000000000..5a408892a11be --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/galaxy_front_landscape.svg @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/galaxy_front_portrait.svg b/addons/html_builder/static/image_shapes/devices/galaxy_front_portrait.svg new file mode 100644 index 0000000000000..7d40258387cb0 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/galaxy_front_portrait.svg @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/galaxy_front_portrait_half.svg b/addons/html_builder/static/image_shapes/devices/galaxy_front_portrait_half.svg new file mode 100644 index 0000000000000..7b49a30b3f470 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/galaxy_front_portrait_half.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/imac_3d_01.svg b/addons/html_builder/static/image_shapes/devices/imac_3d_01.svg new file mode 100644 index 0000000000000..77a3a0d03d7e8 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/imac_3d_01.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/imac_3d_02.svg b/addons/html_builder/static/image_shapes/devices/imac_3d_02.svg new file mode 100644 index 0000000000000..a7bf967437a3c --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/imac_3d_02.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/imac_front.svg b/addons/html_builder/static/image_shapes/devices/imac_front.svg new file mode 100644 index 0000000000000..94015cd7d501a --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/imac_front.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/ipad_3d_landscape_01.svg b/addons/html_builder/static/image_shapes/devices/ipad_3d_landscape_01.svg new file mode 100644 index 0000000000000..42d1802f49c6e --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/ipad_3d_landscape_01.svg @@ -0,0 +1,294 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/ipad_3d_landscape_02.svg b/addons/html_builder/static/image_shapes/devices/ipad_3d_landscape_02.svg new file mode 100644 index 0000000000000..89835c3fefca2 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/ipad_3d_landscape_02.svg @@ -0,0 +1,278 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/ipad_3d_portrait_01.svg b/addons/html_builder/static/image_shapes/devices/ipad_3d_portrait_01.svg new file mode 100644 index 0000000000000..875703867f958 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/ipad_3d_portrait_01.svg @@ -0,0 +1,281 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/ipad_3d_portrait_02.svg b/addons/html_builder/static/image_shapes/devices/ipad_3d_portrait_02.svg new file mode 100644 index 0000000000000..e3ac943572b6a --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/ipad_3d_portrait_02.svg @@ -0,0 +1,289 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/ipad_front_landscape.svg b/addons/html_builder/static/image_shapes/devices/ipad_front_landscape.svg new file mode 100644 index 0000000000000..9f438490158c9 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/ipad_front_landscape.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/ipad_front_portrait.svg b/addons/html_builder/static/image_shapes/devices/ipad_front_portrait.svg new file mode 100644 index 0000000000000..72e39176dc083 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/ipad_front_portrait.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/iphone_3d_landscape_01.svg b/addons/html_builder/static/image_shapes/devices/iphone_3d_landscape_01.svg new file mode 100644 index 0000000000000..17465d8e05392 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/iphone_3d_landscape_01.svg @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/iphone_3d_landscape_02.svg b/addons/html_builder/static/image_shapes/devices/iphone_3d_landscape_02.svg new file mode 100644 index 0000000000000..f585761282d0b --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/iphone_3d_landscape_02.svg @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/iphone_3d_portrait_01.svg b/addons/html_builder/static/image_shapes/devices/iphone_3d_portrait_01.svg new file mode 100644 index 0000000000000..327e3db74dbd1 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/iphone_3d_portrait_01.svg @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/iphone_3d_portrait_02.svg b/addons/html_builder/static/image_shapes/devices/iphone_3d_portrait_02.svg new file mode 100644 index 0000000000000..342de99030456 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/iphone_3d_portrait_02.svg @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/iphone_front_landscape.svg b/addons/html_builder/static/image_shapes/devices/iphone_front_landscape.svg new file mode 100644 index 0000000000000..b095ef0e2deef --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/iphone_front_landscape.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/iphone_front_portrait.svg b/addons/html_builder/static/image_shapes/devices/iphone_front_portrait.svg new file mode 100644 index 0000000000000..230c80f727279 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/iphone_front_portrait.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/macbook_3d_01.svg b/addons/html_builder/static/image_shapes/devices/macbook_3d_01.svg new file mode 100644 index 0000000000000..4e79dfb2ad9d1 --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/macbook_3d_01.svg @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/macbook_3d_02.svg b/addons/html_builder/static/image_shapes/devices/macbook_3d_02.svg new file mode 100644 index 0000000000000..f57442c1fe3aa --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/macbook_3d_02.svg @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/devices/macbook_front.svg b/addons/html_builder/static/image_shapes/devices/macbook_front.svg new file mode 100644 index 0000000000000..4a81df20b7cdc --- /dev/null +++ b/addons/html_builder/static/image_shapes/devices/macbook_front.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_cornered_triangle.svg b/addons/html_builder/static/image_shapes/geometric/geo_cornered_triangle.svg new file mode 100644 index 0000000000000..69163283edc52 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_cornered_triangle.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_diamond.svg b/addons/html_builder/static/image_shapes/geometric/geo_diamond.svg new file mode 100644 index 0000000000000..f6da7579c9f1d --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_diamond.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_door.svg b/addons/html_builder/static/image_shapes/geometric/geo_door.svg new file mode 100644 index 0000000000000..150d550e0874a --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_door.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_emerald.svg b/addons/html_builder/static/image_shapes/geometric/geo_emerald.svg new file mode 100644 index 0000000000000..52ed7fd41729a --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_emerald.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_gem.svg b/addons/html_builder/static/image_shapes/geometric/geo_gem.svg new file mode 100644 index 0000000000000..a0d4d73bdba60 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_gem.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_heptagon.svg b/addons/html_builder/static/image_shapes/geometric/geo_heptagon.svg new file mode 100644 index 0000000000000..d748766dad736 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_heptagon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_hexagon.svg b/addons/html_builder/static/image_shapes/geometric/geo_hexagon.svg new file mode 100644 index 0000000000000..12e3656266259 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_hexagon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_kayak.svg b/addons/html_builder/static/image_shapes/geometric/geo_kayak.svg new file mode 100644 index 0000000000000..a47851cba6a35 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_kayak.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_pentagon.svg b/addons/html_builder/static/image_shapes/geometric/geo_pentagon.svg new file mode 100644 index 0000000000000..c31fa0a0ad1fa --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_pentagon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_shuriken.svg b/addons/html_builder/static/image_shapes/geometric/geo_shuriken.svg new file mode 100644 index 0000000000000..5609b8d50853e --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_shuriken.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_slanted.svg b/addons/html_builder/static/image_shapes/geometric/geo_slanted.svg new file mode 100644 index 0000000000000..3b525c97777d4 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_slanted.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_sonar.svg b/addons/html_builder/static/image_shapes/geometric/geo_sonar.svg new file mode 100644 index 0000000000000..a7626c63dadc5 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_sonar.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_square.svg b/addons/html_builder/static/image_shapes/geometric/geo_square.svg new file mode 100644 index 0000000000000..1396c09d72ae7 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_square.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_square_1.svg b/addons/html_builder/static/image_shapes/geometric/geo_square_1.svg new file mode 100644 index 0000000000000..ccfe894889271 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_square_1.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_square_2.svg b/addons/html_builder/static/image_shapes/geometric/geo_square_2.svg new file mode 100644 index 0000000000000..bb3265a7ba0d1 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_square_2.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_square_3.svg b/addons/html_builder/static/image_shapes/geometric/geo_square_3.svg new file mode 100644 index 0000000000000..3c5d1d75f9c18 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_square_3.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_square_4.svg b/addons/html_builder/static/image_shapes/geometric/geo_square_4.svg new file mode 100644 index 0000000000000..17c876308ebf8 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_square_4.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_square_5.svg b/addons/html_builder/static/image_shapes/geometric/geo_square_5.svg new file mode 100644 index 0000000000000..9d7337e416ce4 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_square_5.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_square_6.svg b/addons/html_builder/static/image_shapes/geometric/geo_square_6.svg new file mode 100644 index 0000000000000..1629f7447b8c6 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_square_6.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_star.svg b/addons/html_builder/static/image_shapes/geometric/geo_star.svg new file mode 100644 index 0000000000000..93aa58c832d62 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_star.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_star_16pin.svg b/addons/html_builder/static/image_shapes/geometric/geo_star_16pin.svg new file mode 100644 index 0000000000000..f80cc53ef1b35 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_star_16pin.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_star_8pin.svg b/addons/html_builder/static/image_shapes/geometric/geo_star_8pin.svg new file mode 100644 index 0000000000000..bf9be1076b86d --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_star_8pin.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_tear.svg b/addons/html_builder/static/image_shapes/geometric/geo_tear.svg new file mode 100644 index 0000000000000..8a542573926d4 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_tear.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_tetris.svg b/addons/html_builder/static/image_shapes/geometric/geo_tetris.svg new file mode 100644 index 0000000000000..1f1d528281b0e --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_tetris.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_triangle.svg b/addons/html_builder/static/image_shapes/geometric/geo_triangle.svg new file mode 100644 index 0000000000000..f3e9bc236b1b1 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_triangle.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric/geo_triangle_corner.svg b/addons/html_builder/static/image_shapes/geometric/geo_triangle_corner.svg new file mode 100644 index 0000000000000..658fb50b86749 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric/geo_triangle_corner.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_blob_hard.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_blob_hard.svg new file mode 100644 index 0000000000000..c39ed0765c44e --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_blob_hard.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_blob_medium.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_blob_medium.svg new file mode 100644 index 0000000000000..472c2d2a45f0e --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_blob_medium.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_blob_soft.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_blob_soft.svg new file mode 100644 index 0000000000000..2d6e77b4492ce --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_blob_soft.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_bread.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_bread.svg new file mode 100644 index 0000000000000..e60012a4f2270 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_bread.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_circle.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_circle.svg new file mode 100644 index 0000000000000..982f25b53bf3f --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_circle.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_clover.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_clover.svg new file mode 100644 index 0000000000000..f0b18d08de091 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_clover.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_cornered.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_cornered.svg new file mode 100644 index 0000000000000..6597500986c62 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_cornered.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_diamond.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_diamond.svg new file mode 100644 index 0000000000000..614018c92771a --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_diamond.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_door.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_door.svg new file mode 100644 index 0000000000000..ba235a5fb84d7 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_door.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_emerald.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_emerald.svg new file mode 100644 index 0000000000000..4fd59f70e26f9 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_emerald.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_gem.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_gem.svg new file mode 100644 index 0000000000000..49782b3c03650 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_gem.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_heptagon.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_heptagon.svg new file mode 100644 index 0000000000000..ccfd2a4502b30 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_heptagon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_hexagon.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_hexagon.svg new file mode 100644 index 0000000000000..2de75a6af1fdd --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_hexagon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_lemon.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_lemon.svg new file mode 100644 index 0000000000000..b060a7f8fee67 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_lemon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_pentagon.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_pentagon.svg new file mode 100644 index 0000000000000..dd44b60ff3469 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_pentagon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_pill.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_pill.svg new file mode 100644 index 0000000000000..3493f34e15905 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_pill.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_shuriken.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_shuriken.svg new file mode 100644 index 0000000000000..d45a1e4850bf3 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_shuriken.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_sonar.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_sonar.svg new file mode 100644 index 0000000000000..1ec550e7efe66 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_sonar.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_square.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_square.svg new file mode 100644 index 0000000000000..8729549559c81 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_square.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_square_1.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_square_1.svg new file mode 100644 index 0000000000000..15b178df8c011 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_square_1.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_square_2.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_square_2.svg new file mode 100644 index 0000000000000..6b38d2daed37c --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_square_2.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_star.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_star.svg new file mode 100644 index 0000000000000..d6b50f504de08 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_star.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_star_16pin.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_star_16pin.svg new file mode 100644 index 0000000000000..5a3812d682773 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_star_16pin.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_star_7pin.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_star_7pin.svg new file mode 100644 index 0000000000000..fac44f0950481 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_star_7pin.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_star_8pin.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_star_8pin.svg new file mode 100644 index 0000000000000..54980041b1e35 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_star_8pin.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_tear.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_tear.svg new file mode 100644 index 0000000000000..660eb05082253 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_tear.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/geometric_round/geo_round_triangle.svg b/addons/html_builder/static/image_shapes/geometric_round/geo_round_triangle.svg new file mode 100644 index 0000000000000..73b2444b2f609 --- /dev/null +++ b/addons/html_builder/static/image_shapes/geometric_round/geo_round_triangle.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/panel/panel_duo.svg b/addons/html_builder/static/image_shapes/panel/panel_duo.svg new file mode 100644 index 0000000000000..f886ef969688a --- /dev/null +++ b/addons/html_builder/static/image_shapes/panel/panel_duo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/panel/panel_duo_r.svg b/addons/html_builder/static/image_shapes/panel/panel_duo_r.svg new file mode 100644 index 0000000000000..5deefb1cb7e2f --- /dev/null +++ b/addons/html_builder/static/image_shapes/panel/panel_duo_r.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/panel/panel_duo_step.svg b/addons/html_builder/static/image_shapes/panel/panel_duo_step.svg new file mode 100644 index 0000000000000..2ade003895fe0 --- /dev/null +++ b/addons/html_builder/static/image_shapes/panel/panel_duo_step.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/panel/panel_duo_step_pill.svg b/addons/html_builder/static/image_shapes/panel/panel_duo_step_pill.svg new file mode 100644 index 0000000000000..5f1befdaf3bf6 --- /dev/null +++ b/addons/html_builder/static/image_shapes/panel/panel_duo_step_pill.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/panel/panel_trio_in_r.svg b/addons/html_builder/static/image_shapes/panel/panel_trio_in_r.svg new file mode 100644 index 0000000000000..ac22ff3434c08 --- /dev/null +++ b/addons/html_builder/static/image_shapes/panel/panel_trio_in_r.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/panel/panel_trio_out_r.svg b/addons/html_builder/static/image_shapes/panel/panel_trio_out_r.svg new file mode 100644 index 0000000000000..0b0adb608e2fb --- /dev/null +++ b/addons/html_builder/static/image_shapes/panel/panel_trio_out_r.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/panel/panel_window.svg b/addons/html_builder/static/image_shapes/panel/panel_window.svg new file mode 100644 index 0000000000000..d762c5cb935e5 --- /dev/null +++ b/addons/html_builder/static/image_shapes/panel/panel_window.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/pattern/pattern_circuit.svg b/addons/html_builder/static/image_shapes/pattern/pattern_circuit.svg new file mode 100644 index 0000000000000..6d496261e44ae --- /dev/null +++ b/addons/html_builder/static/image_shapes/pattern/pattern_circuit.svg @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/pattern/pattern_labyrinth.svg b/addons/html_builder/static/image_shapes/pattern/pattern_labyrinth.svg new file mode 100644 index 0000000000000..8c4b18f1012ec --- /dev/null +++ b/addons/html_builder/static/image_shapes/pattern/pattern_labyrinth.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/pattern/pattern_line_star.svg b/addons/html_builder/static/image_shapes/pattern/pattern_line_star.svg new file mode 100644 index 0000000000000..601779591d640 --- /dev/null +++ b/addons/html_builder/static/image_shapes/pattern/pattern_line_star.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/pattern/pattern_line_sun.svg b/addons/html_builder/static/image_shapes/pattern/pattern_line_sun.svg new file mode 100644 index 0000000000000..fbe18764a85b3 --- /dev/null +++ b/addons/html_builder/static/image_shapes/pattern/pattern_line_sun.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/pattern/pattern_organic_caps.svg b/addons/html_builder/static/image_shapes/pattern/pattern_organic_caps.svg new file mode 100644 index 0000000000000..9bba130409157 --- /dev/null +++ b/addons/html_builder/static/image_shapes/pattern/pattern_organic_caps.svg @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/pattern/pattern_organic_cross.svg b/addons/html_builder/static/image_shapes/pattern/pattern_organic_cross.svg new file mode 100644 index 0000000000000..34c84c42cc744 --- /dev/null +++ b/addons/html_builder/static/image_shapes/pattern/pattern_organic_cross.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/pattern/pattern_organic_dot.svg b/addons/html_builder/static/image_shapes/pattern/pattern_organic_dot.svg new file mode 100644 index 0000000000000..b47013db8d7e8 --- /dev/null +++ b/addons/html_builder/static/image_shapes/pattern/pattern_organic_dot.svg @@ -0,0 +1,662 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/pattern/pattern_oval_zebra.svg b/addons/html_builder/static/image_shapes/pattern/pattern_oval_zebra.svg new file mode 100644 index 0000000000000..bbd572b607615 --- /dev/null +++ b/addons/html_builder/static/image_shapes/pattern/pattern_oval_zebra.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/pattern/pattern_point.svg b/addons/html_builder/static/image_shapes/pattern/pattern_point.svg new file mode 100644 index 0000000000000..8d2fe37444acb --- /dev/null +++ b/addons/html_builder/static/image_shapes/pattern/pattern_point.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/pattern/pattern_wave_1.svg b/addons/html_builder/static/image_shapes/pattern/pattern_wave_1.svg new file mode 100644 index 0000000000000..babae1e93b3f1 --- /dev/null +++ b/addons/html_builder/static/image_shapes/pattern/pattern_wave_1.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/pattern/pattern_wave_2.svg b/addons/html_builder/static/image_shapes/pattern/pattern_wave_2.svg new file mode 100644 index 0000000000000..2c1b6c5ca7370 --- /dev/null +++ b/addons/html_builder/static/image_shapes/pattern/pattern_wave_2.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/pattern/pattern_wave_3.svg b/addons/html_builder/static/image_shapes/pattern/pattern_wave_3.svg new file mode 100644 index 0000000000000..c4752773973ef --- /dev/null +++ b/addons/html_builder/static/image_shapes/pattern/pattern_wave_3.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/pattern/pattern_wave_4.svg b/addons/html_builder/static/image_shapes/pattern/pattern_wave_4.svg new file mode 100644 index 0000000000000..a5fca9f1b8adb --- /dev/null +++ b/addons/html_builder/static/image_shapes/pattern/pattern_wave_4.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/solid/solid_blob_1.svg b/addons/html_builder/static/image_shapes/solid/solid_blob_1.svg new file mode 100644 index 0000000000000..170d6dc424043 --- /dev/null +++ b/addons/html_builder/static/image_shapes/solid/solid_blob_1.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/solid/solid_blob_2.svg b/addons/html_builder/static/image_shapes/solid/solid_blob_2.svg new file mode 100644 index 0000000000000..dc5fd1008d093 --- /dev/null +++ b/addons/html_builder/static/image_shapes/solid/solid_blob_2.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/solid/solid_blob_3.svg b/addons/html_builder/static/image_shapes/solid/solid_blob_3.svg new file mode 100644 index 0000000000000..c6bc3eec0cbca --- /dev/null +++ b/addons/html_builder/static/image_shapes/solid/solid_blob_3.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/solid/solid_blob_4.svg b/addons/html_builder/static/image_shapes/solid/solid_blob_4.svg new file mode 100644 index 0000000000000..6bdbacb5ce2d4 --- /dev/null +++ b/addons/html_builder/static/image_shapes/solid/solid_blob_4.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/solid/solid_blob_5.svg b/addons/html_builder/static/image_shapes/solid/solid_blob_5.svg new file mode 100644 index 0000000000000..c7b3fb9717a87 --- /dev/null +++ b/addons/html_builder/static/image_shapes/solid/solid_blob_5.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/solid/solid_blob_shadow_1.svg b/addons/html_builder/static/image_shapes/solid/solid_blob_shadow_1.svg new file mode 100644 index 0000000000000..162c24d0c786b --- /dev/null +++ b/addons/html_builder/static/image_shapes/solid/solid_blob_shadow_1.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/solid/solid_blob_shadow_2.svg b/addons/html_builder/static/image_shapes/solid/solid_blob_shadow_2.svg new file mode 100644 index 0000000000000..7bcd52ced84ad --- /dev/null +++ b/addons/html_builder/static/image_shapes/solid/solid_blob_shadow_2.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/solid/solid_square_1.svg b/addons/html_builder/static/image_shapes/solid/solid_square_1.svg new file mode 100644 index 0000000000000..2caa6b3a00b1f --- /dev/null +++ b/addons/html_builder/static/image_shapes/solid/solid_square_1.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/solid/solid_square_2.svg b/addons/html_builder/static/image_shapes/solid/solid_square_2.svg new file mode 100644 index 0000000000000..70f560fcc9fcc --- /dev/null +++ b/addons/html_builder/static/image_shapes/solid/solid_square_2.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/solid/solid_square_3.svg b/addons/html_builder/static/image_shapes/solid/solid_square_3.svg new file mode 100644 index 0000000000000..56830ce3eec0a --- /dev/null +++ b/addons/html_builder/static/image_shapes/solid/solid_square_3.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/special/special_filter.svg b/addons/html_builder/static/image_shapes/special/special_filter.svg new file mode 100644 index 0000000000000..cc2a278e624b8 --- /dev/null +++ b/addons/html_builder/static/image_shapes/special/special_filter.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/special/special_flag.svg b/addons/html_builder/static/image_shapes/special/special_flag.svg new file mode 100644 index 0000000000000..a68177553adbe --- /dev/null +++ b/addons/html_builder/static/image_shapes/special/special_flag.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/special/special_layered.svg b/addons/html_builder/static/image_shapes/special/special_layered.svg new file mode 100644 index 0000000000000..2321c7319b839 --- /dev/null +++ b/addons/html_builder/static/image_shapes/special/special_layered.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/special/special_organic.svg b/addons/html_builder/static/image_shapes/special/special_organic.svg new file mode 100644 index 0000000000000..d75bfbca0fc25 --- /dev/null +++ b/addons/html_builder/static/image_shapes/special/special_organic.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/special/special_rain.svg b/addons/html_builder/static/image_shapes/special/special_rain.svg new file mode 100644 index 0000000000000..69037ebdb9bbd --- /dev/null +++ b/addons/html_builder/static/image_shapes/special/special_rain.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/special/special_snow.svg b/addons/html_builder/static/image_shapes/special/special_snow.svg new file mode 100644 index 0000000000000..f492449992b82 --- /dev/null +++ b/addons/html_builder/static/image_shapes/special/special_snow.svg @@ -0,0 +1,316 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/image_shapes/special/special_speed.svg b/addons/html_builder/static/image_shapes/special/special_speed.svg new file mode 100644 index 0000000000000..f7ca285903d90 --- /dev/null +++ b/addons/html_builder/static/image_shapes/special/special_speed.svg @@ -0,0 +1,263 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/img/options/bg_shape.svg b/addons/html_builder/static/img/options/bg_shape.svg new file mode 100644 index 0000000000000..838ddc5320334 --- /dev/null +++ b/addons/html_builder/static/img/options/bg_shape.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/html_builder/static/img/options/desktop_invisible.svg b/addons/html_builder/static/img/options/desktop_invisible.svg new file mode 100644 index 0000000000000..c9a407c74b34b --- /dev/null +++ b/addons/html_builder/static/img/options/desktop_invisible.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/addons/html_builder/static/img/options/mobile_invisible.svg b/addons/html_builder/static/img/options/mobile_invisible.svg new file mode 100644 index 0000000000000..ce5f3091ce816 --- /dev/null +++ b/addons/html_builder/static/img/options/mobile_invisible.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/addons/html_builder/static/img/options/size_large.svg b/addons/html_builder/static/img/options/size_large.svg new file mode 100644 index 0000000000000..1354178068994 --- /dev/null +++ b/addons/html_builder/static/img/options/size_large.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/html_builder/static/img/options/size_medium.svg b/addons/html_builder/static/img/options/size_medium.svg new file mode 100644 index 0000000000000..00b3a3d43d0f8 --- /dev/null +++ b/addons/html_builder/static/img/options/size_medium.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/html_builder/static/img/options/size_small.svg b/addons/html_builder/static/img/options/size_small.svg new file mode 100644 index 0000000000000..aaa36a673855b --- /dev/null +++ b/addons/html_builder/static/img/options/size_small.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/html_builder/static/img/phone.png b/addons/html_builder/static/img/phone.png new file mode 100644 index 0000000000000000000000000000000000000000..0570c4d8fe27d837b829779dd665a6fbc89a4d5d GIT binary patch literal 20595 zcmW(+2{@GB7yf3!7~9w>G$UEZzKfa$8T*oLh-^dl?1?g%u|ygQrL5W4j4dQt8%w0c zmXalF5=Bz#|NZ`+hiBaH-us<%?t9L8&$*;pSr~C3k0Aj7;4mf_*Z=^81^_TL0tSxg zsP&fs07~2dldRgXt1|$00|u)eT9T3UG?<_fUC20_la32I~xuDH6rC9-ye&l zd(Xz_G%Nu%)t%edRbo{5JGH$em9H8+iII5uck1lzUo3GG+QA;O>TM6@;~EE6P6tfG{p5G7#@6>PvACJB)$YRi_g6jt?Pv=;cTC+$(7eGSh#j|Ug4?5< z)^tx71Xj8l^{O> z{qha(zgzw5k?$}4yV3LYq22gQaFm`mZFr@rs-bQ!>Y^4|Wv+oFM*Wxi^YfWww}%C! zxSb8czTF#CUp@Ex$y(0l*C*F6RXo5dA8xTVe6&3Awei@^;2R%{4D{x9sNb->vJ>|V zj|rm51@ETpK^%vMDQ1bM+}3|G*Z_ zc_?3wQ_w*3tkY(`@oD|2XCK;2el8dZg_LF*2}d+fNUf_bnW>JeYz8zbcK4S2ac~`| z?U>zoA86zIB0usYY3f7d+2vVQseG4&6#;Q+dB0DF{?qpR%ByaO{kS=7 z`Bv-V@D$1-Jd00NeC5%}MMG|MahHd8P1=N9=*|0q1to-n%J$Mv*oqYHo+tU1FRs7% zDi!)qIY@HgpTpdrfBf2yUL7fl`|x#GF8@8&)}1=L;b7g%L94d9H+u!$dO;{u;_UEl zO#4gn6~pa_g2%lJ+FMr-6;waR-(_y|=sms2w~=G`sO>gnzc` zh8Nv`Rg*L$*XGo^)%n4_q$aUeM2ZX20bg6IwP&5#Rs8KHc)8%w^U#Nto}5zZn(73` z^*g0+%CEgm)NWf=4l z&?bNL9v;3MK{dYWGrK%l{?r~-WS$H=AyeJ9b<8eLRFnN#Uby!mYSN^BAG00JDCoc3S$o-sMCD(h zi-w|t>gu|Zv}=Et{iR1^olWmri)QLH)}=|ZvWa>Bnj>sXE+wQmrMyvmLf8DeBxn|Z z;tE*az5Y&Jc#vb8;nyLZdgj+LBhQD}5tT)<%;R2(0IFev`v#A(q4FQ0mkkEQC?2*O zBVWCZrOwxi57o)t_k=V$b1D8T*_1k##qq^$cYSaaR=M@;kN2keX)Vvf&bL-99X+nK zt}RMXNTFZTRYF!(;%Ddj{r5Ukqw}|8Pja92eA~{m?@UNCWWz_%E4l&WMjIB zPCq!wxa?M~#Idgy30gW^VcJ`&*5##O_aVSG?#Q zKjt&HR%*VUuJ^+oMh&b63CgvW6s~@TK|e$$7OObvmNU*B4Owhzg$`F<4civtxSw`x z+%cpZSty7uejmdqxwEVo$$3rKGxLry?+Fz(;V%!$nR(57ct`R}qKtdjDIrZ$dfUPx zB42b0KUTc(;JWRd)7%}&5tr(&=YjC$yRDr^eLT7AmHbucDP|Ru*YlG7{$Q&9xeSG1 z41-A(0kEnZ$!0~cm7nQHxc3iaEMz(jr(NH69_EjJp(ljW>A7(0x9zEd7dOWm?zSTe zEdg;Ra|^UUb9#P;8lQ-x)rMQ(OC(L68b#NykSt$2+$ApZnf6}W-O$jrc;0c5uC1b0 zi*nU+_D7ZV*m+=`X66+C4wrD2-?qsz$ZOb2Attget0iSrT~ z{kWkhe&>O1=#9c35Y`09HD{Sq=nKaMl#S4RlRt{m$6eRx6?p@;KR#DF-H1pv=+(-h zbC4fzcisPuFZo&?dot`|PoQU7`atU^OZ4fx=_j8k$}@GEEMY8q_yu;?e{pFmtkoUF zeyn<6&F`eHQ>FMd?2VGt?-VafT#kXp+HWiw7JqN}nd2c>&bqtspQLFtwDt|(>XYsC zjv>|l21IC^TSOHvPq(hx@j>?E4Sla}CY~q5Im<-d?{`j+>IhmFl692?^lDiH^d`>{ z$FC`wjvU5jS3MyZzmn&skXCxTHBEAJUJ8WLU9Pdz8;8{f^HDfd|1IQK6|C3(+DMOR zw8__OR(9u23K5O?Wukk^!R=*r-~A&8QGxa?3*lj-th;GE7UdoUes()C0?(p8Jx0_jqEvD9nC$bMFU&Mz=)0BE zGSgVb(yz#p&)>gKjmN===MBZWmo98DZ6&YSTAASTwi+JU?iI|hcLZAZW#-)$eat$7 zE9Fr?4@j1Lv0#r<7~-@u+Jfy%s{8kF>oBn5f|cB)3u9ZZNko+JIb*$OC_Zj{`TpwI z1Gdsw4Ck3k?>)Do#19P=Z(ovNyKro>a{cMzJqc#y>k8R8riz8E6PYdLtxR^)iKvQA z>Ft(qBfai4nF>>6=Kf>%g9vph@3WiyTsn$Huj_ss!~)gT8tUIeU`Fb$CJsj|)`wlk z9wEb(sod_@j4H@Ddv(cYcJaBv+2X@9l%(J);I>nbQ@w1MTSFw)6?y?BPGooH>}RDr zRZja#MMdi)ni*${U!Ko7`FmpJ-m>P6YC6{sL}`gsu#InX0?OH0D>6Ltje*jQ!$E#e zrvcj8=Qkej(&kudb~wKL0gm73%yErZNnFUu^USGU?$;7$akIfV<}7R4O&A`0f7VWy zQ{4I`vW%z;y59HC|D6{{cz;jCyNdpiqcyUD*)@C-851xYnrwRWn@V1^0nW)}=tpp} z7?xed`R8@)h>>NRUA=%4+pI`+Vef4(x|)gRDQ+(}YGPNRa>4ua6AKGZhgGU~%bad` zE-D+CHVm(q&y*f_Lh-nMH#KgLSy_)0kM! z_WrQhCz8gcwHWd1-m?>F=bt{{I>Gory?EqwVNjvg%zChYm(upAMfiw%AaQ<`^v2CU zdq4b5dCz$A%jJfVVy`dlQBA{K)$K{o&lwv!l zE8@R$#n;wM7ptI>CEI*hd)eJY2=V-#MY8Xxxb^QV# zfyD08XOn+2h@~7ssWsob+S`~5e2cm~n~IL->V9Q~Jd%)tVLx_Dm&Au|df4vUgbu48l^I`s!kk18X{V_7rIo^RReZQg(v1@WCk5>H3FL*4@YDds`>ipuQYQf^zA6Csztq z&>4AVWgmi#&Ia>+h|X*pC=WCehP96z4?6yZCCCJ3EFN%0d?wKDQ>GCV6zUiF{5njZl1Y){Nghz7Ka?UN1K^1cAQ7anQV<)ty03(*gL0e^^XO+CS2D@A7{5atNJA}ZdbbO)t@b4Uq=$R1vg#I z>Ic^vIo@GS-95IUb>c$LRcYA__lr$!-#4dLD*JEz98!~&n zjP)B^Sr6Dx0IbYx4dVdd*RNl3adBQ=UO#{Sw6n8&{ra_)l~r?dvx$jGX=$mWqobvz zCHNV9R8&;7wY6nqV^dI2ke{CqjuVN*hYufubHJZpzI<_VastP}&o2M}0_Xes`c_w0 zgPWI>l;q~-PE1UAdwXBGas}LZe}BKCq5_;86ckidRR!)D5fK4Sj){pWEG!%v8fs{0 zh>wpiE-nV2CM6{`Ha4cGrAmH~CFudfG<(9zKW z>H@wOl3_v-71(m)zamL6Csh009#i7ziRHJUkqf85Hu`wQHaXpyUY& z2~;XIH8mB4K}JSKW@ct~b~Xqq@bp3OfH(m`1%d&z44qB~!SM9yQ}F1)GX;+l)c3=O z51?g03xnnbO$r_yXx@d#F8ZLJkZ>D|3jl6q8f>2d5D!x$10aP-lmPz1dWAp?0|14S z|9wNQmYt0R2U)_6%?(+W*bwYc?(RSC;370&Y;ex@>g-njCBlQ%-~ZaYh{sMoX)aa5 zpsJ3^-kkQc_UAWv>}Xi|O4fgBzi5+%+2w^sM}1;TGPO#ojD>V?LLxu!PD`s@>Uj^3 zzxw#BL^Q(wZQtV1>X)ebpWput@%ne}KP%kc4s&@^Y5Z2|r`-0>-yL6dBMYxHGyV5v zSaWT7V`O+(=k(?Sr_%({o_B`=cDz;BY;K>3ii}*0viS{*-TQj>OnAg3AHQA18E@x3 zm#*I8D80!u_wsn{_NomxbatOqJ*)c5pi1S+oe{lxRI;+eQSl;=em{+Oz}uN8+g!TI zcHjw5>_EuO@aBoBrMAQJ-DUPm0wd|vPKm;&J_C0z4V3zOM3;rUE0&9{Ex7cv+}l*G zI=b+(cvEzJ<)gIZkcCl;I+$K~Gu}RVg18lgo&UGysPle9cZYF!&1*0~Ufw)+_D#W; zO2_j*yw$RzVtX7&q_R754ilMebq8AvYDtJ@J?Ne~b_@O=s`L1&TO$({b34kd?3RW< zhgwLrAEc;9BnaNP;2*P4sJk-y?pWT>+1sLeA6r!y>Lcxs8R#99j6cyV)oD6LL za9D8L+vYRJ`3Ex2ofmB5o_$^^1HH?cf8Mp~z@|3l=QG<$z4{=RYf07J|6ct4sBrPa z_3PI!{oNI;_y0w0I5M^CPKn5FC~P=$U`+hSzwzKNj7@Z?>}P|s{B^v`vxrZk+aG${ z!n!vmyB^Gc^j+V;&L}@oSz$1CUoXC{_6@E0a`Kb(eYInov!`Y2Z~R;pjEfD~{X2i@ z`qAdC&Z+vN(7m2S7KZ_*-@ne?I2xULS9mSu_hegZtLwkdv)#4bXCB}9Rdm??J5bKS z=BGznLz@;2SaDS6&8p*FAanZ%Om4YqikyS(~sQ7azrp@)3y_9WxvR zU4A*JZH%~F2-M@(JQH`=_h!4^wPSkk$?dw)*kG#V|HDx&8b@uECurA&(kYH?}R~4*vXYk}`aAp^A|*-lijjs82q*a77C* zD{F7ods4t8%B=U!%Wv7OnbWI^TIVmGc&-i`sJ5gspJRIAk!fvXBWKVrUMuy}`nb-a zYeexH?6Gq+h-78%1M+5ecMjl^ywyD5@ z?Q*3DUPotzJunLXdvx^ZY|xzUQmc{u_4s=)m*y%}1I$0o&d&NhCtX(hqzOCx@^arQ zezrisBqaCGZ?>s`;IvCY!NHcAS$S8#mxq*uEGo4Ozqx<;ir2dnH&1J{KH$h!-rg)TSo-^WN$ZSt z`1-if?XUA257U3IoG+0azvur*;KVrJ{Hv4~@rtr99%fqzcIJs63CxG@1m@C1qqFC; zieIQm+Li?n4dZ=O#B4e1O|~ww@~C8*gi>#f`L$Gt-XE=e;nxI@m3x-*sMTFA*Reo! zzqwl~qj{*x8s7Yl&d99+%DPk3>$ zbf0FBn6KlEHlIiBdg;FY8O>n%(lcky8N&3j`*ns|wau;G+qjD+HZuM<6`OOGdwk7_ zyUM1%kxw0v>-rbf&oQ^&uGYzbCY^o9FAL;ka-p7^QcO5U^40T`VxzmvK8A(MyqYqc~k%VXsc`mGbJ~PTe>v+ldVV8rvk}F zji*{30RfSm3Y18$5zn@zPNUn;{OUx0+`lEn5^5E2Z#nahRMreO{EUEUBWr`puvr0z z@!lOJL!I+~6TdyRjgtV?OO1<{F@Bb)8%45oixz$S{15NkXkEnL1h&l}`?KF$4A2Bj zu<7{?`IDh2cbj6=d?HUiLROiWNw8u_F$)SNwqv4VCw$iQ_-=Q7JR2N0f0oZB^?2Br z$)epf>alsqF#1LM^3B_|TA!bHo)Y#~e(kSj?RK5dlCYC1BQMr=Wm)wZQoeMzDqze% zGmXn}70MRe^RG%$xK6Yt2KeuZ`RCSE9bR3y_qrdW9E>WSiF7F>n^v$ zFIw%}N*;kA#imcq4870oX_a4w8d~4S=~PYcsI%{kZT#M>=e zD!QF3$3p8lEhD<`7{&a1^Zw`Q3vvS&6#5fC5iqxLx9@1meEqh=oiy3UYBcEGIy*XK zHYmc$tp9qAs>n+>HuF4oA>;D4*h0Io4gFUivRF%*H~XoiWLbUdwlsp$zX*SQ0ty0e9Jj_i3vho1O{c@i!2rp`Bx zmN9ygckY}-8DGoVnf;J1pE>H-w^ee?n6R*c|-(FRWx*H?Wd%@M|cSIwnW2C!^(dIbj z+fw7)_6a`Z%Qd9rQs+S@c9Yq2Q*g1p69-iFd=Q?rSPKK9#d>fazpNFLu>Gv1WGRkY+d-sZY~M>-DUB3CQac5R|kSNQav+^c4ba1$5Wm(yt>{j!vIp$E3Hq~oUFNd%({l6S}!cl^Flhbu9S2Y6E^ml z?XKWTNb&mLt!_)Ek~_{zE!vM>VY4t$aloVhb#xkx6Q zJ5F&s-y16U{L|9S*oLFhQG(v>30E6!}$t8>Dmm}e3T%I)bX|r zjfaEHLl3Rg>=tPKiEFT-~D#w^cR`0+tt*1~J zLhnNYPiy$(nz7{8&H%r@{?)Q}vb)}|MeDfFMgB3pO6$k;^Gr}83582cJUq7%ls~U4 z`Nz}uU)zRfpQgDT^rbPxOA(idZ<@rGlDtj-)>tUt$dk6hL`z?3<@@ydx9G%kmc36s zD|eFxsN^cG`seWu zCh|19`HgGPZOKy!y$iLXPnW$eVI+R%-N8GuCX8O(<-B_Ws;2WApM7FnoZZ((OGzG) z9(A5+9Dhz9U4n>=M+gj=HL80WSVsF_yROldzu(bv)pqa9(bJ(qKe20X91!M{B?)cPOmKH0g%pg{Wh)v7 z`*1;ajIt#ny|=jWP)sA6%V4*b3{aZt9Ovxw)V;IH!>fX))y0tZzJT`c508BIKm8a} z3$3kEwj35IwBALUQj3Wuhd2;<0t5cID0WM@2_lbK$w&|QsW)&HctTdcN>SFX#4xmp1Mi0@>t5{NDM8O-f*EkaR;K{UZ>N(z)}>Qb_%H8`VaIx!;ihO z_lyV{Yts|EP#_fk=d0jWm~+0=a0n$p>lS{>bf5Rpz`k`wr*&;AYhV1k=|4mM&ex9T z6$H%%1&uan9-Or^m$tk!Qp5bCl{BqlR`l=NgJR6~#H|yyL7MGza}t-{?MxkG|LW&6 zsK&dv|LWUEMJ_L_=lF^SPuiH?9r4N2HGlt9sf5;kf9vYmcOWqny^3YqAuJzT7{7bQ zLiizfkb|m7YNqI*i9}|4m}kIH?Az>hn&tXb_x5q&80W|@4@D3km()AmMbCO`dxoMJ zO}b(gBm5!(?`6(lI^Q=Iel2t^cxDw5`}0$R$fc_QC+VkAkH ze@E1RZr5sG`&eKHG-|o;oG~YT+ZR9fu1P4RPfNYMQD93}G24Pw=lk+^o^6BiC2_dd zoXZHr7gHHW z+D-rqOX=7xRS6dk|9#&P3^3#srqCAiE^?i6Z$>nxJE}G#Dgupuqf1kEY_z=CDFY!4 zu7}<>>dWm+m&cdq{N)JF8OZX&FdR}N<(BJvzoo1p{u_hSY*#B7W!{Lv$D7O zn6iS0mkLa7zR7J=I|BMTO~EK6N2i0HTb&E zyw|Dk!0sU!ReDef7EuAwR03|$MJ}oHbMO3mHRTLpcnM+!Zt2AHtK4jhk$Uj~vN~SZyD9Rr>rzra3`!fi#p)<$ za$5Qk56!AI?h>$DUe;UKX-0n9gN!Fs@NW{?`{Vzm=5;t8j(qfmFa{pf9G4fT%{D!@ z4fkW=5*MNEQ1*sJG5V50_O%uTIefc9IX5k`k87jrXJ3Yd9WI(f2z|K@_8)j?=8iHZ z5s{;pM?I)$^w4PjP0^lVStolH(~hd zY5{zCcS8#j%1KrNRLYrETqoFYLXaVEQ4!SUJ$8mU1mP$^LeE%pr+l}dz9Ok#(&7)$ z?HZ>ZY5~PnDDIRH`p^5{ZhH#YnH%LP7J~jlwz5mPO!SO!;i^Y&DM6sZy>P-{rdUm6 z`-}W&+upwG&jRCwcfyC*$-xIpkCB|Gb=V11feRy z66F2Em;T%yKd5Pw?XYj7yfnP`))yvo3TJZ5rGgsw&Fj0(wjb<9{Dw5(&fFjLg~u}V zQ#>vSKmolz3@h_!^uiW<4JKNz9LP)6aJme6ain45@MeL8vS7L#`)^B+GB{PxBALZdbNw6H^Z0^@rl~ znP}lU`6eXUFaRAz&LrY#@F8o}?xyP!tNy)}% zQRANzrS8`7$Vsd(ki!edHt9IbU4JvUO^rHopPv1WVLF_4*0wrtoQmbU%&ug-!w&kelk=GDZj17!oF2+#SYRl~) z%>+i{m?af^-54gAGW!ymj5f`6dW*!`?;VD~CBe~}A(eeWimK>GV=}qD^mi^ z++b+E#}M5Xhkh#qZFE+q)k>o7={o|ABk|Jm{wG>3gdB6!Nh7e=a(v&l(Qrf6v zkO}iL{T`^IFin>luwX1ooIH?p6u6GgMpA!R>~mhpp{l~kN^MxE7n@`RbM^r4CO68b zMUwXD70H^#Lq+ht>Ukf9%&iUumV~zXaWNLE(qn8X>QN59;H?7$<&7MI;rXD6myt$3_bg@8#l7*@_}K#6Qzwn_fr zx{)&gOEtwEDAc6A*MXqJ`8`bgxoOH!gM$-uY45jR^w1pvWmep>aBp`xC$gAb;y%~% zjqdn>{D)FGOvqvm2?YoyCt`QGYy}deM9~I8L(jHI14{n|t`dv4^pH4d@MewbXqJ&a z;XCvVH>Mr)Tj6^Q(T&&~KdnUbItJmE_kS_{9*^}up+bS6jzc}<>|pCl9korbNR1GW zaiJFmBRugmV^O|d1B}?xbh-(~`OhS)G(`oaYL9?=$N+FY{P7efNC=urCap6u(qC4? zRnRaf(jDF}4SOx+C~#kYt_7ed(C!ICs%&B0mZ1O|%a^4-*i06gr?P|K7?q&_?Uk7r z0(iXqG`VAeiqZy(xS}nRwOCX8)${P(I$&8vnj%VrL$V-f9jMJi*_SNPhU7JNL*tCcpMH43Vc!?6N!A#7$Q4a0_0^7+!{ni z5|;T118;;I^;v!o&&~MkB1y~G_5wR;Js11Ok zLnHKhpTY&*e7~K0Y6OaYs|tr1$A}X!s}!pfG-m|EqER%iUqn$;s@L>+QTJyQorNA+QpOBSrC3P z+x$ahvlQh2Ll1{I+K< zg+QtjsU@8K6mmElPWB^%9AE)Ob>Q#@n90ZNgh!)vtE4CH#g&l8eor_-#STFb1&WdV zHi(pBY8w{l1qjM4%LdQzSKhO^2-u-Fqj6{YaIIi)(*{9?vt?Rd3}=(3D5D;*!nF14 z|BRRA=eT0@*=ZOxIVu^>O7kKByDzroKbwFVa2O{ppiSF+vFY6K9XIT* zD3SM-Xf042#aNX5%8u}y3m48TQOjCd#xjm^BZkY%*$btpC4=khvKnFhHgII|#a0-0 zQw^BNCM|q$W$)(*L%5*1;SxF08mq8#i5O|hjU*I$hoXHin0Q;8imSZfKzLgMF+l7& zSs@0jq;Pn6iV-x_XnLd<2|+iGV z_c8=siaCFsT-Sdrp^z5e50hM`#dE zhA;>W2AbL995S<|3x?WYk3T@sgqdh>V7;yaP^hyMmQUqTbFHg8DF0)CE^u?utGHl& z%^yO24COhWLydRrZ~b}8w<7%l0vQ4HitA6}xj7NPE;W3;6?pd&GgavV0ReN}xt4{cDS9*%geSuTeRN8fwjSjEJ0G}Dp?bg! znz6#P5Gw>PT3H>8brIz#VVVqp=VlyYz&9va5du&U?P@TDfk1sO%i;xxXiK(qp*Cu< zY!G2~G`eQ}nU5|_5t4F-{Np=L7654n<`jyQ1cI=E2g5{Dodwfy6>LF%lOhRHrkgos zwkgL5+|6C-eDnSOT9-r*#*E&G0zEYJ~nop+{IEB0MBxQSoHZXCTh5_(Voab<{RPvQD1~V9Oy{vc< z$i3EL9RNQ=|Ko^(Y$??I2OyRIF3vI)O!F~5|4$Lji!X3$DsX&0H?1cD*V;q>0fReF zzLErri303iL}2&UAn0jpvJxmET@*ppQ6Lb_;)7`J3ip?MtRW5gC3ym2Pv9iKVxnV5 zF@7T(%Xm?;6?+6me^QzDFK5+q1GN!=zwY8`!!qLGPGpf@hKjLky4(0vpl(^sGmm)O z!<=QsCG~529mnTqlBm}2<)FSF1>>KK^p7C02mPqTr8&e3TZ)xN3%%n@o`Ly8}Tmx;*>q( zOan?mzuc|Z5u^&G8*A<7tq>=vaQ>WA&~wNk2&zL^9B5pDEP#ca611d@ruP`DN7eBG z)q&8?SztC4Hl;y-!`1*a&mu5P$0p7ShdyMBxTn7c5f%ZD%H!oYZ3(1fu#$;=d(pl` z4B%dhih+P}^b23siH#zZBrbLqX9Xs10i1noY^5Bwrhy;t;sAO!1aX(0@lb@pYnb%h zjUdWESTkTV#++fOjcfE;v3qa#m<{S_evZ=2IaHP9o}F0q9U6vRb@^%m2^5=OSY(GA z?Qei#0wsTCnCg~`Govh@*s4k+l zza{e^qFHl+|8go^#_Llq6eMuz_#+>~Bz7`MnIi&jK(A$^VX*T4W15Q&P+z{T&*P3BXnU>|liR}F1Kqhh087yfkQ6K5Gvgn6+znc6a z6tnQ6gT;n8O%tHFSNb~&0Pa4-Q1qh6Zn*{vF1(1wc^_Xi@|uMfB8C{nuc9A6&bjQSJDR&K~+bm@!EA z>QE}lU(9gtO;5jdc#2?DtLPIdqq;xqi%Ldlxa1%iS}btB-hR3*a%M2+9aVDAMWw3G zjRE#~*%$tcJvEPQFcn2x?~IdbIaeS(IT5jf8iK;a$)a-nY}A!p5`5$}7b<{w-YP|m zOq|)z=mhoG@yzFUAv01#w^$J0wsk#G=Lj_oe%WQP;=JOX9#?LVA91m&9%x_h%RI* zPHpTWh25Q1!V(kVvggB%5_A;kW6y-mEUWZ?u7!_yJVH4akn&IEFyZbIbC8I$pjh6x zuQ|(YT?%XTA1YN*nh?}v7~_5{SS^h5c^rQt2VLh|MXk|=pqW@x&VB^Wkyg=7u#F(N2Lgg35Kp~mb?vR4mXsJY98dN)Eb4JQjh+|l+T ze%zl7SDZ3Nf+LJ5wk01&*?^vke?R*UCguI#sRVa2UMGnw_x(Ws3ds$Pz~46ShD z7x@~(}(i+0+x*rKSlS#F*lQ+04eo+ z9>9nP+{=oMaNG-V68+4s3~zBIjBs3<5}Cvx-}|pC^U3%_Er}?U0t4aoZFrcD+K{D! zbP6h-)R9E$abwW84OT~!=N^7`E-F|9+`=af_RuTsuY6S! z&Z1IFo<^uRSYc?GLmLD|=Q{dh0#1w#MD68BB4E%RxZPlfMCVIc<$i{vf5cz?Wz}6= z47To+-gXP{huXrS#bc6rSjM+pw$8i7{gGK zId*eDvw0A0?OJ&4sr)T+2fwsv~7M`mGQ=JBdR(db!SfnX~&Me_uL(h)TKasP;< z2~1>?TEak^{df)L$0Qt57S5FvF@|mK(m_N(7?Gl*m_vw7WJw*j92{$^h#PA*Pb96E zZ)LB5ii@QvRas7*%Pl)OvKUXshpP*fu{GZcJq~XUxK;OLw82`9#_oxnh z6B4|wn|uA=e8B6@3HGKU)J^`YEHqz``H>K;dtkX~nWPq!fOmAVzYmmNv`RS+#y*u2 z=0yRJW+^w;LUC7;N`aIcAh1NqVE?w{eXHgPh9uxFxba~YXJQT(Xt)gkaUGq;;zN*W z?&`Rw-~V5OsAOdK79V?rRmz!G3jpz+T^nqMrYNZNAAyx|9cdstu%VM>1(lTZc?1Tw zuyyr!$qy5>!Tw%wkMWT%^P(8YE0O)1@_ZY2^iUh=d?g?DJH~mzI%yV{bcz~VF|iLt z&E}}r3U7cY%f}mx9Gb)FQ06G6~_2Q_v}8Eo(#xlF)m|%j(SaFh&6b14X zWKWJAEAAdJ!5QH}j0F2r$vTa-8FWv;z!IkgCV^{fbU4^^&&E=zuUS!6DXz@=KuJTZ z15||-7Y-}c2RstlCx%LI1pMRxCNN+W$|r)XKVEN6g|am5|% zvIvBwFw-KT5==P9fc(qQnfsMY)U`f31gngS z@b|WK$*6*rpuB%O4d3c#^u`|O?WKR4GG)cn~z@{<}t*z=UocA1~Wqz8hWXY=3W2a6IJlW_Y{o{L4bu5 z%{s6*Y{nf3`dgJB;J9g^k%7N%G`A(%^54};QZAAxP(j0jeJBI&jc$L+wLzqCZ~2Ig zv%P#FrU-Q&DLmNSPuj%Xjf)jj(SHe2PeV{k=B~~Ft>~qEI^HOTWT{wnObqXv#!E4KJ48B zzAP*=)rwi=A}R$!BJf~MrEp_NFk{>U?#o;TqrWDO9g)I8CWG}E%W|Cn7+fE*MojOhjYS9kcY5FciJjx{ zf{~Tr^pgMj0@);fD+DA9H1&vLOBYpM4icyc7QQlY2!y)UPoGV4Q+g|kvP6QxGAAOz z0Rc>;;V(k=>{Vvz4(dN+H5FN7>t8PNoq@0tK{cy zl`u3fK3APnxa2rGwzAKS&~mpS`Ol0j;F|9P%sq~wy|6R zg;k+EBklMo$jWbqu-Y*AC^~E-y|}0J*0#o9@(>jc(DQeyS80PQ*BWZR^MTpA_cgu0 zn`=fO-i_K1-o;osz=RjcYLgIDxw8})d>TpG09aR>Mx^a%r$E25oO1kj5IMkYmCe0zl|lRv{o&0EBsl%qW562#{PF zGK9e#A^@q+15ync!Yo7N3Yo@0@L)Iv3SpKZGkq$B5FpU@GE;~}00K&qu76l;1y%Lb^Co9CV00=`MrWWykPP{wRHR9|%&MRcb3=2i8 zMkLGf+OLa1_L>I*qGcrj2)$aC0r6asfDH9Uj3N*V0HIc1@vIZyvhk(}q^+?fVXWZ? z!$jNy3q`AjhlTU9e9O*zB9QW}({@i433Uew^_UA*icV#ghJ{e~&E@)k7tVffA8#?* z&yv9q1Bp6espwQ(0D=^<;`-_L4*vR6lcy(bB}aw5tcVAeidLpe3h`}T`@M(1zRK0| z#!`MtR7eb#icT#piJ`tSIPu%>=lSc)oGoqPT@gquXoR(*QPD*S$XsCOi{GB`H`G`5 zRs|YMBp^{(EE+Ypw6r8-$a47j!)yEe4Xwtm)}R3csV0^c@;hL)C`9_A2xQLZjdec# z=`w#ySFWRbW&^(-GDQ>;T3d(Z`t;?K0~t0CJiS!G-*RQ6>PmXFo&1d<8VqCMH6Kjt zpS)rVi%U}5>vjD2L&XFBre6$g-?-v!xXy5+lCr` zeiLZMKr$u-Bpg^>9)uP9wB?;$SX>lC-Ro<w=%-T5q$7ix8GXXL;JO^v` zDQoT3>;jh|3mC{!p!1L4u8X&xH{NWCW$`~ggMmZ|ki}(KwNF?6SFZ$xU?6i#=8HdE zyD8q>?>zFr6Wc{VMtOxS4=*lwVckAe4ZeIy6oOCfU051k_73MB{P5!~@%F0?d)MBq z8JZ0n#*{h11DSob1S|K68s3*25KbWo$dd2e-=BXeyT=Ll-3#l+9mB?$l>;(|fxLPJ zw)H7?OqS+(AgIe)Tv%B0dY7U{e)#jc>^|MjTlXA^#;sassVy-R5rDjWwE*k)v1{q& zya;3%Ei(c#yfoVX=+_q6o$k~fxv?FreKYCAXE{W8%X)>s50HG+3cQ}57l9y&EFd7m zODog=`Qg$<`Tg4W?jMP#8{AHZt=J+dgw*UXtmDV2p!e1MyaLGL>}#Zu#YorFKTa#| zT0j2a>Qvb6&Uuo>@n{eOS(xR4pnAP9yfBOF^}A|z;q@VFVL|+93*_xJx`Mnd$bXLT zadB~$e6@h@^Llo1k=${15q*thkAB)Bz7{>m#l;2gX8gAB8eg$EOD?^0+(-PWf7!RP z?4KLO1s zcYgiG8FE!;t!NNgA6)S+Exc9&Q6S-%U?|W3`TrBm$Kopy(Ezf5=0k*KwEy<+7ghFl z+V$=Is+g^M)M`uM?cT!x!8Al1fJGC?3r!&NJP?%A$X0!)PX6$-8pvmDZGZl_v2HY4 zN#Bcsh^emRAyWwXmBIzoJAlmRDFg}R)xzvz=9@=fT+izOx@w<&UT?D)ONBNI19??Q zATlB%6YWW#^<84Zr`qk2KhdXX3F0OUX* z^C}{^%S1d-`?zuU_Q}&4O>&pJ;^7rzAZaR2h^ekbqM0GmF9!hckO;qgLWH0$7On5= znZAdQ{@bE8wv_MvcE8LPv=t|2!fUI`Bt+yuG&1BlFAackKo>14L=FTADoWbW9X zxBu4U@!9pwzK3^rV!=cr9tn=%N#r%r$g4t@C3L*cq4zz6MCK*SA_94hgXHC6?O%@` z-s{z#?CTBxecHbnT#LtJp+O%3pCh{!@+R{v-Xi~s8}BOeYGJ@4yTZtR{-U;5$E zFI@$;TAuBe|MvZ9*P4GOhWacrtk1rFok!$_HW9Jul1(P>BJ@9GSuf-~P?qC0oqrYY z{{Hq?-32$lt{5(S@kN)@?~g@;NFsQ%es)$)B!5;vWc{P$_B_7_co(YwlLM%RKwb(w z<`Iuz)1SBhGjP4|cH|TH>F-bWj13M(!+{ahXe}<1EkJp;%Uw%?^5VmHd)~Q$^pEU* z{Dq8-o^`0M2Q7sX2Z~usTd@l7LY=FFw@jz>dzI+*~`TO=CS6UC=vOn5z>FLQnS7h$4AaU}k@3@yGOB`26{c7j#*PJ-X^zh2Cgbmc--5{Oefj{YNM7yo1MQKb24P zKm6mOAvU+VHsV`ZCg;nGB2i@t*HlY%4vpx2igUyl(snklzmHG#ADM%S#`pXq0ugx3 zzj!{s;OIX2^#19pcfP9Czh}7l`NRK~TUX~+R}m3WBnl!jCfdb6B}n)E;?vc0F3R}J zk1Q%-Nk4oeLX*eK`R6ZQ4v*h^`2CIjqKof-o$<$G?(UP1zHY6KF3Xd&i5Vn%%^mFNE)*t-b= z;d#8ocJ=a=ceMVm`!}9-4!u46=@BiB8?C=Se0aB`F1h9%_6n{g+19I=eHgnh$H{NMUunTV&Jywa?fAF1YF&dYSh1r|BQZ2nW%zZ4&j^@S|X`3Lgq^T+6)`cnbXMdihaHasuHR^18?D;;=un*Zs}i{_Y|}%~iZ3YDzr&y*T`o6XiT+<~Ez#Pn8e3v+?4| z_Uj*f?qfyIw8K7mwCmDWpFjNm?`z*&I{oBvNqKXlVR8di?c}6eU7ND1tJ~ZhFI1b( zF(+THy74-p@ORa19#n44nv_*7I59bV3^E}rsC`VfGyX2NGy6UQz=C19)}2$O`Z8pYK7{L8=wiTU<@`WVy?P z)joCf=rOXsZD?iBo!i|(tBY%ByAjtLQd?VF4WtZQaoXo4?yhxB<($S$I%SSW0)a@X zwypE}M>}u(V%G_SF&U2pgX`gFES@MfCrwssX&Nch;mKr1M@I#TS2_<@Q)e%<9_i>f zf-cWYV*ki@l*5&1JJr?5d8AVQx-q^)D>9Bsl_f=t$$<-5lHF>>MwCn@Z8ozRy%(cO zpo%NYVwthb;EKiYJ6};;k??vj7z{-dDUZ`oUf=c6Po}@u^|s{PP~C&9E zz1@@&%gVir-@{!)G5|lC_)KU$6vKxZkIzJ-czwCWUJnQSYv>6{);t*W4+bL~fDroJ zADM~AxyOeI+D!J0F}GpZIaa@a{pu(ED~Hp)ednJb$zNzi)>@AWnQK{DC;G@$CtBpP z&capJSyNM1_PB>Pk*@A8;X4agC;p+_H+R+?dJCwnrk*d90ZnhkYUSRsUA1ywx1ysb zZ|GsvaNm5q43)4ueg<1v3HSNlS^UK={}<)>^E2G%yEUBW)!^?$Kd7~*>kj-;%>VKF Ye;8eW-F&+wx&QzG07*qoM6N<$f`wet-v9sr literal 0 HcmV?d00001 diff --git a/addons/html_builder/static/src/bootstrap_overriden.scss b/addons/html_builder/static/src/bootstrap_overriden.scss new file mode 100644 index 0000000000000..3264d215aa741 --- /dev/null +++ b/addons/html_builder/static/src/bootstrap_overriden.scss @@ -0,0 +1,96 @@ + +// Prefix for :root CSS variables +$variable-prefix: '' !default; + +// Automatically update bootstrap colors map (unused by BS itself) +$colors: () !default; +@each $name, $color in $o-color-palette { + $colors: map-merge(('#{$name}': o-color($color)), $colors); +} + +$o-btn-bg-colors: () !default; +$o-btn-border-colors: () !default; +@if not (variable-exists('prevent-backend-colors-alteration') and $prevent-backend-colors-alteration) { + $o-btn-bg-colors: map-merge(( + 'primary': o-color('o-cc1-btn-primary'), + 'secondary': o-color('o-cc1-btn-secondary'), + ), $o-btn-bg-colors); + $o-btn-border-colors: map-merge(( + 'primary': o-color('o-cc1-btn-primary-border'), + 'secondary': o-color('o-cc1-btn-secondary-border'), + ), $o-btn-border-colors); +} + +// Automatically extend bootstrap to create theme background/text/button classes +$theme-colors: () !default; +@each $name, $color in $o-theme-color-palette { + $theme-colors: map-merge(('#{$name}': o-color($color)), $theme-colors); +} + +// Automatically extend bootstrap gray palette (the theme palette is supposed to +// at least declare white and black) +$grays: () !default; +@each $name, $color in $o-gray-color-palette { + $grays: map-merge(('#{$name}': o-color($color)), $grays); +} + +// Detach colors that are used for backend UI (see comment linked to the +// prevent-backend-colors-alteration for more information) +@if variable-exists('prevent-backend-colors-alteration') and $prevent-backend-colors-alteration { + $theme-colors: map-remove($theme-colors, 'primary', 'secondary', 'success', 'info', 'warning', 'danger', 'light', 'dark'); + $grays: map-remove($grays, '100', '200', '300', '400', '500', '600', '700', '800', '900', 'black', 'white'); +} + +// Bootstrap use standard variables to define individual colors which are then +// placed into a map which is then used to get the value of each individual +// color. As BS4 allows to extend the map a priori to define our own colors, +// it does not take care of making the standard variables match the values in +// the user's map. The problem is that, at least for grays, bootstrap uses the +// standard variables in its _variables.scss file, so if: +// +// User file: +// $grays: ( +// '100': blue, +// ); +// +// BS4: +// $gray-100: gray !default; +// $grays: () !default; +// $grays: map-merge(( +// '100': $gray-100, +// ), $grays); +// +// -> Here map-get($grays, '100') is blue but $gray-100 is still gray... so BS4 is not +// correctly generated as BS4 uses $gray-100 in _variables.scss +$primary: map-get($theme-colors, 'primary') !default; +$secondary: map-get($theme-colors, 'secondary') !default; +$success: map-get($theme-colors, 'success') !default; +$info: map-get($theme-colors, 'info') !default; +$warning: map-get($theme-colors, 'warning') !default; +$danger: map-get($theme-colors, 'danger') !default; +$light: map-get($theme-colors, 'light') !default; +$dark: map-get($theme-colors, 'dark') !default; + +$white: map-get($grays, 'white') !default; +$gray-100: map-get($grays, '100') !default; +$gray-200: map-get($grays, '200') !default; +$gray-300: map-get($grays, '300') !default; +$gray-400: map-get($grays, '400') !default; +$gray-500: map-get($grays, '500') !default; +$gray-600: map-get($grays, '600') !default; +$gray-700: map-get($grays, '700') !default; +$gray-800: map-get($grays, '800') !default; +$gray-900: map-get($grays, '900') !default; +$black: map-get($grays, 'black') !default; + +$o-color-system-initialized: true; + +// This was added by compatibility but it actually became a nice behavior: the +// bootstrap default "small" behavior will use the ratio of the configured base +// font size (if configured, e.g. with website settings) and the Odoo own's +// "small" font size. Grep: SMALLER_FONT_SIZE_RATIO. +$small-font-size: if( + variable-exists('font-size-base'), + ($o-small-font-size / $font-size-base) * 1em, + null +) !default; diff --git a/addons/html_builder/static/src/builder.js b/addons/html_builder/static/src/builder.js new file mode 100644 index 0000000000000..c4de2970ed88f --- /dev/null +++ b/addons/html_builder/static/src/builder.js @@ -0,0 +1,266 @@ +import { Editor } from "@html_editor/editor"; +import { MAIN_PLUGINS } from "@html_editor/plugin_sets"; +import { closestElement } from "@html_editor/utils/dom_traversal"; +import { + Component, + EventBus, + onMounted, + onWillDestroy, + onWillStart, + onWillUpdateProps, + useRef, + useState, + useSubEnv, +} from "@odoo/owl"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { useHotkey } from "@web/core/hotkeys/hotkey_hook"; +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { addLoadingEffect as addButtonLoadingEffect } from "@web/core/utils/ui"; +import { useSetupAction } from "@web/search/action_hook"; +import { InvisibleElementsPanel } from "./sidebar/invisible_elements_panel"; +import { BlockTab } from "./sidebar/block_tab"; +import { CustomizeTab } from "./sidebar/customize_tab"; +import { CORE_PLUGINS } from "./core/core_plugins"; +import { EDITOR_COLOR_CSS_VARIABLES, getCSSVariableValue } from "./utils/utils_css"; + +export class Builder extends Component { + static template = "html_builder.Builder"; + static components = { BlockTab, CustomizeTab, InvisibleElementsPanel }; + static props = { + closeEditor: { type: Function }, + snippetsName: { type: String }, + toggleMobile: { type: Function }, + overlayRef: { type: Function }, + isTranslation: { type: Boolean }, + iframeLoaded: { type: Object }, + isMobile: { type: Boolean }, + Plugins: { type: Array, optional: true }, + }; + + setup() { + // const actionService = useService("action"); + this.builder_sidebarRef = useRef("builder_sidebar"); + this.state = useState({ + canUndo: false, + canRedo: false, + activeTab: this.props.isTranslation ? "customize" : "blocks", + currentOptionsContainers: undefined, + invisibleEls: [], + }); + useHotkey("control+z", () => this.undo()); + useHotkey("control+y", () => this.redo()); + useHotkey("control+shift+z", () => this.redo()); + this.websiteService = useService("website"); + this.orm = useService("orm"); + this.dialog = useService("dialog"); + this.ui = useService("ui"); + this.notification = useService("notification"); + + const editorBus = new EventBus(); + + const mainPlugins = removePlugins([...MAIN_PLUGINS], ["PowerButtonsPlugin"]); + const Plugins = [...mainPlugins, ...CORE_PLUGINS, ...(this.props.Plugins || [])]; + // TODO: maybe do a different config for the translate mode and the + // "regular" mode. + this.editor = new Editor( + { + Plugins, + onChange: ({ isPreviewing }) => { + this.state.canUndo = this.editor.shared.history.canUndo(); + this.state.canRedo = this.editor.shared.history.canRedo(); + if (!isPreviewing) { + this.updateInvisibleEls(); + } + editorBus.trigger("UPDATE_EDITING_ELEMENT"); + editorBus.trigger("STEP_ADDED", { isPreviewing }); + }, + resources: { + change_current_options_containers_listeners: (currentOptionsContainers) => { + this.state.currentOptionsContainers = currentOptionsContainers; + if (!currentOptionsContainers.length) { + // There is no options, fallback on the blocks tab + this.setTab("blocks"); + return; + } + this.setTab("customize"); + }, + unsplittable_node_predicates: (/** @type {Node} */ node) => + node.querySelector?.("[data-oe-translation-source-sha]"), + can_display_toolbar: (namespace) => + // disable the toolbar for images and icons + namespace === undefined ? true : false, + }, + getRecordInfo: (editableEl) => { + if (!editableEl) { + editableEl = closestElement( + this.editor.shared.selection.getEditableSelection().anchorNode + ); + } + return { + resModel: editableEl.dataset["oeModel"], + resId: editableEl.dataset["oeId"], + field: editableEl.dataset["oeField"], + type: editableEl.dataset["oeType"], + }; + }, + localOverlayContainers: { + key: this.env.localOverlayContainerKey, + ref: this.props.overlayRef, + }, + replaceSnippet: async (snippet) => await this.snippetModel.replaceSnippet(snippet), + saveSnippet: (snippetEl, cleanForSaveHandlers) => + this.snippetModel.saveSnippet(snippetEl, cleanForSaveHandlers), + }, + this.env.services + ); + + this.snippetModel = useState(useService("html_builder.snippets")); + + 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; + this.editor.attachTo(iframeEl.contentDocument.body.querySelector("#wrapwrap")); + }); + + useSubEnv({ + editor: this.editor, + editorBus, + }); + // onMounted(() => { + // // actionService.setActionMode("fullscreen"); + // }); + onWillDestroy(() => { + this.editor.destroy(); + // actionService.setActionMode("current"); + }); + + useSetupAction({ + beforeUnload: (ev) => this.onBeforeUnload(ev), + beforeLeave: () => this.onBeforeLeave(), + }); + + onMounted(() => { + this.setCSSVariables(); + // TODO: onload editor + this.updateInvisibleEls(); + }); + onWillUpdateProps((nextProps) => { + if (nextProps.isMobile !== this.props.isMobile) { + this.updateInvisibleEls(nextProps.isMobile); + } + }); + } + + setCSSVariables() { + const el = this.builder_sidebarRef.el; + for (const style of EDITOR_COLOR_CSS_VARIABLES) { + let value = getCSSVariableValue(style); + if (value.startsWith("'") && value.endsWith("'")) { + // Gradient values are recovered within a string. + value = value.substring(1, value.length - 1); + } + el.style.setProperty(`--we-cp-${style}`, value); + } + } + + discard() { + if (this.state.canUndo) { + this.dialog.add(ConfirmationDialog, { + body: _t( + "If you discard the current edits, all unsaved changes will be lost. You can cancel to return to edit mode." + ), + confirm: () => this.props.closeEditor(), + cancel: () => {}, + }); + } else { + this.props.closeEditor(); + } + } + + getInvisibleSelector(isMobile = this.props.isMobile) { + return `.o_snippet_invisible, ${ + isMobile ? ".o_snippet_mobile_invisible" : ".o_snippet_desktop_invisible" + }`; + } + + async save() { + this.isSaving = true; + // TODO: handle the urgent save and the fail of the save operation + const snippetMenuEl = this.builder_sidebarRef.el; + // Add a loading effect on the save button and disable the other actions + addButtonLoadingEffect(snippetMenuEl.querySelector("[data-action='save']")); + const actionButtonEls = snippetMenuEl.querySelectorAll("[data-action]"); + for (const actionButtonEl of actionButtonEls) { + actionButtonEl.disabled = true; + } + await this.editor.shared.savePlugin.save(this.props.isTranslation); + this.props.closeEditor(); + } + + setTab(tab) { + this.state.activeTab = tab; + } + + undo() { + this.editor.shared.history.undo(); + } + + redo() { + this.editor.shared.history.redo(); + } + + onBeforeUnload(event) { + if (!this.isSaving && this.state.canUndo) { + event.preventDefault(); + event.returnValue = "Unsaved changes"; + } + } + + async onBeforeLeave() { + if (this.state.canUndo) { + let continueProcess = true; + await new Promise((resolve) => { + this.dialog.add(ConfirmationDialog, { + body: _t("If you proceed, your changes will be lost"), + confirmLabel: _t("Continue"), + confirm: () => resolve(), + cancel: () => { + continueProcess = false; + resolve(); + }, + }); + }); + return continueProcess; + } + return true; + } + + onMobilePreviewClick() { + this.props.toggleMobile(); + this.editor.resources["on_mobile_preview_clicked"].forEach((handler) => handler()); + } + + updateInvisibleEls(isMobile = this.props.isMobile) { + this.state.invisibleEls = [ + ...this.editor.editable.querySelectorAll(this.getInvisibleSelector(isMobile)), + ]; + } +} + +/** + * Removes the specified plugins from a given list of plugins. + * + * @param {Array} plugins the list of plugins + * @param {Array} pluginsToRemove the names of the plugins to remove + * @returns {Array} + */ +function removePlugins(plugins, pluginsToRemove) { + return plugins.filter((p) => !pluginsToRemove.includes(p.name)); +} + +registry.category("lazy_components").add("website.Builder", Builder); diff --git a/addons/html_builder/static/src/builder.scss b/addons/html_builder/static/src/builder.scss new file mode 100644 index 0000000000000..c41b57ce55d13 --- /dev/null +++ b/addons/html_builder/static/src/builder.scss @@ -0,0 +1,210 @@ +.o-snippets-menu { + background-color: $o-we-bg-darker; + color: #d9d9d9; + width: 288px; +} + +.o-snippets-top-actions { + border-bottom: 1px solid $o-we-bg-lighter; + height: 46px; + + .btn { + border: none; + border-radius: 0; + padding: 0.375rem 0.75rem; + font-size: $o-we-font-size; + font-weight: 400; + line-height: 1; + + &:not(.fa) { + font-family: $o-we-font-family; + } + &.btn-primary { + @include button-variant($o-brand-primary, $o-brand-primary); + } + &.btn-secondary { + @include button-variant($o-we-sidebar-tabs-bg, $o-we-sidebar-tabs-bg); + } + &:focus, &:active, &:focus:active { + outline: none; + box-shadow: none !important; + } + } + + button[data-action="mobile"] span.fa { + font-size: 20px; + } +} + +.o-snippets-tabs { + font-size: 12px; + line-height: 24px; + + > button { + color: $o-we-color; + } + .active { + box-shadow: inset 0 -2px 0 #01bad2; + } +} + +.o-tab-content { + background-color: $o-we-bg-dark; + font-size: 12px; +} + +.we-bg-darker { + background-color: #2b2b33; +} +.we-bg-options-container { + background-color: #3e3e46; +} + +.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; + border-radius: 10rem; + cursor: pointer; + + &::after { + content: "" !important; + box-shadow: $o-we-sidebar-content-field-colorpicker-shadow; + } +} + +.o_we_invisible_el_panel { + max-height: 220px; + overflow-y: auto; + padding: $o-we-sidebar-blocks-content-spacing; + background-color: $o-we-sidebar-blocks-content-bg; + box-shadow: $o-we-item-standup-top rgba($o-we-item-standup-color-light, .2); + + .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:first-child::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} { + @extend %o_we_sublevel; + + @if $level > 1 { + > div:first-child::before { + padding-left: ($level - 1) * 0.6em; + } + } + } +} + +.o-snippets-tabs > button[disabled] { + opacity: .5; +} + +// TODO: adjust the style of those elements +.o_we_border_preview { + display: inline-block; + width: 40px; + max-width: 100%; + margin-bottom: 2px; + border-width: 4px; + border-bottom: none !important; +} + +.o_pager_container { + overflow-y: scroll; + scroll-behavior: smooth; +} + +.builder_select_page { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: $o-we-item-spacing / 2; + padding: $o-we-item-spacing; + background-color: $o-we-bg-lighter; + + button { + --PreviewAlphaBg-background-size: 16px; + + @extend %o-preview-alpha-background; + padding: $o-we-item-spacing; + background-color: transparent; + } + // For background shapes + .button_shape { + grid-column: span 2; + padding: 0; + + button, div { + width: 100% !important; + height: 50px; + } + } + img { + width: 100%; + aspect-ratio: 1; + object-fit: contain; + } +} + +.o_we_shape_animated_label { + @include o-position-absolute(0, 0); + padding: 0 4px; + background: $o-we-toolbar-color-accent; + color: white; + + > span { + @include o-text-overflow(inline-block); + max-width: 0; + } +} +div:hover>.o_we_shape_animated_label { + i { + padding-right: $o-we-item-spacing / 2; + } + + > span { + max-width: $o-we-sidebar-width / 2; + transition: max-width 0.5s ease 0s; + } +} + +.o_pager_nav_angle { + @include button-variant($o-we-bg-light, $o-we-bg-light); + padding: $o-we-item-spacing / 2; + font-size: $o-we-sidebar-font-size * 1.4; +} + +@include media-breakpoint-down(md) { + .o_we_shape:not(.o_shape_show_mobile) { + display: none; + } +} diff --git a/addons/html_builder/static/src/builder.variables.scss b/addons/html_builder/static/src/builder.variables.scss new file mode 100644 index 0000000000000..6f18081d46630 --- /dev/null +++ b/addons/html_builder/static/src/builder.variables.scss @@ -0,0 +1,684 @@ +/// +/// This files regroups the variables and mixins which are specific to the editor. +/// + +//------------------------------------------------------------------------------ +// Odoo Editor UI +//------------------------------------------------------------------------------ + +$o-we-bg-darkest: #000000 !default; +$o-we-bg-darker: #141217 !default; +$o-we-bg-dark: #191922 !default; +$o-we-bg-light: #2b2b33 !default; +$o-we-bg-lighter: #3e3e46 !default; +$o-we-bg-lightest: #595964 !default; + +$o-we-fg-darker: #9d9d9d !default; +$o-we-fg-dark: #C6C6C6 !default; +$o-we-fg-light: #D9D9D9 !default; +$o-we-fg-lighter: #FFFFFF !default; + +$o-we-color-danger: #e6586c !default; +$o-we-color-warning: #f0ad4e !default; +$o-we-color-success: #40ad67 !default; +$o-we-color-info: #6999a8 !default; + +$o-we-bg: $o-we-bg-light !default; +$o-we-color: $o-we-fg-light !default; +$o-we-font-size: 13px !default; +$o-we-font-family: $o-font-family-sans-serif !default; +$o-we-accent: #01bad2 !default; +$o-we-border-width: 1px !default; +$o-we-border-color: $o-we-bg-light !default; + +// Needed to be changed to be high enough to not overflow when a user +// has a page with a lot of content (10000px was proven to be too small) +$o-we-handles-offset-to-hide: 100000px !default; +$o-we-handles-btn-size: 14px !default; +$o-we-handles-accent-color: $o-we-accent !default; +$o-we-handles-accent-color-preview: $o-enterprise-color !default; +$o-we-handle-edge-size: $o-we-handles-btn-size !default; +$o-we-handle-border-width: 2px !default; +$o-we-handle-inside-line-width: 3px !default; + +$o-we-dropzone-size: 30px !default; // $grid-gutter-width (todo: allow to use the variable) +$o-we-dropzone-border-width: 2px !default; +$o-we-dropzone-border: $o-we-dropzone-border-width dashed $o-brand-odoo !default; +$o-we-dropzone-accent-color: $o-we-accent !default; +$o-we-dropzone-bg-color: rgba($o-we-dropzone-accent-color, .5) !default; + +// Translations +$o-we-content-to-translate-color: rgba(255, 255, 90, 0.5) !default; +$o-we-translated-content-color: rgba(120, 215, 110, 0.5) !default; + +$o-we-toolbar-height: 40px !default; + +$o-we-item-spacing: 8px !default; +$o-we-item-border-width: 1px !default; +$o-we-item-border-color: transparent !default; +$o-we-item-border-radius: 4px !default; +$o-we-item-clickable-bg: $o-we-bg-lightest!default; +$o-we-item-clickable-color: $o-we-fg-light!default; +$o-we-item-clickable-hover-bg: $o-we-bg-dark!default; +$o-we-item-pressed-bg: $o-we-bg-light !default; +$o-we-item-pressed-color: $o-we-fg-lighter !default; + +$o-we-item-standup-color-light: $o-we-fg-lighter; +$o-we-item-standup-color-dark: $o-we-bg-darkest; +$o-we-item-standup-top: inset 0 1px 0; +$o-we-item-standup-bottom: inset 0 -1px 0; + +$o-we-dropdown-spacing: $o-we-item-spacing !default; +$o-we-dropdown-bg: $o-we-bg-darker !default; +$o-we-dropdown-border-width: 1px !default; +$o-we-dropdown-border-color: $o-we-bg-darkest !default; +$o-we-dropdown-shadow: 0 2px 8px 0 rgba(black, 0.5) !default; +$o-we-dropdown-item-height: 34px !default; +$o-we-dropdown-item-spacing: 1px !default; +$o-we-dropdown-item-bg: $o-we-bg-lightest !default; +$o-we-dropdown-item-bg-hover: $o-we-bg-light !default; +$o-we-dropdown-item-color: $o-we-fg-dark !default; +$o-we-dropdown-item-hover-color: $o-we-fg-light !default; +$o-we-dropdown-item-active-bg: mix($o-we-dropdown-item-bg, $o-we-dropdown-item-bg-hover) !default; +$o-we-dropdown-item-active-color: $o-we-fg-lighter !default; +$o-we-dropdown-caret-spacing: 2px !default; + +$o-we-sidebar-bg: $o-we-bg !default; +$o-we-sidebar-color: $o-we-color !default; +$o-we-sidebar-font-size: 12px !default; +$o-we-sidebar-border-width: $o-we-border-width !default; +$o-we-sidebar-border-color: $o-we-border-color !default; + +// This sidebar width cannot be increased at the moment, it is at the maximum +// value it can have, given our current specs, which is 1920px / 150% - 992px. +// - 1920px: the usual size of user screens, supposedly the browser one if the +// OS task bar is not anchored to the right/left. +// - 150%: this is actually the recommended Windows zoom (virtually decreasing +// the amount of available pixels to fit our UI). +// - 992px: the current minimum width the screen must have for our websites to +// be in "desktop" mode (below, columns break over multiple lines). +// +// If the sidebar is 1px larger, entering edit mode on such Full HD + 150% zoom +// will display the website in "mobile" mode (note it is the same with browser +// zoom or OS zoom). +// +// Notice that 1920px / 150% = 1280px which gives the minimum size of the screen +// that will display the website in "desktop" mode in the editor if no zoom is +// used, which seems like an acceptable value. +// +// Note: reducing the sidebar width even further to support more devices or +// more zoom / OS task bar configuration would be problematic as the sidebar +// would become too small. It is currently kinda at both its maximum and minimum +// authorized value. +// +// We tried solutions to virtually "de-zoom" the website iframe to display the +// website in "desktop" mode no matter what but this did not give great results. +// On problematic devices, the user still has the possibility to de-zoom its +// browser by himself. +$o-we-sidebar-width: 288px !default; // This includes $o-we-sidebar-border-width + +$o-we-sidebar-top-height: 46px !default; + +$o-we-sidebar-tabs-size-ratio: 1 !default; +$o-we-sidebar-tabs-height: 3rem; +$o-we-sidebar-tabs-bg: $o-we-bg-darker !default; +$o-we-sidebar-tabs-color: $o-we-sidebar-color !default; +$o-we-sidebar-tabs-disabled-color: $o-we-fg-darker !default; +$o-we-sidebar-tabs-active-border-width: 2px !default; +$o-we-sidebar-tabs-active-border-color: $o-we-accent !default; +$o-we-sidebar-tabs-active-color: $o-we-fg-lighter !default; + +$o-we-sidebar-blocks-content-bg: $o-we-bg-dark !default; +$o-we-sidebar-blocks-content-spacing: 10px !default; +$o-we-sidebar-blocks-content-snippet-spacing: 2px !default; +$o-we-sidebar-blocks-content-snippet-bg: $o-we-bg-lighter !default; + +$o-we-sidebar-content-highlight-bar-width: 2px !default; +$o-we-sidebar-content-highlight-bar-color: $o-we-accent !default; + +$o-we-sidebar-content-gutter-item-indent: 5px !default; +$o-we-sidebar-content-padding-base: 10px !default; +$o-we-sidebar-content-indent: $o-we-sidebar-content-gutter-item-indent + $o-we-sidebar-content-padding-base !default; +$o-we-sidebar-content-backdrop-bg: rgba(black, 0.2) !default; +$o-we-sidebar-content-available-room: $o-we-sidebar-width - $o-we-sidebar-content-padding-base - $o-we-sidebar-content-indent !default; + +$o-we-sidebar-content-main-title-height: 32px !default; +$o-we-sidebar-content-main-title-color: $o-we-fg-lighter !default; +$o-we-sidebar-content-main-title-font-size: 13px !default; + +$o-we-sidebar-content-block-spacing: 10px !default; + +$o-we-sidebar-content-fold-block-bg: $o-we-bg-lighter !default; + +$o-we-sidebar-content-field-spacing: $o-we-item-spacing !default; +$o-we-sidebar-content-field-color: $o-we-fg-darker !default; +$o-we-sidebar-content-field-control-item-color: $o-we-fg-darker !default; +$o-we-sidebar-content-field-control-item-size: 1em !default; +$o-we-sidebar-content-field-control-item-spacing: 0.5em !default; +$o-we-sidebar-content-field-label-spacing: 6px !default; + +$o-we-sidebar-content-field-label-width: $o-we-sidebar-content-available-room * .4 !default; +$o-we-sidebar-content-field-multi-spacing: $o-we-sidebar-content-field-label-spacing * .5 !default; +$o-we-sidebar-content-field-height: 22px !default; + +$o-we-sidebar-content-field-border-width: $o-we-item-border-width !default; +$o-we-sidebar-content-field-border-color:$o-we-item-border-color !default; +$o-we-sidebar-content-field-border-radius: $o-we-item-border-radius !default; +$o-we-sidebar-content-field-disabled-color: $o-we-sidebar-content-field-control-item-color !default; +$o-we-sidebar-content-field-clickable-bg: $o-we-item-clickable-bg !default; +$o-we-sidebar-content-field-clickable-color: $o-we-item-clickable-color !default; +$o-we-sidebar-content-field-clickable-spacing: $o-we-sidebar-content-field-label-spacing !default; +$o-we-sidebar-content-field-pressed-bg: $o-we-item-pressed-bg !default; +$o-we-sidebar-content-field-pressed-color: $o-we-item-pressed-color !default; + +$o-we-sidebar-content-field-dropdown-spacing: $o-we-dropdown-spacing !default; +$o-we-sidebar-content-field-dropdown-bg: $o-we-dropdown-bg !default; +$o-we-sidebar-content-field-dropdown-border-width: $o-we-dropdown-border-width !default; +$o-we-sidebar-content-field-dropdown-border-color: $o-we-dropdown-border-color !default; +$o-we-sidebar-content-field-dropdown-shadow: $o-we-dropdown-shadow !default; +$o-we-sidebar-content-field-dropdown-item-height: $o-we-dropdown-item-height !default; +$o-we-sidebar-content-field-dropdown-item-spacing: $o-we-dropdown-item-spacing !default; +$o-we-sidebar-content-field-dropdown-item-bg: $o-we-dropdown-item-bg !default; +$o-we-sidebar-content-field-dropdown-item-bg-hover: $o-we-dropdown-item-bg-hover !default; +$o-we-sidebar-content-field-dropdown-item-color: $o-we-dropdown-item-color !default; +$o-we-sidebar-content-field-dropdown-item-hover-color: $o-we-dropdown-item-hover-color !default; +$o-we-sidebar-content-field-dropdown-item-active-bg: $o-we-dropdown-item-active-bg !default; +$o-we-sidebar-content-field-dropdown-item-active-color: $o-we-dropdown-item-active-color !default; +$o-we-sidebar-content-field-dropdown-grid-item-height: 60px !default; +$o-we-sidebar-content-field-dropdown-grid-item-width: 80px !default; + +$o-we-sidebar-content-field-colorpicker-size: 20px !default; +$o-we-sidebar-content-field-colorpicker-size-large: 26px !default; +$o-we-sidebar-content-field-colorpicker-shadow: inset 0 0 0 1px rgba(white, 0.5) !default; +$o-we-sidebar-content-field-colorpicker-dropdown-bg: $o-we-bg-lighter !default; +$o-we-sidebar-content-field-colorpicker-dropdown-color: $o-we-fg-light !default; +$o-we-sidebar-content-field-colorpicker-dropdown-active-color: $o-we-fg-lighter !default; +$o-we-sidebar-content-field-colorpicker-cc-width: 208px !default; +$o-we-sidebar-content-field-colorpicker-cc-height: 26px !default; + +$o-we-sidebar-content-field-input-max-width: 60px !default; +$o-we-sidebar-content-field-input-bg: $o-we-bg-light !default; +$o-we-sidebar-content-field-input-font-family: $o-we-font-family !default; +$o-we-sidebar-content-field-input-unit-font-size: 11px !default; +$o-we-sidebar-content-field-input-border-color: $o-we-accent !default; + +$o-we-sidebar-content-field-button-group-button-spacing: $o-we-sidebar-content-field-clickable-spacing; + +$o-we-sidebar-content-field-progress-height: 4px !default; +$o-we-sidebar-content-field-progress-control-height: 10px !default; +$o-we-sidebar-content-field-progress-color: $o-we-fg-darker !default; +$o-we-sidebar-content-field-progress-active-color: $o-we-accent !default; + +$o-we-sidebar-content-field-toggle-width: 20px !default; +$o-we-sidebar-content-field-toggle-height: 12px !default; +$o-we-sidebar-content-field-toggle-bg: $o-we-fg-darker !default; +$o-we-sidebar-content-field-toggle-active-bg: $o-we-accent !default; +$o-we-sidebar-content-field-toggle-control-width: 11px !default; +$o-we-sidebar-content-field-toggle-control-height: $o-we-sidebar-content-field-toggle-height - 2px !default; +$o-we-sidebar-content-field-toggle-control-bg: $o-we-fg-lighter !default; + +$o-we-technical-modal-zindex: 2001; + +//------------------------------------------------------------------------------ +// Preview component Mixins +//------------------------------------------------------------------------------ + +@mixin o-we-preview-box($color-text: white) { + border-top: 1px solid black; + border-bottom: 1px solid white; + background-image: linear-gradient(-150deg, $o-we-bg-light, $o-we-bg-dark); + + color: $color-text; +} + +// ------------------------------------------------------------------ +// Selection wrapper +// ------------------------------------------------------------------ + +@mixin o-we-active-wrapper($icon: '\f00c', $top: auto, $right: auto, $bottom: auto, $left: auto) { + box-shadow: 0 0 0 3px $o-brand-primary; + + &:not(.fa) { + border: 3px solid $o-brand-primary; + box-shadow: none; + &:before { + content: $icon; + @include o-position-absolute($top, $right, $bottom, $left); + width: 19px; + height: 19px; + background-color: $o-brand-primary; + font-family: 'FontAwesome'; + color: white; + border-radius: 50%; + text-align: center; + z-index: 1; + box-shadow: $box-shadow; + } + } +} + +//------------------------------------------------------------------------------ +// Edited content +//------------------------------------------------------------------------------ + +$o-support-13-0-color-system: false !default; + +$o-checklist-margin-left: 20px; +$o-checklist-checkmark-width: 2px; +$o-checklist-before-size: 13px; + + +// Edition colors + +// Note: the "base" palettes contain all possible keys a palette should or +// must contain, with a default value which should work in use cases where it +// will be used. Any palette defined by an app will be merged with the base +// palette once selected to ensure it works. + +// Colors +$o-base-color-palette: ( + 'o-color-1': transparent, + 'o-color-2': transparent, + 'o-color-3': transparent, + 'o-color-4': transparent, + 'o-color-5': transparent, +) !default; +$o-color-palettes: ( + 'base-1': ( + 'o-color-1': $o-enterprise-color, + 'o-color-2': #2D3142, + 'o-color-3': #F3F2F2, + 'o-color-4': #FFFFFF, + 'o-color-5': #111827, + ), + 'base-2': ( + 'o-color-1': #337ab7, + 'o-color-2': #e9ecef, + 'o-color-3': #F8F9FA, + 'o-color-4': #FFFFFF, + 'o-color-5': #343a40, + ), +) !default; +$o-color-palette-name: 'base-1' !default; + +// Theme colors +$o-base-theme-color-palette: () !default; +$o-theme-color-palettes: ( + // alpha -> epsilon are old color names kept for compatibility. + // They should not be used in the code base anymore and ideally they will + // not generate any classes for >= 13.4 databases. + 'base-1': ( + 'alpha': $o-enterprise-action-color, + 'beta': $o-enterprise-color, + 'gamma': #5C5B80, + 'delta': #5B899E, + 'epsilon': #E46F78, + ), +) !default; +$o-theme-color-palette-name: 'base-1' !default; + +// Greyscale transparent colours + +// Note: BS values are forced by default in every palette as the values can +// be used in bootstrap_overridden.scss files through the o-color function. +// Also, all of the gray colors generates bg- classes in Odoo so black and white +// are added for the same reason. + +$o-base-gray-color-palette: ( + 'white': #FFFFFF, + '100': #F8F9FA, + '200': #E9ECEF, + '300': #DEE2E6, + '400': #CED4DA, + '500': #ADB5BD, + '600': #6C757D, + '700': #495057, + '800': #343A40, + '900': #212529, + 'black': #000000, +) !default; +$o-transparent-grays: ( + 'black-15': rgba(black, 0.15), + 'black-25': rgba(black, 0.25), + 'black-50': rgba(black, 0.5), + 'black-75': rgba(black, 0.75), + 'white-25': rgba(white, 0.25), + 'white-50': rgba(white, 0.5), + 'white-75': rgba(white, 0.75), + 'white-85': rgba(white, 0.85), +) !default; +$o-gray-color-palettes: () !default; +$o-gray-color-palette-name: '' !default; + +// Color combinations +$o-base-color-combination: ( + 'bg': 'white', + 'text': null, // Default to better contrast with the 'bg' + 'headings': null, // Default to 'text' + 'h2': null, // Default to 'h(x-1)' + 'h3': null, + 'h4': null, + 'h5': null, + 'h6': null, + 'link': null, // Default to BS 'primary' (= first odoo color) + 'btn-primary': null, // Default to BS 'primary' (= first odoo color) + 'btn-primary-border': null, // Default to 'btn-primary' + 'btn-secondary': null, // Default to BS 'secondary' (= second odoo color) + 'btn-secondary-border': null, // Default to 'btn-secondary' +); +$o-color-combinations-presets: ( + ( + ( + 'bg': 'o-color-4', + ), + ( + 'bg': 'o-color-3', + 'headings': 'o-color-5', + ), + ( + 'bg': 'o-color-2', + 'btn-secondary': 'o-color-3', + ), + ( + 'bg': 'o-color-1', + 'link': 'o-color-5', + 'btn-primary': 'o-color-5', + 'btn-secondary': 'o-color-3', + ), + ( + 'bg': 'o-color-5', + 'headings': 'o-color-4', + 'btn-secondary': 'o-color-3', + ), + ), +) !default; +$o-color-combinations-preset-number: 1; + +// We allow snippets to be colored and elements like card and columns to be +// colored as well. We need components targeted by those colored classes to +// use the deepest coloring element config. We only allow here for this to +// work for one level of nesting. Note: snippets which can contain other +// snippets will have problem because of this; this is a limitation of the +// system until a better solution is found. +$o-color-extras-nesting-selector: '&, .o_colored_level &'; + +// Apply colors according to the given identifier. Can either be a preset +// number, a color name or a css color. +@mixin o-apply-colors($identifier, $with-extras: true, $background: $body-bg) { + $-related-color: o-related-color($identifier, $max-recursions: 10); + @if type-of($-related-color) == 'number' { + // This is a preset to be applied, just extend it. This should probably + // be avoided and use the class in XML if possible. + @extend .o_cc; + @extend .o_cc#{$-related-color}; + } @else { + @include o-bg-color(o-color($-related-color), $with-extras: $with-extras, $background: $background, $important: false); + } +} + +// Function which returns if a color has contrast enough in comparaison to +// another given color. +@function has-enough-contrast($color1, $color2, $threshold: 500) { + $r: (max(red($color1), red($color2))) - (min(red($color1), red($color2))); + $g: (max(green($color1), green($color2))) - (min(green($color1), green($color2))); + $b: (max(blue($color1), blue($color2))) - (min(blue($color1), blue($color2))); + $sum-rgb: $r + $g + $b; + @return ($sum-rgb >= $threshold); +} + +// Function which transforms a color to increase its contrast in comparison to +// another given color. +@function increase-contrast($color1, $color2) { + @if not $color1 or not $color2 { + @return null; + } + $luma-c1: luma($color1); + $luma-c2: luma($color2); + $lightness-c1: lightness($color1); + $lightness-inc: if($luma-c1 < $luma-c2, -1%, 1%); + $i: 0; + // Max 25% lightness change even if not contrasted enough + @while ($lightness-c1 > 0.1% and $lightness-c1 < 99.9% and $i < 25 and not has-enough-contrast($color1, $color2)) { + $color1: adjust-color($color1, $lightness: $lightness-inc); + $lightness-c1: $lightness-c1 + $lightness-inc; + $i: $i + 1; + } + @return $color1; +} + +// Given a primary color (and eventually a secondary one), the function returns +// a basic odoo palette in sass-map format. The palette will be generated using +// the safest readability values possible. +@function o-make-palette($-primary, $-secondary: null, $-overrides-map: null) { + $-o-color-2: $-secondary or increase-contrast(desaturate(mix(complement($-primary), #FFFFFF, 80%), 20%), $-primary); + + $-palette: ( + 'o-color-1': $-primary, + 'o-color-2': $-o-color-2, + 'o-color-3': change-color(#F5F0F0, $hue: hue($-primary), $saturation: min(saturation($-primary), saturation(#F5F0F0))), + 'o-color-4': #FFFFFF, + 'o-color-5': change-color(#2e1414, $hue: hue($-primary), $saturation: min(saturation($-primary), saturation(#2e1414))), + ); + + // Check if primary/dark contrast is enough. If not adapt cc4 & cc5 schemes accordingly + @if not (has-enough-contrast(map-get($-palette, 'o-color-5'), map-get($-palette, 'o-color-1'), 300)) { + @each $-cc in (4, 5) { + $-palette: map-merge($-palette, ( + 'o-cc#{$-cc}-btn-primary': 'o-color-4', + 'o-cc#{$-cc}-btn-secondary': 'o-color-2', + 'o-cc#{$-cc}-text': 'o-color-3', + 'o-cc#{$-cc}-link': 'o-color-4' + )); + } + } + + @if $-overrides-map { + $-palette: map-merge($-palette, $-overrides-map); + } + + @return $-palette; +} + +// format: (module_name: (shape_filename: ('position': X, 'size': Y, 'colors': (1, [3], ...)), ...)) +$o-bg-shapes: ('web_editor': ( + 'Airy/01': ('position': bottom, 'size': 100% auto, 'colors': (1)), + 'Airy/02': ('position': top, 'size': 100% auto, 'colors': (1)), + 'Airy/03': ('position': top, 'size': 100% auto, 'colors': (5)), + 'Airy/03_001': ('position': top, 'size': 100% auto, 'colors': (5)), + 'Airy/04': ('position': center, 'size': 100% 100%, 'colors': (1)), + 'Airy/04_001': ('position': center, 'size': 100% 100%, 'colors': (1)), + 'Airy/05': ('position': center, 'size': 100% 100%, 'colors': (1)), + 'Airy/05_001': ('position': center, 'size': 100% 100%, 'colors': (1)), + 'Airy/06': ('position': bottom, 'size': 100% auto, 'colors': (2)), + 'Airy/07': ('position': top, 'size': 100% auto, 'colors': (2)), + 'Airy/08': ('position': bottom, 'size': 100% auto, 'colors': (1)), + 'Airy/09': ('position': top, 'size': 100% auto, 'colors': (1)), + 'Airy/10': ('position': bottom, 'size': 100% auto, 'colors': (5)), + 'Airy/11': ('position': top, 'size': 100% auto, 'colors': (5)), + 'Airy/12': ('position': top, 'size': 100% auto, 'colors': (1, 3)), + 'Airy/12_001': ('position': top, 'size': 100% auto, 'colors': (1, 3)), + 'Airy/13': ('position': bottom, 'size': 100% auto, 'colors': (1, 4)), + 'Airy/13_001': ('position': bottom, 'size': 100% auto, 'colors': (1, 4)), + 'Airy/14': ('position': bottom, 'size': 100% auto, 'colors': (1, 4)), + 'Blobs/01': ('position': top, 'size': 100% auto, 'colors': (2)), + 'Blobs/01_001': ('position': top, 'size': 100% auto, 'colors': (2)), + 'Blobs/02': ('position': bottom, 'size': 100% auto, 'colors': (1, 2)), + 'Blobs/03': ('position': top, 'size': 100% auto, 'colors': (2)), + 'Blobs/04': ('position': center, 'size': 100% auto, 'colors': (5)), + 'Blobs/05': ('position': bottom, 'size': 100% auto, 'colors': (1)), + 'Blobs/06': ('position': top, 'size': 100% auto, 'colors': (1)), + 'Blobs/07': ('position': top, 'size': 100% auto, 'colors': (5)), + 'Blobs/08': ('position': right, 'size': 100% auto, 'colors': (1)), + 'Blobs/09': ('position': bottom, 'size': 100% auto, 'colors': (3)), + 'Blobs/10': ('position': top, 'size': 100% auto, 'colors': (1, 5)), + 'Blobs/10_001': ('position': top, 'size': 100% auto, 'colors': (1)), + 'Blobs/11': ('position': center, 'size': 100% auto, 'colors': (1)), + 'Blobs/12': ('position': bottom, 'size': 100% auto, 'colors': (1)), + 'Blocks/01': ('position': bottom, 'size': 100% auto, 'colors': (1, 3, 5)), + 'Blocks/01_001': ('position': top, 'size': 100% auto, 'colors': (1, 3, 5)), + 'Blocks/02': ('position': top, 'size': 100% auto, 'colors': (1, 3, 5)), + 'Blocks/02_001': ('position': bottom, 'size': 100% auto, 'colors': (1, 3, 5)), + 'Blocks/03': ('position': bottom, 'size': 100% auto, 'colors': (1, 4)), + 'Blocks/04': ('position': bottom, 'size': 100% auto, 'colors': (1, 2, 3, 5)), + 'Bold/01': ('position': top, 'size': 100% auto, 'colors': (2)), + 'Bold/02': ('position': bottom, 'size': 100% auto, 'colors': (1, 2, 3)), + 'Bold/03': ('position': bottom, 'size': 100% auto, 'colors': (1, 3, 5)), + 'Bold/04': ('position': top, 'size': 100% auto, 'colors': (2, 3)), + 'Bold/05': ('position': center, 'size': 100% auto, 'colors': (5)), + 'Bold/05_001': ('position': center, 'size': 100% auto, 'colors': (3)), + 'Bold/06': ('position': center, 'size': 100% auto, 'colors': (5)), + 'Bold/06_001': ('position': center, 'size': 100% auto, 'colors': (3)), + 'Bold/07': ('position': bottom, 'size': 100% auto, 'colors': (1, 2)), + 'Bold/07_001': ('position': bottom, 'size': 100% auto, 'colors': (1, 2)), + 'Bold/08': ('position': top, 'size': 100% auto, 'colors': (1)), + 'Bold/09': ('position': bottom, 'size': 100% auto, 'colors': (2, 3)), + 'Bold/10': ('position': top, 'size': 100% auto, 'colors': (1, 3, 4, 5)), + 'Bold/10_001': ('position': top, 'size': 100% auto, 'colors': (1, 4, 5)), + 'Bold/11': ('position': bottom, 'size': 100% auto, 'colors': (1, 2, 3)), + 'Bold/11_001': ('position': bottom, 'size': 100% auto, 'colors': (1, 2)), + 'Bold/12': ('position': center, 'size': 100% auto, 'colors': (1, 2, 5)), + 'Bold/12_001': ('position': center, 'size': 100% auto, 'colors': (1, 2, 5)), + 'Floats/01': ('position': center right, 'size': auto 100%, 'colors': (1, 2, 3, 4, 5)), + 'Floats/02': ('position': center, 'size': 100%, 'colors': (1, 2, 3, 5)), + 'Floats/03': ('position': center, 'size': 100%, 'colors': (1, 2, 3, 5)), + 'Floats/04': ('position': center, 'size': 100%, 'colors': (1, 2, 4, 5)), + 'Floats/05': ('position': center, 'size': 100%, 'colors': (1, 2, 3, 5)), + 'Floats/06': ('position': center, 'size': auto 100%, 'colors': (1, 2, 3, 5)), + 'Floats/07': ('position': right bottom, 'size': auto 100%, 'colors': (1, 2, 3, 5)), + 'Floats/08': ('position': top left, 'size': auto 100%, 'colors': (1, 2, 3, 5)), + 'Floats/09': ('position': center right, 'size': auto 100%, 'colors': (1, 2, 3)), + 'Floats/10': ('position': center, 'size': 100% auto, 'colors': (1, 2, 3, 5)), + 'Floats/11': ('position': center, 'size': 100% 100%, 'colors': (1, 3)), + 'Floats/12': ('position': top, 'size': 100% auto, 'colors': (1, 2, 3, 5), 'repeat-y': true), + 'Floats/13': ('position': center, 'size': auto 100%, 'colors': (1, 2, 5)), + 'Floats/14': ('position': center, 'size': 100%, 'colors': (1, 2, 3, 5), 'repeat-y': true), + 'Origins/01': ('position': bottom, 'size': 100% auto, 'colors': (2, 5)), + 'Origins/02': ('position': bottom, 'size': 100% auto, 'colors': (3)), + 'Origins/02_001': ('position': bottom, 'size': 100% auto, 'colors': (4, 5)), + 'Origins/03': ('position': top, 'size': 100% auto, 'colors': (3)), + 'Origins/04': ('position': bottom, 'size': 100% auto, 'colors': (3)), + 'Origins/04_001': ('position': top, 'size': 100% 100%, 'colors': (3)), + 'Origins/05': ('position': top, 'size': 100% auto, 'colors': (3)), + 'Origins/06': ('position': center, 'size': 100% auto, 'colors': (3)), + 'Origins/06_001': ('position': center, 'size': 100% auto, 'colors': (3, 4)), + 'Origins/07': ('position': center, 'size': 100% 100%, 'colors': (3)), + 'Origins/07_001': ('position': center, 'size': 100% 100%, 'colors': (3, 5)), + 'Origins/07_002': ('position': center, 'size': 100% 100%, 'colors': (3, 4, 5)), + 'Origins/08': ('position': bottom, 'size': 100% auto, 'colors': (3)), + 'Origins/09': ('position': top, 'size': 100% auto, 'colors': (1, 5)), + 'Origins/09_001': ('position': top, 'size': 100% auto, 'colors': (3)), + 'Origins/10': ('position': bottom, 'size': 100% auto, 'colors': (2, 5)), + 'Origins/11': ('position': top, 'size': 100% auto, 'colors': (3, 5)), + 'Origins/11_001': ('position': top, 'size': 100% auto, 'colors': (3, 4)), + 'Origins/12': ('position': top, 'size': 100% auto, 'colors': (3, 5)), + 'Origins/13': ('position': center, 'size': 100% auto, 'colors': (3, 5)), + 'Origins/14': ('position': bottom, 'size': 100% auto, 'colors': (4)), + 'Origins/14_001': ('position': bottom, 'size': 100% auto, 'colors': (3, 4)), + 'Origins/15': ('position': top, 'size': 100% auto, 'colors': (4)), + 'Origins/16': ('position': center, 'size': 100% 100%, 'colors': (3)), + 'Origins/17': ('position': center, 'size': 100% 100%, 'colors': (3)), + 'Origins/18': ('position': center, 'size': 100% 100%, 'colors': (1)), + 'Origins/19': ('position': center, 'size': 100% 100%, 'colors': (5)), + 'Rainy/01': ('position': bottom, 'size': 100% auto, 'colors': (1, 5)), + 'Rainy/01_001': ('position': bottom, 'size': 100% auto, 'colors': (1, 5)), + 'Rainy/02': ('position': top, 'size': 100% auto, 'colors': (1, 4, 5)), + 'Rainy/02_001': ('position': top, 'size': 100% auto, 'colors': (1, 4, 5)), + 'Rainy/03': ('position': top, 'size': 100% auto, 'colors': (2, 4, 5), 'repeat-y': true), + 'Rainy/03_001': ('position': top, 'size': 100% auto, 'colors': (2, 5), 'repeat-y': true), + 'Rainy/04': ('position': top, 'size': 100% auto, 'colors': (1, 5)), + 'Rainy/05': ('position': top, 'size': 100% auto, 'colors': (1, 5)), + 'Rainy/05_001': ('position': top, 'size': 100% auto, 'colors': (1)), + 'Rainy/06': ('position': bottom, 'size': 100% auto, 'colors': (1, 2, 3)), + 'Rainy/07': ('position': top, 'size': 100% auto, 'colors': (1, 2, 3)), + 'Rainy/08': ('position': top, 'size': 100% auto, 'colors': (1, 4)), + 'Rainy/08_001': ('position': top, 'size': 100% auto, 'colors': (1, 4)), + 'Rainy/09': ('position': top, 'size': 100% auto, 'colors': (1)), + 'Rainy/09_001': ('position': top, 'size': 100% auto, 'colors': (1)), + 'Rainy/10': ('position': center, 'size': 100% auto, 'colors': (1, 3)), + 'Wavy/01': ('position': bottom, 'size': 100% auto, 'colors': (4)), + 'Wavy/01_001': ('position': bottom, 'size': 100% auto, 'colors': (3)), + 'Wavy/02': ('position': top, 'size': 100% auto, 'colors': (4)), + 'Wavy/02_001': ('position': top, 'size': 100% auto, 'colors': (3)), + 'Wavy/03': ('position': top, 'size': 100% auto, 'colors': (1, 2)), + 'Wavy/04': ('position': bottom, 'size': 100% auto, 'colors': (1, 5)), + 'Wavy/05': ('position': top, 'size': 100% auto, 'colors': (1, 5)), + 'Wavy/06': ('position': top, 'size': 100% auto, 'colors': (1, 3, 4, 5)), + 'Wavy/06_001': ('position': top, 'size': 100% auto, 'colors': (1, 3, 5)), + 'Wavy/07': ('position': top, 'size': 100% auto, 'colors': (3)), + 'Wavy/08': ('position': top, 'size': 100% auto, 'colors': (2)), + 'Wavy/09': ('position': bottom, 'size': 100% auto, 'colors': (1, 5)), + 'Wavy/10': ('position': center, 'size': 100% auto, 'colors': (1, 2)), + 'Wavy/11': ('position': bottom, 'size': 100% auto, 'colors': (1, 4)), + 'Wavy/12': ('position': top, 'size': 100% auto, 'colors': (1)), + 'Wavy/12_001': ('position': bottom, 'size': 100% auto, 'colors': (1)), + 'Wavy/13': ('position': bottom, 'size': 100% auto, 'colors': (4)), + 'Wavy/13_001': ('position': bottom, 'size': 100% auto, 'colors': (1)), + 'Wavy/14': ('position': bottom, 'size': 100% auto, 'colors': (1, 3)), + 'Wavy/15': ('position': top, 'size': 100% auto, 'colors': (1)), + 'Wavy/16': ('position': bottom, 'size': 100% auto, 'colors': (1)), + 'Wavy/17': ('position': top, 'size': 100% auto, 'colors': (1)), + 'Wavy/18': ('position': bottom, 'size': 100% auto, 'colors': (5)), + 'Wavy/19': ('position': top, 'size': 100% auto, 'colors': (5)), + 'Wavy/20': ('position': bottom, 'size': 100% auto, 'colors': (2)), + 'Wavy/21': ('position': top, 'size': 100% auto, 'colors': (2)), + 'Wavy/22': ('position': bottom, 'size': 100% auto, 'colors': (3)), + 'Wavy/23': ('position': top, 'size': 100% auto, 'colors': (3)), + 'Wavy/24': ('position': center, 'size': 100% auto, 'colors': (1, 2)), + 'Wavy/25': ('position': top, 'size': 100% auto, 'colors': (1, 2)), + 'Wavy/26': ('position': bottom right, 'size': auto 100%, 'colors': (1, 2)), + 'Wavy/27': ('position': center, 'size': 100% auto, 'colors': (1, 2)), + 'Wavy/28': ('position': center, 'size': 100% 100%, 'colors': (1, 3)), + 'Zigs/01': ('position': bottom, 'size': 100% auto, 'colors': (2)), + 'Zigs/01_001': ('position': bottom, 'size': 100% auto, 'colors': (2)), + 'Zigs/02': ('position': bottom, 'size': 100% auto, 'colors': (2)), + 'Zigs/02_001': ('position': bottom, 'size': 100% auto, 'colors': (2)), + 'Zigs/03': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': true), + 'Zigs/04': ('position': bottom, 'size': 100% auto, 'colors': (1)), + 'Zigs/05': ('position': bottom, 'size': 100% auto, 'colors': (3)), + 'Zigs/06': ('position': bottom, 'size': 30px 100%, 'colors': (4, 5), 'repeat-x': true), +)); + +@function change-shape-colors-mapping($module, $shape-name, $mapping, $shapes: $o-bg-shapes) { + $-module-shapes: map-get($shapes, $module); + $-modified-module-shapes: map-merge($-module-shapes, ( + $shape-name: map-merge(map-get($-module-shapes, $shape-name), ('color-to-cc-bg-map': $mapping)), + )); + @return map-merge($shapes, ( + $module: $-modified-module-shapes, + )); +} + +@function add-extra-shape-colors-mapping($module, $shape-name, $mapping-name, $mapping, $shapes: $o-bg-shapes) { + $-module-shapes: map-get($shapes, $module); + $-shape-data: map-get($-module-shapes, $shape-name); + $-extra-mappings: map-get($-shape-data, 'extra-mappings') or (); + $-modified-module-shapes: map-merge($-module-shapes, ( + $shape-name: map-merge($-shape-data, ('extra-mappings': map-merge($-extra-mappings, ($mapping-name: $mapping)))), + )); + @return map-merge($shapes, ( + $module: $-modified-module-shapes, + )); +} + +@function add-header-shape-colors-mapping($module, $shape-name, $mapping, $shapes: $o-bg-shapes) { + @return add-extra-shape-colors-mapping($module, $shape-name, 'header', $mapping, $shapes); +} + +@function add-footer-shape-colors-mapping($module, $shape-name, $mapping, $shapes: $o-bg-shapes) { + @return add-extra-shape-colors-mapping($module, $shape-name, 'footer', $mapping, $shapes); +} + +@mixin o-input-number-no-arrows() { + // Remove arrows/spinners from input type number + // => Chrome, Safari, Edge, Opera + input::-webkit-outer-spin-button, + input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + // => Firefox + input[type=number] { + -moz-appearance: textfield; + } +}; diff --git a/addons/html_builder/static/src/builder.xml b/addons/html_builder/static/src/builder.xml new file mode 100644 index 0000000000000..c328f25c62f0e --- /dev/null +++ b/addons/html_builder/static/src/builder.xml @@ -0,0 +1,43 @@ + + + + +
+
+
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + + theme + +
+ +
+
+ +
diff --git a/addons/html_builder/static/src/core/building_blocks/basic_many2many.js b/addons/html_builder/static/src/core/building_blocks/basic_many2many.js new file mode 100644 index 0000000000000..c81c908b00e60 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/basic_many2many.js @@ -0,0 +1,124 @@ +import { Component, useRef, useState, onWillStart, onWillUpdateProps } from "@odoo/owl"; +import { useService, useAutofocus } from "@web/core/utils/hooks"; +import { debounce } from "@web/core/utils/timing"; +import { basicContainerBuilderComponentProps } from "./utils"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { useCachedModel } from "@html_builder/core/plugins/cached_model_utils"; + +export class BasicMany2ManySearchInput extends Component { + static template = "html_builder.BasicMany2ManySearchInput"; + static props = { + onSearch: Function, + toFocus: Function, + }; + setup() { + useAutofocus(); + const inputRef = useRef("autofocus"); + this.props.toFocus(() => inputRef.el.focus()); + } +} + +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 defaultProps = { + fields: [], + domain: [], + limit: 10, + }; + static components = { Dropdown, DropdownItem, BasicMany2ManySearchInput }; + + setup() { + this.searchInputFocusCallback = undefined; + this.orm = useService("orm"); + this.cachedModel = useCachedModel(); + this.openerRef = useRef("opener"); + this.createInputRef = useRef("createInput"); + this.state = useState({ + createEnabled: false, + searchResults: [], + }); + this.onSearch = debounce(this.search.bind(this), 300); + onWillStart(async () => { + await this.handleProps(this.props); + }); + onWillUpdateProps(async (newProps) => { + await this.handleProps(newProps); + }); + } + async handleProps(props) { + this.state.searchResults = []; + } + setSearchInputFocusCallback(callback) { + this.searchInputFocusCallback = callback; + } + search(ev) { + this._search(ev.target.value); + } + searchMore() { + this.searchInputFocusCallback(); + } + async _search(searchValue) { + const tuples = await this.orm.call(this.props.model, "name_search", [], { + name: searchValue, + args: Object.values(this.props.domain).filter((item) => item !== null), + operator: "ilike", + limit: this.props.limit + 1, + }); + this.state.searchMore = tuples.length > this.props.limit; + if (this.props.fields.length) { + const fields = this.props.fields.includes("name") + ? this.props.fields + : ["name", ...this.props.fields]; + this.state.searchResults = await this.cachedModel.ormRead( + this.props.model, + tuples.map(([id, _name]) => id), + fields + ); + } else { + this.state.searchResults = []; + for (const tuple of tuples.slice(0, this.props.limit)) { + this.state.searchResults.push({ + id: tuple[0], + name: tuple[1], + }); + } + } + } + select(entry) { + this.props.setSelection([...this.props.selection, entry]); + } + unselect(id) { + this.props.setSelection([...this.props.selection.filter((item) => item.id !== id)]); + } + async onCreateInput() { + const name = this.createInputRef.el.value; + 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.selection.map((item) => item.name), + ]; + this.state.createEnabled = name.length > 0 && !usedNames.includes(name); + } + create() { + const name = this.createInputRef.el.value; + this.props.create(name); + this.openerRef.el.click(); // close dropdown + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/basic_many2many.xml b/addons/html_builder/static/src/core/building_blocks/basic_many2many.xml new file mode 100644 index 0000000000000..ea6fa5c88d55b --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/basic_many2many.xml @@ -0,0 +1,47 @@ + + + + + + + + +
+ + + + + +
+ + +
+ + + +
+ +
+ + + +
+ + + + +
Search more...
+
+ +
+ + +
+
+ + +
+
+ + diff --git a/addons/html_builder/static/src/core/building_blocks/builder_button.js b/addons/html_builder/static/src/core/building_blocks/builder_button.js new file mode 100644 index 0000000000000..4a31100e84df5 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_button.js @@ -0,0 +1,56 @@ +import { Component } from "@odoo/owl"; +import { clickableBuilderComponentProps, useSelectableItemComponent } from "./utils"; +import { BuilderComponent } from "./builder_component"; + +export class BuilderButton extends Component { + static template = "html_builder.BuilderButton"; + static components = { BuilderComponent }; + static props = { + ...clickableBuilderComponentProps, + + id: { type: String, optional: true }, + 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: "primary", + }; + + setup() { + const { state, operation } = useSelectableItemComponent(this.props.id); + this.state = state; + this.onClick = operation.commit; + this.onMouseenter = operation.preview; + this.onMouseleave = operation.revert; + } + + get className() { + let className = this.props.className || ""; + className += ` btn-${this.props.type}`; + if (this.state.isActive) { + className = `active ${className}`; + if (this.props.classActive) { + className += ` ${this.props.classActive}`; + } + } + if (!this.props.icon) { + return className; + } + if (this.props.icon.startsWith("fa-")) { + return className + ` fa fa-fw ${this.props.icon}`; + } else if (this.props.icon.startsWith("oi-")) { + return className + ` oi oi-fw ${this.props.icon}`; + } + return className; + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_button.xml b/addons/html_builder/static/src/core/building_blocks/builder_button.xml new file mode 100644 index 0000000000000..608fcbac1998e --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_button.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/addons/html_builder/static/src/core/building_blocks/builder_button_group.js b/addons/html_builder/static/src/core/building_blocks/builder_button_group.js new file mode 100644 index 0000000000000..0a66a73256d3d --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_button_group.js @@ -0,0 +1,24 @@ +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, + id: { type: String, optional: true }, + slots: { type: Object, optional: true }, + }; + static components = { BuilderComponent }; + + setup() { + useVisibilityObserver("root", useApplyVisibility("root")); + + useSelectableComponent(this.props.id); + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_button_group.xml b/addons/html_builder/static/src/core/building_blocks/builder_button_group.xml new file mode 100644 index 0000000000000..5d085f7896205 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_button_group.xml @@ -0,0 +1,12 @@ + + + + + +
+ +
+
+
+ +
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_checkbox.js b/addons/html_builder/static/src/core/building_blocks/builder_checkbox.js new file mode 100644 index 0000000000000..be2bb993da77f --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_checkbox.js @@ -0,0 +1,33 @@ +import { Component } from "@odoo/owl"; +import { CheckBox } from "@web/core/checkbox/checkbox"; +import { + clickableBuilderComponentProps, + 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, + id: { type: String, optional: true }, + }; + + setup() { + const { operation, isApplied } = useClickableBuilderComponent(); + if (this.props.id) { + useDependencyDefinition(this.props.id, { isActive: isApplied }); + } + this.state = useDomState(() => ({ + isActive: isApplied(), + })); + this.onChange = operation.commit; + } + + getClassName() { + return "o_field_boolean o_boolean_toggle form-switch"; + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_checkbox.xml b/addons/html_builder/static/src/core/building_blocks/builder_checkbox.xml new file mode 100644 index 0000000000000..994b5449c7fa0 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_checkbox.xml @@ -0,0 +1,12 @@ + + + + + +
+ +
+
+
+ +
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_colorpicker.js b/addons/html_builder/static/src/core/building_blocks/builder_colorpicker.js new file mode 100644 index 0000000000000..c47dda33a1903 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_colorpicker.js @@ -0,0 +1,110 @@ +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, +} from "./utils"; +import { isColorGradient } from "@web/core/utils/colors"; + +// 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.makePreviewableOperation((applySpecs) => { + for (const applySpec of applySpecs) { + let actionValue = applySpec.actionValue; + if (actionValue.startsWith("color-prefix-")) { + actionValue = `var(${actionValue.replace("color-prefix-", "--")})`; + } + applySpec.apply({ + editingElement: applySpec.editingElement, + param: applySpec.actionParam, + value: actionValue, + loadResult: applySpec.loadResult, + dependencyManager: comp.env.dependencyManager, + }); + } + }); + 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, param: actionParam }); + return { + selectedColor: actionValue, + }; + } + + function onApply(colorValue) { + callOperation(applyOperation.commit, { userInputValue: colorValue }); + } + let onPreview = (colorValue) => { + callOperation(applyOperation.preview, { + userInputValue: colorValue, + operationParams: { + cancellable: true, + cancelPrevious: () => applyOperation.revert(), + }, + }); + }; + if ( + comp.props.preview === false || + (comp.env.weContext.preview === false && comp.props.preview !== true) + ) { + onPreview = () => {}; + } + return { + state, + onApply, + onPreview, + onPreviewRevert: () => applyOperation.revert(), + }; +} + +export class BuilderColorPicker extends Component { + static template = "html_builder.BuilderColorPicker"; + static props = { + ...basicContainerBuilderComponentProps, + noTransparency: { type: Boolean, optional: true }, + unit: { type: String, optional: true }, + title: { type: String, optional: true }, + }; + static components = { + ColorSelector: ColorSelector, + BuilderComponent, + }; + + setup() { + useBuilderComponent(); + const { state, onApply, onPreview, onPreviewRevert } = useColorPickerBuilderComponent(); + this.colorButton = useRef("colorButton"); + this.state = state; + useColorPicker("colorButton", { + state, + applyColor: onApply, + applyColorPreview: onPreview, + applyColorResetPreview: onPreviewRevert, + getUsedCustomColors: () => [], + colorPrefix: "color-prefix-", + noTransparency: this.props.noTransparency, + }); + } + + getSelectedColorStyle() { + if (isColorGradient(this.state.selectedColor)) { + return `background-image: ${this.state.selectedColor}`; + } + return `background-color: ${this.state.selectedColor}`; + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_colorpicker.xml b/addons/html_builder/static/src/core/building_blocks/builder_colorpicker.xml new file mode 100644 index 0000000000000..92d007e6167c3 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_colorpicker.xml @@ -0,0 +1,10 @@ + + + + + +
+ + + + diff --git a/addons/html_builder/static/src/core/building_blocks/builder_component.js b/addons/html_builder/static/src/core/building_blocks/builder_component.js new file mode 100644 index 0000000000000..c7cc87b460a48 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_component.js @@ -0,0 +1,15 @@ +import { Component, xml } from "@odoo/owl"; +import { useDomState } from "./utils"; + +export class BuilderComponent extends Component { + static template = xml``; + static props = { + slots: { type: Object }, + }; + + setup() { + this.state = useDomState((editingElement) => ({ + isVisible: !!editingElement, + })); + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_context.js b/addons/html_builder/static/src/core/building_blocks/builder_context.js new file mode 100644 index 0000000000000..79558936d74c7 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_context.js @@ -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` + + + + `; + static props = { + ...basicContainerBuilderComponentProps, + slots: { type: Object }, + }; + static components = { + BuilderComponent, + }; + + setup() { + useBuilderComponent(); + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_datetimepicker.js b/addons/html_builder/static/src/core/building_blocks/builder_datetimepicker.js new file mode 100644 index 0000000000000..0be69f9cacfd6 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_datetimepicker.js @@ -0,0 +1,115 @@ +import { Component } from "@odoo/owl"; +import { useDateTimePicker } from "@web/core/datetime/datetime_hook"; +import { ConversionError, formatDateTime, parseDateTime } from "@web/core/l10n/dates"; +import { pick } from "@web/core/utils/objects"; +import { BuilderComponent } from "./builder_component"; +import { BuilderTextInputBase, textInputBasePassthroughProps } from "./builder_text_input_base"; +import { basicContainerBuilderComponentProps, useBuilderComponent, useInputBuilderComponent } from "./utils"; + +const { DateTime } = luxon; + +export class BuilderDateTimePicker extends Component { + static template = "html_builder.BuilderDateTimePicker"; + static props = { + ...basicContainerBuilderComponentProps, + ...textInputBasePassthroughProps, + id: { type: String, optional: true }, + default: { type: String, optional: true }, + type: { type: [{ value: "date" }, { value: "datetime" }], optional: true }, + format: { type: String, optional: true }, + }; + static defaultProps = { + type: "datetime", + }; + static components = { + BuilderComponent, + BuilderTextInputBase, + }; + + setup() { + useBuilderComponent(); + const { state, commit, preview } = useInputBuilderComponent({ + id: this.props.id, + defaultValue: this.props.default, + formatRawValue: this.formatRawValue.bind(this), + parseDisplayValue: this.parseDisplayValue.bind(this), + }); + this.commit = commit; + this.preview = preview; + this.state = state; + + const getPickerProps = () => ({ + type: this.props.type, + minDate: DateTime.fromObject({ year: 1000 }), + maxDate: DateTime.now().plus({ year: 200 }), + value: this.getCurrentValueDateTime(), + rounding: 0, + }); + + this.dateTimePicker = useDateTimePicker({ + target: "root", + format: this.props.format, + get pickerProps() { + return getPickerProps(); + }, + onApply: (value) => { + this.commit(formatDateTime(value)); + }, + onChange: (value) => { + this.preview(formatDateTime(value)); + }, + }); + } + + getDefaultValue() { + if (this.props.default === "now") { + return DateTime.now().toUnixInteger().toString(); + } else { + return undefined; + } + } + + getCurrentValueDateTime() { + let value = this.state.value; + if (this.state.value === undefined) { + value = this.getDefaultValue(); + } + return value !== undefined + ? DateTime.fromSeconds(parseInt(value)) + : undefined; + } + + formatRawValue(rawValue) { + return formatDateTime(DateTime.fromSeconds(parseInt(rawValue))); + } + + parseDisplayValue(displayValue) { + 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; + } + } + return this.getDefaultValue(); + } + + 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(); + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_datetimepicker.xml b/addons/html_builder/static/src/core/building_blocks/builder_datetimepicker.xml new file mode 100644 index 0000000000000..663f6f5998507 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_datetimepicker.xml @@ -0,0 +1,18 @@ + + + + + +
+ +
+
+
+ +
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_many2many.js b/addons/html_builder/static/src/core/building_blocks/builder_many2many.js new file mode 100644 index 0000000000000..593c3b19d8783 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_many2many.js @@ -0,0 +1,89 @@ +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 }, + id: { type: String, optional: true }, + }; + static defaultProps = { + ...BuilderComponent.defaultProps, + fields: [], + domain: [], + limit: 10, + }; + 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.makePreviewableOperation( + this.callApply.bind(this) + ); + this.selectionToApply = undefined; + 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, + param: 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; + } else { + this.state.searchModel = props.model; + } + } + callApply(applySpecs) { + for (const applySpec of applySpecs) { + applySpec.apply({ + editingElement: applySpec.editingElement, + param: applySpec.actionParam, + value: this.selectionToApply, + loadResult: applySpec.loadResult, + dependencyManager: this.env.dependencyManager, + }); + } + } + setSelection(newSelection) { + this.selectionToApply = JSON.stringify(newSelection); + this.callOperation(this.applyOperation.commit); + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_many2many.xml b/addons/html_builder/static/src/core/building_blocks/builder_many2many.xml new file mode 100644 index 0000000000000..e71fd78d17b73 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_many2many.xml @@ -0,0 +1,17 @@ + + + + + + + + + + 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 new file mode 100644 index 0000000000000..9ec7eda7414d2 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_number_input.js @@ -0,0 +1,51 @@ +import { Component } from "@odoo/owl"; +import { + basicContainerBuilderComponentProps, + useInputBuilderComponent, + useBuilderComponent, +} from "./utils"; +import { BuilderComponent } from "./builder_component"; + +// TODO: use BuilderTextInputBase +export class BuilderNumberInput extends Component { + static template = "html_builder.BuilderNumberInput"; + static props = { + ...basicContainerBuilderComponentProps, + default: { type: Number, optional: true }, + unit: { type: String, optional: true }, + saveUnit: { type: String, optional: true }, + step: { type: Number, optional: true }, + id: { type: String, optional: true }, + placeholder: { type: String, optional: true }, + }; + static components = { BuilderComponent }; + + setup() { + useBuilderComponent(); + const { state, commit, preview } = useInputBuilderComponent({ + id: this.props.id, + defaultValue: this.props.default, + }); + this.commit = commit; + this.preview = preview; + this.state = state; + } + + onChange(e) { + const normalizedDisplayValue = this.commit(e.target.value); + e.target.value = normalizedDisplayValue; + } + + onInput(e) { + this.preview(e.target.value); + } + + // TODO: use this.preview or this.commit? + handleKeydown(event) { + if (event.key === "ArrowUp") { + event.target.value = parseFloat(event.target.value) + (this.props.step || 1); + } else if (event.key === "ArrowDown") { + event.target.value = parseFloat(event.target.value) - (this.props.step || 1); + } + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_number_input.xml b/addons/html_builder/static/src/core/building_blocks/builder_number_input.xml new file mode 100644 index 0000000000000..f5dd85797f35b --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_number_input.xml @@ -0,0 +1,25 @@ + + + + + +
+ + +
+
+
+ +
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_range.js b/addons/html_builder/static/src/core/building_blocks/builder_range.js new file mode 100644 index 0000000000000..f33130bc44fff --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_range.js @@ -0,0 +1,54 @@ +import { Component } from "@odoo/owl"; +import { + basicContainerBuilderComponentProps, + useBuilderComponent, + useInputBuilderComponent, +} from "./utils"; +import { BuilderComponent } from "./builder_component"; + +// TODO: adapt and use BuilderTextInputBase? +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 }, + id: { type: String, optional: true }, + unit: { type: String, optional: true }, + }; + static defaultProps = { + ...BuilderComponent.defaultProps, + min: 0, + max: 100, + step: 1, + displayRangeValue: false, + }; + static components = { BuilderComponent }; + + setup() { + useBuilderComponent(); + const { state, commit, preview } = useInputBuilderComponent({ id: this.props.id }); + + this.commit = commit; + this.preview = preview; + this.state = state; + } + + onChange(e) { + const normalizedDisplayValue = this.commit(e.target.value); + e.target.value = normalizedDisplayValue; + } + + onInput(e) { + this.preview(e.target.value); + } + + getOutput(value) { + // TODO: adapt when agau's PR that adapts `useInputBuilderComponent` is + // merged. + return this.props.computedOutput ? this.props.computedOutput(value) : value; + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_range.xml b/addons/html_builder/static/src/core/building_blocks/builder_range.xml new file mode 100644 index 0000000000000..5abee381e396f --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_range.xml @@ -0,0 +1,20 @@ + + + + + +
+ + +
+
+
+ +
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_row.js b/addons/html_builder/static/src/core/building_blocks/builder_row.js new file mode 100644 index 0000000000000..50fd9810e2da5 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_row.js @@ -0,0 +1,51 @@ +import { Component, useState } from "@odoo/owl"; +import { + useVisibilityObserver, + useApplyVisibility, + basicContainerBuilderComponentProps, + useBuilderComponent, +} from "./utils"; +import { BuilderComponent } from "./builder_component"; +import { uniqueId } from "@web/core/utils/functions"; + +export class BuilderRow extends Component { + static template = "html_builder.BuilderRow"; + static components = { BuilderComponent }; + static props = { + ...basicContainerBuilderComponentProps, + label: String, + tooltip: { type: String, optional: true }, + slots: { type: Object, optional: true }, + level: { type: Number, optional: true }, + expand: { type: Boolean, optional: true }, + }; + static defaultProps = { expand: false }; + + setup() { + useBuilderComponent(); + useVisibilityObserver("content", useApplyVisibility("root")); + + this.state = useState({ + hasCollapseContent: false, + expanded: this.props.expand, + }); + + if (this.props.slots.collapse) { + useVisibilityObserver("collapse-content", (hasContent) => { + this.state.hasCollapseContent = hasContent; + }); + + this.collapseContentId = uniqueId("builder_collapse_content_"); + } + } + + getLevelClass() { + return this.props.level ? `o_we_sublevel_${this.props.level}` : ""; + } + + toggleCollapseContent() { + if (this.state.hasCollapseContent) { + this.state.expanded = !this.state.expanded; + } + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_row.scss b/addons/html_builder/static/src/core/building_blocks/builder_row.scss new file mode 100644 index 0000000000000..3b3ad6788213f --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_row.scss @@ -0,0 +1,35 @@ +.o_we_collapse_toggler { + @include o-position-absolute($top: 0, $left: 0); + width: $o-we-sidebar-content-indent; + height: $o-we-sidebar-content-field-height; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: none; + border: none; + font-family: FontAwesome; + + &::after { + content: '\f0da'; + position: static; + transform: none; + color: #9d9d9d; + + .o_rtl & { + transform: scaleX(-1); + } + } + + &.active { + + &::after { + content: '\f0d7'; + } + + * { + background: none; + border: none; + box-shadow: none; + } + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_row.xml b/addons/html_builder/static/src/core/building_blocks/builder_row.xml new file mode 100644 index 0000000000000..c86ec29967e7e --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_row.xml @@ -0,0 +1,26 @@ + + + + + +
+
+
+ +
+
+
+ +
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_select.js b/addons/html_builder/static/src/core/building_blocks/builder_select.js new file mode 100644 index 0000000000000..57dae671c7d95 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_select.js @@ -0,0 +1,50 @@ +import { Component, onMounted, useRef, useSubEnv } 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"; + +export class BuilderSelect extends Component { + static template = "html_builder.BuilderSelect"; + static props = { + ...basicContainerBuilderComponentProps, + id: { type: String, optional: true }, + slots: Object, + }; + static components = { + Dropdown, + BuilderComponent, + }; + + setup() { + useVisibilityObserver("content", useApplyVisibility("root")); + + this.dropdown = useDropdownState(); + + const buttonRef = useRef("button"); + let currentLabel; + const updateCurrentLabel = () => { + if (buttonRef.el) { + buttonRef.el.innerHTML = currentLabel || _t("None"); + } + }; + useSelectableComponent(this.props.id, { + onItemChange(item) { + currentLabel = item.getLabel(); + updateCurrentLabel(); + }, + }); + onMounted(updateCurrentLabel); + useSubEnv({ + onSelectItem: () => { + this.dropdown.close(); + }, + }); + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_select.xml b/addons/html_builder/static/src/core/building_blocks/builder_select.xml new file mode 100644 index 0000000000000..dd306b4d83d38 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_select.xml @@ -0,0 +1,20 @@ + + + + + + +
+
+ +
+
+
+ +
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_select_item.js b/addons/html_builder/static/src/core/building_blocks/builder_select_item.js new file mode 100644 index 0000000000000..72c371170a960 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_select_item.js @@ -0,0 +1,42 @@ +import { Component, onMounted, useRef } from "@odoo/owl"; +import { clickableBuilderComponentProps, useSelectableItemComponent } from "./utils"; +import { BuilderComponent } from "./builder_component"; + +export class BuilderSelectItem extends Component { + static template = "html_builder.BuilderSelectItem"; + static props = { + ...clickableBuilderComponentProps, + id: { type: String, optional: true }, + title: { type: String, optional: true }, + slots: { type: Object, optional: true }, + }; + static components = { BuilderComponent }; + + setup() { + if (!this.env.selectableContext) { + throw new Error("BuilderSelectItem must be used inside a BuilderSelect component."); + } + 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 = item.el?.innerHTML || label || ""; + return label; + }; + + onMounted(getLabel); + + const { state, operation } = useSelectableItemComponent(this.props.id, { + getLabel, + }); + this.state = state; + this.onClick = () => { + this.env.onSelectItem(); + operation.commit(); + }; + this.onMouseenter = operation.preview; + this.onMouseleave = operation.revert; + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_select_item.xml b/addons/html_builder/static/src/core/building_blocks/builder_select_item.xml new file mode 100644 index 0000000000000..f36da9d5ab1a6 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_select_item.xml @@ -0,0 +1,27 @@ + + + + + +
+ +
+
+
+ +
diff --git a/addons/html_builder/static/src/core/building_blocks/builder_text_input.js b/addons/html_builder/static/src/core/building_blocks/builder_text_input.js new file mode 100644 index 0000000000000..8d3ae30ddbcc6 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_text_input.js @@ -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, + id: { 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)); + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_text_input.xml b/addons/html_builder/static/src/core/building_blocks/builder_text_input.xml new file mode 100644 index 0000000000000..fd529529e4467 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_text_input.xml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/addons/html_builder/static/src/core/building_blocks/builder_text_input_base.js b/addons/html_builder/static/src/core/building_blocks/builder_text_input_base.js new file mode 100644 index 0000000000000..0e6ba7025035d --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_text_input_base.js @@ -0,0 +1,36 @@ +import { Component, useRef } from "@odoo/owl"; + +export const textInputBasePassthroughProps = { + action: { type: String, optional: true }, + placeholder: { type: String, optional: true }, + title: { type: String, optional: true }, +}; + +// TODO: rename BuilderInputBase if compatible with type=range +export class BuilderTextInputBase extends Component { + static template = "html_builder.BuilderTextInputBase"; + static props = { + ...textInputBasePassthroughProps, + commit: { type: Function }, + preview: { type: Function }, + onFocus: { type: Function, optional: true }, + value: { type: [String, { value: null }], optional: true }, + }; + + setup() { + this.inputRef = useRef("input"); + } + + onChange() { + const normalizedDisplayValue = this.props.commit(this.inputRef.el.value); + this.inputRef.el.value = normalizedDisplayValue; + } + + onInput() { + this.props.preview(this.inputRef.el.value); + } + + onFocus() { + this.props.onFocus?.(); + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/builder_text_input_base.xml b/addons/html_builder/static/src/core/building_blocks/builder_text_input_base.xml new file mode 100644 index 0000000000000..d906a6a2e1c84 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/builder_text_input_base.xml @@ -0,0 +1,22 @@ + + + + +
+ +
+
+ +
diff --git a/addons/html_builder/static/src/core/building_blocks/model_many2many.js b/addons/html_builder/static/src/core/building_blocks/model_many2many.js new file mode 100644 index 0000000000000..6d919e716c4ba --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/model_many2many.js @@ -0,0 +1,99 @@ +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/building_blocks/utils"; +import { useCachedModel } from "@html_builder/core/plugins/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.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, + }, + ]); + } +} diff --git a/addons/html_builder/static/src/core/building_blocks/model_many2many.xml b/addons/html_builder/static/src/core/building_blocks/model_many2many.xml new file mode 100644 index 0000000000000..72df41deefccc --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/model_many2many.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/addons/html_builder/static/src/core/building_blocks/utils.js b/addons/html_builder/static/src/core/building_blocks/utils.js new file mode 100644 index 0000000000000..2b43d7e45a1d6 --- /dev/null +++ b/addons/html_builder/static/src/core/building_blocks/utils.js @@ -0,0 +1,692 @@ +import { isTextNode } from "@html_editor/utils/dom_info"; +import { convertNumericToUnit } from "@html_editor/utils/formatting"; +import { + onMounted, + onWillDestroy, + useComponent, + useEffect, + useEnv, + useRef, + useState, + useSubEnv, +} from "@odoo/owl"; +import { useBus } from "@web/core/utils/hooks"; +import { useDebounced } from "@web/core/utils/timing"; + +export function useDomState(getState) { + const env = useEnv(); + const state = useState(getState(env.getEditingElement())); + useBus(env.editorBus, "STEP_ADDED", () => { + const editingElement = env.getEditingElement(); + if (!editingElement || editingElement.isConnected) { + Object.assign(state, getState(editingElement)); + } + }); + return state; +} + +function querySelectorAll(targets, selector) { + const elements = new Set(); + for (const target of targets) { + for (const el of target.querySelectorAll(selector)) { + elements.add(el); + } + } + return [...elements]; +} + +export function useBuilderComponent() { + const comp = useComponent(); + const newEnv = {}; + const oldEnv = useEnv(); + if (comp.props.applyTo) { + let editingElements = querySelectorAll(oldEnv.getEditingElements(), comp.props.applyTo); + useBus(oldEnv.editorBus, "UPDATE_EDITING_ELEMENT", () => { + editingElements = querySelectorAll(oldEnv.getEditingElements(), comp.props.applyTo); + }); + newEnv.getEditingElements = () => editingElements; + newEnv.getEditingElement = () => editingElements[0]; + } + const weContext = {}; + for (const key in basicContainerBuilderComponentProps) { + if (key in comp.props) { + weContext[key] = comp.props[key]; + } + } + if (Object.keys(weContext).length) { + newEnv.weContext = { ...comp.env.weContext, ...weContext }; + } + useSubEnv(newEnv); +} +export function useDependencyDefinition(id, item) { + const comp = useComponent(); + comp.env.dependencyManager.add(id, item); + onWillDestroy(() => { + comp.env.dependencyManager.removeByValue(item); + }); +} + +export function useDependencies(dependencies) { + const env = useEnv(); + const isDependenciesVisible = () => { + const deps = Array.isArray(dependencies) ? dependencies : [dependencies]; + return deps.filter(Boolean).every((dependencyId) => { + const match = dependencyId.match(/(!)?(.*)/); + const inverse = !!match[1]; + const id = match[2]; + const isActiveFn = env.dependencyManager.get(id)?.isActive; + if (!isActiveFn) { + return false; + } + const isActive = isActiveFn(); + return inverse ? !isActive : isActive; + }); + }; + return isDependenciesVisible; +} + +export function useIsActiveItem() { + const env = useEnv(); + const listenedKeys = new Set(); + + function isActive(itemId) { + const isActiveFn = env.dependencyManager.get(itemId)?.isActive; + if (!isActiveFn) { + return false; + } + return isActiveFn(); + } + + const getState = () => { + const newState = {}; + for (const itemId of listenedKeys) { + newState[itemId] = isActive(itemId); + } + return newState; + }; + const state = useDomState(getState); + const listener = () => { + const newState = getState(); + Object.assign(state, newState); + }; + env.dependencyManager.addEventListener("dependency-updated", listener); + onWillDestroy(() => { + env.dependencyManager.removeEventListener("dependency-updated", listener); + }); + return function isActiveItem(itemId) { + listenedKeys.add(itemId); + if (state[itemId] === undefined) { + return isActive(itemId); + } + return state[itemId]; + }; +} + +export function useGetItemValue() { + const env = useEnv(); + const listenedKeys = new Set(); + + function getValue(itemId) { + const getValueFn = env.dependencyManager.get(itemId)?.getValue; + if (!getValueFn) { + return null; + } + return getValueFn(); + } + + const getState = () => { + const newState = {}; + for (const itemId of listenedKeys) { + newState[itemId] = getValue(itemId); + } + return newState; + }; + const state = useDomState(getState); + const listener = () => { + const newState = getState(); + Object.assign(state, newState); + }; + env.dependencyManager.addEventListener("dependency-updated", listener); + onWillDestroy(() => { + env.dependencyManager.removeEventListener("dependency-updated", listener); + }); + return function getItemValue(itemId) { + listenedKeys.add(itemId); + if (state[itemId] === undefined) { + return getValue(itemId); + } + return state[itemId]; + }; +} + +export function useSelectableComponent(id, { onItemChange } = {}) { + useBuilderComponent(); + const selectableItems = []; + const refreshCurrentItemDebounced = useDebounced(refreshCurrentItem, 0, { immediate: true }); + let currentSelectedItem; + const env = useEnv(); + + function refreshCurrentItem() { + let currentItem; + let itemPriority = 0; + for (const selectableItem of selectableItems) { + if (selectableItem.isApplied() && selectableItem.priority >= itemPriority) { + currentItem = selectableItem; + itemPriority = selectableItem.priority; + } + } + if (currentItem && currentItem !== currentSelectedItem) { + currentSelectedItem = currentItem; + env.dependencyManager.triggerDependencyUpdated(); + } + if (currentItem) { + onItemChange?.(currentItem); + } + } + + if (id) { + useDependencyDefinition(id, { + type: "select", + getSelectableItems: () => selectableItems.slice(0), + }); + } + + onMounted(refreshCurrentItem); + useBus(env.editorBus, "STEP_ADDED", (ev) => { + if (ev.detail.isPreviewing) { + return; + } + refreshCurrentItem(); + }); + function cleanSelectedItem(...args) { + if (currentSelectedItem) { + currentSelectedItem.clean(...args); + } + } + + useSubEnv({ + selectableContext: { + cleanSelectedItem, + addSelectableItem: (item) => { + selectableItems.push(item); + }, + removeSelectableItem: (item) => { + const index = selectableItems.indexOf(item); + if (index !== -1) { + selectableItems.splice(index, 1); + } + }, + update: refreshCurrentItemDebounced, + getSelectedItem: () => { + refreshCurrentItem(); + return currentSelectedItem; + }, + }, + }); +} +export function useSelectableItemComponent(id, { getLabel = () => {} } = {}) { + const { operation, isApplied, getActions, priority, clean } = useClickableBuilderComponent(); + const env = useEnv(); + + let isSelectableActive = isApplied; + if (env.selectableContext) { + isSelectableActive = () => env.selectableContext.getSelectedItem() === selectableItem; + + const selectableItem = { + isApplied, + priority, + getLabel, + clean, + getActions, + }; + + env.selectableContext.addSelectableItem(selectableItem); + onMounted(env.selectableContext.update); + onWillDestroy(() => { + env.selectableContext.removeSelectableItem(selectableItem); + }); + } + + if (id) { + useDependencyDefinition(id, { + isActive: isSelectableActive, + getActions, + onBeforeApplyAction: () => {}, + cleanSelectedItem: env.selectableContext?.cleanSelectedItem, + }); + } + + const state = useDomState(() => ({ + isActive: isSelectableActive(), + })); + + return { state, operation }; +} +export function useClickableBuilderComponent() { + useBuilderComponent(); + const comp = useComponent(); + const { getAllActions, callOperation, isApplied } = getAllActionsAndOperations(comp); + const getAction = comp.env.editor.shared.builderActions.getAction; + const applyOperation = comp.env.editor.shared.history.makePreviewableOperation(callApply); + const shouldToggle = !comp.env.selectableContext; + const inheritedActionIds = + comp.props.inheritedActions || comp.env.weContext.inheritedActions || []; + const hasPreview = + comp.props.preview === true || + (comp.props.preview === undefined && comp.env.weContext.preview !== false); + + const operation = { + commit: () => { + callOperation(applyOperation.commit); + }, + preview: () => { + callOperation(applyOperation.preview, { + operationParams: { + cancellable: true, + cancelPrevious: () => applyOperation.revert(), + }, + }); + }, + revert: () => { + // The `next` will cancel the previous operation, which will revert + // the operation in case of a preview. + comp.env.editor.shared.operation.next(); + }, + }; + + if (!hasPreview) { + operation.preview = () => {}; + } + + function clean(nextApplySpecs) { + for (const { actionId, actionParam, actionValue } of getAllActions()) { + for (const editingElement of comp.env.getEditingElements()) { + let nextAction; + getAction(actionId).clean?.({ + editingElement, + param: actionParam, + value: actionValue, + dependencyManager: comp.env.dependencyManager, + get nextAction() { + nextAction = + nextAction || nextApplySpecs.find((a) => a.actionId === actionId) || {}; + return { + param: nextAction.actionParam, + value: nextAction.actionValue, + }; + }, + }); + } + } + } + + function callApply(applySpecs) { + comp.env.selectableContext?.cleanSelectedItem(applySpecs); + const cleans = inheritedActionIds + .map((actionId) => comp.env.dependencyManager.get(actionId).cleanSelectedItem) + .filter(Boolean); + for (const clean of new Set(cleans)) { + clean(applySpecs); + } + let shouldClean = shouldToggle && isApplied(); + shouldClean = comp.props.inverseAction ? !shouldClean : shouldClean; + for (const applySpec of applySpecs) { + if (shouldClean) { + applySpec.clean?.({ + editingElement: applySpec.editingElement, + param: applySpec.actionParam, + value: applySpec.actionValue, + dependencyManager: comp.env.dependencyManager, + }); + } else { + applySpec.apply({ + editingElement: applySpec.editingElement, + param: applySpec.actionParam, + value: applySpec.actionValue, + loadResult: applySpec.loadResult, + dependencyManager: comp.env.dependencyManager, + }); + } + } + } + function getPriority() { + return ( + getAllActions() + .map( + (a) => + getAction(a.actionId).getPriority?.({ + param: a.actionParam, + value: a.actionValue, + }) || 0 + ) + .find((x) => x !== 0) || 0 + ); + } + + return { + operation, + isApplied, + clean, + priority: getPriority(), + getActions: getAllActions, + }; +} +export function useInputBuilderComponent( + { + id, + defaultValue, + formatRawValue = (rawValue) => rawValue, + parseDisplayValue = (displayValue) => displayValue, + } = {}, +) { + const comp = useComponent(); + // TODO: replace saveUnit and unit by formatRawValue and parseDisplayValue? + if (comp.props.saveUnit && !comp.props.unit) { + throw new Error("'unit' must be defined to use the 'saveUnit' props"); + } + const { getAllActions, callOperation } = getAllActionsAndOperations(comp); + const getAction = comp.env.editor.shared.builderActions.getAction; + const state = useDomState(getState); + const applyOperation = comp.env.editor.shared.history.makePreviewableOperation((applySpecs) => { + for (const applySpec of applySpecs) { + let actionValue = applySpec.actionValue; + if (comp.props.unit && comp.props.saveUnit) { + // Convert value from unit to saveUnit + actionValue = convertNumericToUnit( + actionValue, + comp.props.unit, + comp.props.saveUnit + ); + } + if (comp.props.unit) { + if (comp.props.saveUnit || comp.props.saveUnit === "") { + actionValue = actionValue + comp.props.saveUnit; + } else { + actionValue = actionValue + comp.props.unit; + } + } + applySpec.apply({ + editingElement: applySpec.editingElement, + param: applySpec.actionParam, + value: actionValue, + loadResult: applySpec.loadResult, + dependencyManager: comp.env.dependencyManager, + }); + } + }); + 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; + let actionValue = getAction(actionId).getValue({ editingElement, param: actionParam }); + if (comp.props.unit) { + // Remove the unit + actionValue = actionValue && actionValue.match(/\d+/g)[0]; + if (comp.props.saveUnit) { + // Convert value from saveUnit to unit + actionValue = convertNumericToUnit( + actionValue, + comp.props.saveUnit, + comp.props.unit + ); + } + } + return { + value: actionValue, + }; + } + + function commit(userInputValue) { + if (defaultValue !== undefined) { + userInputValue ||= formatRawValue(defaultValue); + } + const rawValue = parseDisplayValue(userInputValue); + callOperation(applyOperation.commit, { userInputValue: rawValue }); + // If the parsed value is not equivalent to the user input, we want to + // normalize the displayed value. It is useful in cases of invalid + // input and allows to fall back to the output of parseDisplayValue. + return rawValue !== undefined ? formatRawValue(rawValue) : ""; + } + + const shouldPreview = comp.props.preview !== false && + (comp.props.preview === true || comp.env.weContext.preview !== false); + function preview(userInputValue) { + if (shouldPreview) { + callOperation(applyOperation.preview, { + userInputValue: parseDisplayValue(userInputValue), + operationParams: { + cancellable: true, + cancelPrevious: () => applyOperation.revert(), + }, + }); + } + } + + if (id) { + useDependencyDefinition(id, { + type: "input", + getValue: () => state.value, + }); + } + + return { + state, + commit, + preview, + }; +} + +export function useApplyVisibility(refName) { + const ref = useRef(refName); + return (hasContent) => { + ref.el?.classList.toggle("d-none", !hasContent); + }; +} + +export function useVisibilityObserver(contentName, callback) { + const contentRef = useRef(contentName); + + const applyVisibility = () => { + const hasContent = [...contentRef.el.childNodes].some((el) => + isTextNode(el) ? el.textContent !== "" : !el.classList.contains("d-none") + ); + callback(hasContent); + }; + + const observer = new MutationObserver(applyVisibility); + useEffect( + (contentEl) => { + if (!contentEl) { + return; + } + applyVisibility(); + observer.observe(contentEl, { + subtree: true, + attributes: true, + childList: true, + attributeFilter: ["class"], + }); + return () => { + observer.disconnect(); + }; + }, + () => [contentRef.el] + ); +} + +export const basicContainerBuilderComponentProps = { + applyTo: { type: String, optional: true }, + preview: { type: Boolean, optional: true }, + inheritedActions: { type: Array, element: String, optional: true }, + // preview: { type: Boolean, optional: true }, + // reloadPage: { type: Boolean, optional: true }, + + action: { type: String, optional: true }, + actionParam: { validate: () => true, optional: true }, + + // Shorthand actions. + classAction: { type: String, optional: true }, + attributeAction: { type: String, optional: true }, + dataAttributeAction: { type: String, optional: true }, + styleAction: { type: String, optional: true }, +}; +const validateIsNull = { validate: (value) => value === null }; + +export const clickableBuilderComponentProps = { + ...basicContainerBuilderComponentProps, + inverseAction: { type: Boolean, optional: true }, + + actionValue: { + type: [Boolean, String, Number, { type: Array, element: [Boolean, String, Number] }], + optional: true, + }, + + // Shorthand actions values. + classActionValue: { type: [String, Array, validateIsNull], optional: true }, + attributeActionValue: { type: [String, Array, validateIsNull], optional: true }, + dataAttributeActionValue: { type: [String, Array, validateIsNull], optional: true }, + styleActionValue: { type: [String, Array, validateIsNull], optional: true }, + + inheritedActions: { type: Array, element: String, optional: true }, +}; + +export function getAllActionsAndOperations(comp) { + const inheritedActionIds = + comp.props.inheritedActions || comp.env.weContext.inheritedActions || []; + + function getActionsSpecs(actions, userInputValue) { + const getAction = comp.env.editor.shared.builderActions.getAction; + const specs = []; + for (let { actionId, actionParam, actionValue } of actions) { + const action = getAction(actionId); + // Take the action value defined by the clickable or the input given + // by the user. + actionValue = actionValue === undefined ? userInputValue : actionValue; + for (const editingElement of comp.env.getEditingElements()) { + specs.push({ + editingElement, + actionId, + actionParam, + actionValue, + apply: action.apply, + clean: action.clean, + load: action.load, + }); + } + } + return specs; + } + function getShorthandActions() { + const actions = []; + const shorthands = [ + ["classAction", "classActionValue"], + ["attributeAction", "attributeActionValue"], + ["dataAttributeAction", "dataAttributeActionValue"], + ["styleAction", "styleActionValue"], + ]; + for (const [actionId, actionValue] of shorthands) { + const actionParam = comp.env.weContext[actionId] || comp.props[actionId]; + if (actionParam !== undefined) { + actions.push({ actionId, actionParam, actionValue: comp.props[actionValue] }); + } + } + return actions; + } + function getCustomAction() { + const action = { + actionId: comp.env.weContext.action || comp.props.action, + actionParam: comp.env.weContext.actionParam || comp.props.actionParam, + actionValue: comp.props.actionValue, + }; + if (action.actionId) { + return action; + } + } + function getAllActions() { + const actions = getShorthandActions(); + + const { actionId, actionParam, actionValue } = getCustomAction() || {}; + if (actionId) { + actions.push({ actionId, actionParam, actionValue }); + } + const inheritedActions = + inheritedActionIds + .map( + (actionId) => + comp.env.dependencyManager + // The dependency might not be loaded yet. + .get(actionId) + ?.getActions?.() || [] + ) + .flat() || []; + return actions.concat(inheritedActions || []); + } + function callOperation(fn, params = {}) { + const actionsSpecs = getActionsSpecs(getAllActions(), params.userInputValue); + comp.env.editor.shared.operation.next( + () => { + fn(actionsSpecs); + }, + { + load: async () => + Promise.all( + actionsSpecs.map(async (applySpec) => { + if (!applySpec.load) { + return; + } + const shouldToggle = !comp.env.actionBus; + if (shouldToggle && isApplied()) { + // The element will be cleaned, do not load + return; + } + const result = await applySpec.load({ + editingElement: applySpec.editingElement, + param: applySpec.actionParam, + value: applySpec.actionValue, + }); + applySpec.loadResult = result; + }) + ), + ...params.operationParams, + } + ); + } + function isApplied() { + const getAction = comp.env.editor.shared.builderActions.getAction; + const editingElements = comp.env.getEditingElements(); + if (!editingElements.length) { + return; + } + const areActionsActiveTabs = getAllActions().map((o) => { + const { actionId, actionParam, actionValue } = o; + // TODO isApplied === first editing el or all ? + const editingElement = editingElements[0]; + const isApplied = getAction(actionId).isApplied?.({ + editingElement, + param: actionParam, + value: actionValue, + }); + return comp.props.inverseAction ? !isApplied : isApplied; + }); + // If there is no `isApplied` method for the widget return false + if (areActionsActiveTabs.every((el) => el === undefined)) { + return false; + } + // If `isApplied` is explicitly false for an action return false + if (areActionsActiveTabs.some((el) => el === false)) { + return false; + } + // `isApplied` is true for at least one action + return true; + } + return { + getAllActions: getAllActions, + callOperation: callOperation, + isApplied: isApplied, + }; +} diff --git a/addons/html_builder/static/src/core/core_plugins.js b/addons/html_builder/static/src/core/core_plugins.js new file mode 100644 index 0000000000000..3d5984495a618 --- /dev/null +++ b/addons/html_builder/static/src/core/core_plugins.js @@ -0,0 +1,45 @@ +import { AnchorPlugin } from "./plugins/anchor/anchor_plugin"; +import { BuilderActionsPlugin } from "./plugins/builder_actions_plugin"; +import { BuilderOptionsPlugin } from "./plugins/builder_options_plugin"; +import { BuilderOverlayPlugin } from "./plugins/builder_overlay/builder_overlay_plugin"; +import { CachedModelPlugin } from "./plugins/cached_model_plugin"; +import { ClonePlugin } from "./plugins/clone/clone_plugin"; +import { DragAndDropPlugin } from "./plugins/drag_and_drop/drag_and_drop_plugin"; +import { DropZonePlugin } from "./plugins/drop_zone_plugin"; +import { DropZoneSelectorPlugin } from "./plugins/dropzone_selector_plugin"; +import { GridLayoutPlugin } from "./plugins/grid_layout/grid_layout_plugin"; +import { SavePlugin } from "./plugins/save_plugin"; +import { MediaWebsitePlugin } from "./plugins/media_website_plugin"; +import { MovePlugin } from "./plugins/move/move_plugin"; +import { OperationPlugin } from "./plugins/operation_plugin"; +import { OverlayButtonsPlugin } from "./plugins/overlay_buttons/overlay_buttons_plugin"; +import { RemovePlugin } from "./plugins/remove/remove_plugin"; +import { ReplacePlugin } from "./plugins/replace/replace_plugin"; +import { SaveSnippetPlugin } from "./plugins/save_snippet/save_snippet_plugin"; +import { SetupEditorPlugin } from "./plugins/setup_editor_plugin"; +import { VisibilityPlugin } from "./plugins/visibility_plugin"; +import { CoreBuilderActionPlugin } from "./plugins/core_builder_action_plugin"; + +export const CORE_PLUGINS = [ + BuilderOptionsPlugin, + BuilderActionsPlugin, + OperationPlugin, + BuilderOverlayPlugin, + OverlayButtonsPlugin, + MovePlugin, + GridLayoutPlugin, + DragAndDropPlugin, + ReplacePlugin, + RemovePlugin, + ClonePlugin, + SaveSnippetPlugin, + AnchorPlugin, + DropZonePlugin, + MediaWebsitePlugin, + SetupEditorPlugin, + SavePlugin, + VisibilityPlugin, + DropZoneSelectorPlugin, + CachedModelPlugin, + CoreBuilderActionPlugin, +]; diff --git a/addons/html_builder/static/src/core/default_builder_components.js b/addons/html_builder/static/src/core/default_builder_components.js new file mode 100644 index 0000000000000..ac33e0955000b --- /dev/null +++ b/addons/html_builder/static/src/core/default_builder_components.js @@ -0,0 +1,37 @@ +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 { ModelMany2Many } from "./building_blocks/model_many2many"; + +export const defaultBuilderComponents = { + BuilderContext, + BuilderRow, + Dropdown, + DropdownItem, + BuilderButtonGroup, + BuilderButton, + BuilderTextInput, + BuilderNumberInput, + BuilderRange, + BuilderColorPicker, + BuilderSelect, + BuilderSelectItem, + BuilderCheckbox, + BasicMany2Many, + BuilderMany2Many, + ModelMany2Many, + BuilderDateTimePicker, +}; diff --git a/addons/html_builder/static/src/core/plugins/anchor/anchor_dialog.js b/addons/html_builder/static/src/core/plugins/anchor/anchor_dialog.js new file mode 100644 index 0000000000000..f4891e283a67a --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/anchor/anchor_dialog.js @@ -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(); + } +} diff --git a/addons/html_builder/static/src/core/plugins/anchor/anchor_dialog.xml b/addons/html_builder/static/src/core/plugins/anchor/anchor_dialog.xml new file mode 100644 index 0000000000000..794402c6ada32 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/anchor/anchor_dialog.xml @@ -0,0 +1,28 @@ + + + + + +
+ +
+ +
+

The chosen name already exists

+
+
+
+ + + + + +
+
+ +
diff --git a/addons/html_builder/static/src/core/plugins/anchor/anchor_plugin.js b/addons/html_builder/static/src/core/plugins/anchor/anchor_plugin.js new file mode 100644 index 0000000000000..8bddea26e0fea --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/anchor/anchor_plugin.js @@ -0,0 +1,131 @@ +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_clone_handlers: this.onClone.bind(this), + get_options_container_top_buttons: withSequence( + 0, + this.getOptionsContainerTopButtons.bind(this) + ), + }; + + onClone({ 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 btn-outline-info", + 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) { + 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 = markup(_t("Anchor copied to clipboard
Link: %s", anchorLink)); + 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, "-")); + } +} diff --git a/addons/html_builder/static/src/core/plugins/builder_actions_plugin.js b/addons/html_builder/static/src/core/plugins/builder_actions_plugin.js new file mode 100644 index 0000000000000..17c78a8d608cc --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/builder_actions_plugin.js @@ -0,0 +1,42 @@ +import { Plugin } from "@html_editor/plugin"; + +/** + * @typedef {Object} BuilderAction + * @property {string} id + * @property {Function} apply + * @property {Function} [isApplied] + * @property {Function} [clean] + * @property {() => Promise} [load] + */ + +export class BuilderActionsPlugin extends Plugin { + static id = "builderActions"; + static shared = ["getAction"]; + + setup() { + this.actions = {}; + for (const actions of this.getResource("builder_actions")) { + for (const [actionId, action] of Object.entries(actions)) { + if (actionId in this.actions) { + throw new Error(`Duplicate builder action id: ${action.id}`); + } + this.actions[actionId] = { id: actionId, ...action }; + } + } + 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; + } +} diff --git a/addons/html_builder/static/src/core/plugins/builder_options_plugin.js b/addons/html_builder/static/src/core/plugins/builder_options_plugin.js new file mode 100644 index 0000000000000..db3fcaf9b715f --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/builder_options_plugin.js @@ -0,0 +1,171 @@ +import { Plugin } from "@html_editor/plugin"; +import { uniqueId } from "@web/core/utils/functions"; +import { isRemovable } from "./remove/remove_plugin"; +import { isClonable } from "./clone/clone_plugin"; +import { getElementsWithOption } from "@html_builder/utils/utils"; + +export class BuilderOptionsPlugin extends Plugin { + static id = "builder-options"; + static dependencies = ["selection", "overlay", "operation", "history"]; + static shared = ["getContainers", "updateContainers"]; + resources = { + step_added_handlers: () => this.updateContainers(), + clean_for_save_handlers: this.cleanForSave.bind(this), + }; + + setup() { + this.builderOptions = this.getResource("builder_options").map((option) => ({ + ...option, + id: uniqueId(), + })); + this.builderHeaderMiddleButtons = this.getResource("builder_header_middle_buttons").map( + (headerMiddleButton) => ({ ...headerMiddleButton, id: uniqueId() }) + ); + this.addDomListener(this.editable, "pointerup", (e) => { + this.updateContainers(e.target); + }); + + this.lastContainers = []; + } + + updateContainers(target) { + if (target) { + this.target = target; + } + if (!this.target || !this.target.isConnected) { + this.lastContainers = []; + this.dispatchTo("change_current_options_containers_listeners", this.lastContainers); + return; + } + if (this.target.dataset.invisible === "1") { + delete this.target; + // The element is present on a page but is not visible + this.lastContainers = []; + this.dispatchTo("change_current_options_containers_listeners", this.lastContainers); + return; + } + + const mapElementsToOptions = (options) => { + const map = new Map(); + for (const option of options) { + const { selector, exclude } = option; + let elements = getClosestElements(this.target, selector); + if (exclude) { + elements = elements.filter((el) => !el.matches(exclude)); + } + 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); + + // Find the closest element with no options that should still have the + // overlay buttons. + let element = this.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])); + const newContainers = [...elementToOptions] + .sort(([a], [b]) => (b.contains(a) ? 1 : -1)) + .map(([element, options]) => ({ + id: previousElementToIdMap.get(element) || uniqueId(), + element, + options, + headerMiddleButtons: elementToHeaderMiddleButtons.get(element) || [], + hasOverlayOptions: this.hasOverlayOptions(element), + isRemovable: isRemovable(element), + isClonable: isClonable(element), + optionsContainerTopButtons: this.getOptionsContainerTopButtons(element), + })); + + // Do not update the containers if they did not change. + if (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]); + if (areSameElements) { + const previousOptions = this.lastContainers.flatMap((c) => [ + ...c.options, + ...c.headerMiddleButtons, + ]); + const newOptions = newContainers.flatMap((c) => [ + ...c.options, + ...c.headerMiddleButtons, + ]); + 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); + } + + getContainers() { + return this.lastContainers; + } + + hasOverlayOptions(el) { + for (const hasOverlayOptions of this.getResource("has_overlay_options")) { + if (hasOverlayOptions(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); + } + } + } +} + +function getClosestElements(element, selector) { + if (!element) { + // TODO we should remove it + return []; + } + const parent = element.closest(selector); + return parent ? [parent, ...getClosestElements(parent.parentElement, selector)] : []; +} diff --git a/addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay.js b/addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay.js new file mode 100644 index 0000000000000..d1387efb67031 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay.js @@ -0,0 +1,612 @@ +import { renderToElement } from "@web/core/utils/render"; +import { isMobileView } from "@html_builder/utils/utils"; +import { + addBackgroundGrid, + setElementToMaxZindex, + getGridProperties, + resizeGrid, +} 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, addStep, hasOverlayOptions }) { + this.addStep = addStep; + 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.initHandles(); + this.initSizing(); + this.refreshHandles(); + } + + hasSizingHandles() { + 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; + } + + // TODO transform + const iframeRect = this.iframe.getBoundingClientRect(); + const overlayContainerRect = this.overlayContainer.getBoundingClientRect(); + const targetRect = this.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 = 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.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 ? "" : "lg-"; + 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 ? "" : "lg-"; + // (?!\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-lg-0"); + } else { + const className = this.overlayTarget.className; + const hasDesktopClass = !!className.match(/(^|\s+)offset-lg-\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-lg-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 = this.overlayTarget.style.gridRowStart; + const rowEnd = this.overlayTarget.style.gridRowEnd; + const columnStart = this.overlayTarget.style.gridColumnStart; + const columnEnd = this.overlayTarget.style.gridColumnEnd; + + 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-lg-" + (columnEnd - v)), + values: valuesW.map((v) => (gridProp.columnSize + gridProp.columnGap) * (v - 1)), + cssProperty: "grid-column-start", + }, + e: { + classes: valuesE.map((v) => "g-col-lg-" + (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(/\s*(g-col-lg-)([0-9-]+)/g, `g-col-lg-${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"; + } + + 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; + + const handleEl = ev.currentTarget; + const isGridHandle = handleEl.classList.contains("o_grid_handle"); + this.isMobile = 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. + const delta = + ev[`page${dir.XY}`] - dir.initialPageXY + 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)); + } + + // 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 (directions.every((dir) => dir.initialIndex === dir.currentIndex)) { + 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) && typeof el.click === "function" + ); + if (toBeClickedEl) { + toBeClickedEl.click(); + } + } + return; + } + + this.addStep(); + }; + + window.addEventListener("pointermove", onSizingMove); + window.addEventListener("pointerup", onSizingStop); + } +} diff --git a/addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay.scss b/addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay.scss new file mode 100644 index 0000000000000..57e0edf9c4eb6 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay.scss @@ -0,0 +1,252 @@ +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; + } + &.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; + } + &.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; + } + &.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; + } + &.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; + } + &.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; + } + .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; + 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; + } + } + } +} + +@each $cursor in (nesw-resize, nwse-resize, ns-resize, ew-resize, move) { + .#{$cursor}-important * { + cursor: $cursor !important; + } +} + +.o_resizing { + pointer-events: none; +} diff --git a/addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay.xml b/addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay.xml new file mode 100644 index 0000000000000..1fdea3ec48062 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay.xml @@ -0,0 +1,50 @@ + + + + +
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+ + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
diff --git a/addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay_plugin.js b/addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay_plugin.js new file mode 100644 index 0000000000000..24ee4349df2cd --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/builder_overlay/builder_overlay_plugin.js @@ -0,0 +1,157 @@ +import { Plugin } from "@html_editor/plugin"; +import { throttleForAnimation } from "@web/core/utils/timing"; +import { getScrollingElement, getScrollingTarget } from "@web/core/utils/scrolling"; +import { BuilderOverlay, sizingY, sizingX, sizingGrid } from "./builder_overlay"; + +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"]; + static shared = ["showOverlayPreview", "hideOverlayPreview"]; + resources = { + step_added_handlers: this.refreshOverlays.bind(this), + change_current_options_containers_listeners: this.openBuilderOverlays.bind(this), + on_mobile_preview_clicked: this.refreshOverlays.bind(this), + has_overlay_options: (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" + ); + /** @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((ev) => { + this.toggleOverlaysVisibility(true); + this.refreshPositions(); + ev.currentTarget.removeEventListener("mousemove", onMouseMoveOrDown); + ev.currentTarget.removeEventListener("mousedown", onMouseMoveOrDown); + }); + this.addDomListener(this.editable, "keydown", (ev) => { + this.toggleOverlaysVisibility(false); + ev.currentTarget.addEventListener("mousemove", onMouseMoveOrDown); + ev.currentTarget.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, + addStep: this.dependencies.history.addStep, + hasOverlayOptions: option.hasOverlayOptions, + }); + 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); + overlayToShow.toggleOverlayPreview(true); + overlayToShow.toggleOverlayVisibility(true); + } + + hideOverlayPreview(el) { + // Remove the preview. + const overlayToHide = this.overlays.find((overlay) => overlay.overlayTarget === el); + overlayToHide.toggleOverlayPreview(false); + // Show back the active overlays. + this.toggleOverlaysVisibility(true); + } +} diff --git a/addons/html_builder/static/src/core/plugins/cached_model_plugin.js b/addons/html_builder/static/src/core/plugins/cached_model_plugin.js new file mode 100644 index 0000000000000..27f67fac4f29e --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/cached_model_plugin.js @@ -0,0 +1,70 @@ +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"]; + resources = { + before_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 }) => { + const modelEdit = new ModelEdit(this.editable); + modelEdit.setRecord(model, recordId); + return modelEdit; + }, JSON.stringify); + } + destroy() { + this.ormReadCache.invalidate(); + this.ormSearchReadCache.invalidate(); + this.modelEditCache.invalidate(); + } + ormRead(model, ids, fields) { + return this.ormReadCache.read({ model, ids, 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(editableEl = this.editable) { + 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; + } +} diff --git a/addons/html_builder/static/src/core/plugins/cached_model_utils.js b/addons/html_builder/static/src/core/plugins/cached_model_utils.js new file mode 100644 index 0000000000000..a979bc4d8a0ae --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/cached_model_utils.js @@ -0,0 +1,74 @@ +import { useEnv } from "@odoo/owl"; + +export function useCachedModel() { + return useEnv().editor.shared.CachedModel; +} + +export class ModelEdit { + constructor(editableEl) { + this.editableEl = editableEl; + // Keeps track of initial model values to handle browsing back in the + // history beyond init. + this.initialValues = {}; + } + setRecord(model, recordId) { + if (this.model) { + // Reused + if (model !== this.model || recordId !== this.recordId) { + throw new Error( + `Incompatible record: ${model} ${recordId} vs ${this.model} ${this.recordId}` + ); + } + return; + } + this.model = model; + this.recordId = recordId; + this.selector = `[data-res-model="${this.model}"][data-res-id="${this.recordId}"]`; + } + getPropertyName(field) { + return `_Edit${field[0].toUpperCase()}${field.slice(1)}`; + } + has(field) { + return field in this.initialValues; + } + get(field) { + const jsonValue = this.editableEl.querySelector(this.selector).dataset[ + this.getPropertyName(field) + ]; + if (!jsonValue) { + return this.initialValues[field]; + } + return JSON.parse(jsonValue); + } + init(field, value) { + this.initialValues[field] = value; + this.set(field, value); + } + set(field, value) { + const propertyName = this.getPropertyName(field); + const textValue = JSON.stringify(value); + for (const el of this.editableEl.querySelectorAll(this.selector)) { + el.dataset[propertyName] = textValue; + } + } + collect(inventory) { + const records = inventory[this.model] || {}; + const record = records[this.recordId] || {}; + for (const field of Object.keys(this.initialValues)) { + const textInitialValue = JSON.stringify(this.initialValues[field]); + const propertyName = this.getPropertyName(field); + const el = this.editableEl.querySelector(this.selector); + if (el) { + const textValue = el.dataset[propertyName]; + if (textValue !== textInitialValue) { + inventory[this.model] = records; + records[this.recordId] = record; + record[field] = JSON.parse(textValue); + for (const el of this.editableEl.querySelectorAll(this.selector)) { + delete el.dataset[propertyName]; + } + } + } + } + } +} diff --git a/addons/html_builder/static/src/core/plugins/clone/clone_plugin.js b/addons/html_builder/static/src/core/plugins/clone/clone_plugin.js new file mode 100644 index 0000000000000..70e1fccd66956 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/clone/clone_plugin.js @@ -0,0 +1,72 @@ +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/remove_plugin"; +import { isMovable } from "../move/move_plugin"; + +const clonableSelector = "a.btn:not(.oe_unremovable)"; + +export function isClonable(el) { + return el.matches(clonableSelector) || (isRemovable(el) && isMovable(el)); +} + +export class ClonePlugin extends Plugin { + static id = "clone"; + static dependencies = ["history", "builder-options"]; + static shared = ["cloneElement"]; + + resources = { + builder_actions: this.getActions(), + get_overlay_buttons: withSequence(2, this.getActiveOverlayButtons.bind(this)), + }; + + setup() { + this.overlayTarget = null; + } + + getActions() { + return { + // TODO maybe rename to cloneItem ? + addItem: { + apply: ({ editingElement, param: itemSelector, value: position }) => { + const itemEl = editingElement.querySelector(itemSelector); + this.cloneElement(itemEl, { position, scrollToClone: true }); + this.dependencies.history.addStep(); + }, + }, + }; + } + + getActiveOverlayButtons(target) { + if (!isClonable(target)) { + this.overlayTarget = null; + return []; + } + const buttons = []; + this.overlayTarget = target; + buttons.push({ + class: "o_snippet_clone fa fa-clone", + title: _t("Duplicate"), + handler: () => { + this.cloneElement(this.overlayTarget, { scrollToClone: true }); + this.dependencies.history.addStep(); + }, + }); + return buttons; + } + + cloneElement(el, { position = "afterend", scrollToClone = false } = {}) { + // TODO snippet_will_be_cloned ? + // TODO cleanUI resource for each option + const cloneEl = el.cloneNode(true); + el.insertAdjacentElement(position, cloneEl); + this.dependencies["builder-options"].updateContainers(cloneEl); + this.dispatchTo("on_clone_handlers", { cloneEl: cloneEl, originalEl: el }); + if (scrollToClone && !isElementInViewport(cloneEl)) { + cloneEl.scrollIntoView({ behavior: "smooth", block: "center" }); + } + // TODO snippet_cloned ? + return cloneEl; + } +} diff --git a/addons/html_builder/static/src/core/plugins/color_style_plugin.js b/addons/html_builder/static/src/core/plugins/color_style_plugin.js new file mode 100644 index 0000000000000..6ff1c94668267 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/color_style_plugin.js @@ -0,0 +1,38 @@ +import { Plugin } from "@html_editor/plugin"; +import { registry } from "@web/core/registry"; + +class ColorStylePlugin extends Plugin { + static id = "ColorStyle"; + static dependencies = ["color"]; + resources = { + builder_style_actions: this.getStyleActions(), + }; + + getStyleActions() { + return { + "background-color": { + getValue: (editingElement) => + this.dependencies.color.getElementColors(editingElement)["backgroundColor"], + apply: (editingElement, value) => { + const match = value.match(/var\(--([a-zA-Z0-9-_]+)\)/); + if (match) { + value = `bg-${match[1]}`; + } + this.dependencies.color.colorElement(editingElement, value, "backgroundColor"); + }, + }, + color: { + getValue: (editingElement) => + this.dependencies.color.getElementColors(editingElement)["color"], + apply: (editingElement, value) => { + const match = value.match(/var\(--([a-zA-Z0-9-_]+)\)/); + if (match) { + value = `text-${match[1]}`; + } + this.dependencies.color.colorElement(editingElement, value, "color"); + }, + }, + }; + } +} +registry.category("website-plugins").add(ColorStylePlugin.id, ColorStylePlugin); diff --git a/addons/html_builder/static/src/core/plugins/core_builder_action_plugin.js b/addons/html_builder/static/src/core/plugins/core_builder_action_plugin.js new file mode 100644 index 0000000000000..f4baf1df4cb6c --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/core_builder_action_plugin.js @@ -0,0 +1,203 @@ +import { Plugin } from "@html_editor/plugin"; +import { CSS_SHORTHANDS, areCssValuesEqual } from "@html_builder/utils/utils_css"; + +export class CoreBuilderActionPlugin extends Plugin { + static id = "CoreBuilderAction"; + resources = { + builder_actions: this.getActions(), + builder_style_actions: this.getStyleActions(), + }; + + setup() { + this.customStyleActions = {}; + for (const styleActions of this.getResource("builder_style_actions")) { + for (const [actionId, action] of Object.entries(styleActions)) { + if (actionId in this.customStyleActions) { + throw new Error(`Duplicate builder action id: ${action.id}`); + } + this.customStyleActions[actionId] = { id: actionId, ...action }; + } + } + Object.freeze(this.customStyleActions); + } + + getActions() { + return { + classAction, + styleAction: this.getStyleAction(), + attributeAction, + dataAttributeAction, + setClassRange, + }; + } + + getStyleAction() { + const getValue = ({ editingElement, param: styleName }) => { + const customStyle = this.customStyleActions[styleName]; + if (customStyle) { + return customStyle.getValue(editingElement); + } else { + return getComputedStyle(editingElement).getPropertyValue(styleName); + } + }; + return { + getValue, + isApplied: ({ editingElement, param, value }) => { + const currentValue = getValue({ editingElement, param }); + return currentValue === value; + }, + apply: ({ editingElement, param: styleName, value }) => { + // Always reset the inline style first to not put inline style on an + // element which already has this style through css stylesheets. + const cssProps = CSS_SHORTHANDS[styleName] || [styleName]; + for (const cssProp of cssProps) { + editingElement.style.setProperty(cssProp, ""); + } + const customStyle = this.customStyleActions[styleName]; + if (customStyle) { + customStyle?.apply(editingElement, value); + } else { + const styles = window.getComputedStyle(editingElement); + if (!areCssValuesEqual(styles.getPropertyValue(styleName), value, styleName)) { + editingElement.style.setProperty(styleName, value, "important"); + } + } + }, + }; + } + + getStyleActions() { + return styleMap; + } +} + +function getNumericStyle(styleName) { + return { + getValue: (editingElement) => + parseInt(getComputedStyle(editingElement).getPropertyValue(styleName)).toString(), + apply: (editingElement, value) => { + editingElement.style.setProperty(styleName, value, "important"); + }, + }; +} + +function getNumericStyleWithClass(styleName, className) { + const action = getNumericStyle(styleName); + return { + ...action, + apply: (editingElement, value) => { + const parsedValue = parseInt(value); + const hasBorderClass = editingElement.classList.contains(className); + if (!parsedValue || parsedValue < 0) { + if (hasBorderClass) { + editingElement.classList.remove(className); + } + } else { + if (!hasBorderClass) { + editingElement.classList.add(className); + } + } + action.apply(editingElement, value); + }, + }; +} + +const styleMap = { + "border-width": getNumericStyleWithClass("border-width", "border"), + "border-radius": getNumericStyleWithClass("border-radius", "rounded"), + // todo: handle all the other styles + padding: getNumericStyle("padding"), +}; + +export const classAction = { + getPriority: ({ param: classNames = "" }) => + classNames?.trim().split(/\s+/).filter(Boolean).length || 0, + isApplied: ({ editingElement, param: classNames }) => { + if (classNames === "") { + return true; + } + return classNames + .split(" ") + .every((className) => editingElement.classList.contains(className)); + }, + apply: ({ editingElement, param: classNames }) => { + for (const className of classNames.split(" ")) { + if (className !== "") { + editingElement.classList.add(className); + } + } + }, + clean: ({ editingElement, param: classNames }) => { + for (const className of classNames.split(" ")) { + if (className !== "") { + editingElement.classList.remove(className); + } + } + }, +}; + +const attributeAction = { + getValue: ({ editingElement, param: attributeName }) => + editingElement.getAttribute(attributeName), + isApplied: ({ editingElement, param: attributeName, value }) => { + if (value) { + return ( + editingElement.hasAttribute(attributeName) && + editingElement.getAttribute(attributeName) === value + ); + } else { + return !editingElement.hasAttribute(attributeName); + } + }, + apply: ({ editingElement, param: attributeName, value }) => { + if (value) { + editingElement.setAttribute(attributeName, value); + } else { + editingElement.removeAttribute(attributeName); + } + }, + clean: ({ editingElement, param: attributeName }) => { + editingElement.removeAttribute(attributeName); + }, +}; + +const dataAttributeAction = { + getValue: ({ editingElement, param: attributeName }) => editingElement.dataset[attributeName], + isApplied: ({ editingElement, param: attributeName, value }) => { + if (value) { + return editingElement.dataset[attributeName] === value; + } else { + return !(attributeName in editingElement.dataset); + } + }, + apply: ({ editingElement, param: attributeName, value }) => { + if (value) { + editingElement.dataset[attributeName] = value; + } else { + delete editingElement.dataset[attributeName]; + } + }, + clean: ({ editingElement, param: attributeName }) => { + delete editingElement.dataset[attributeName]; + }, +}; + +// TODO maybe find a better place for this +const setClassRange = { + getValue: ({ editingElement, param: classNames }) => { + for (const index in classNames) { + const className = classNames[index]; + if (editingElement.classList.contains(className)) { + return index; + } + } + }, + apply: ({ editingElement, param: classNames, value: index }) => { + for (const className of classNames) { + if (editingElement.classList.contains(className)) { + editingElement.classList.remove(className); + } + } + editingElement.classList.add(classNames[index]); + }, +}; diff --git a/addons/html_builder/static/src/core/plugins/dependency_manager.js b/addons/html_builder/static/src/core/plugins/dependency_manager.js new file mode 100644 index 0000000000000..e6b1a40fc6501 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/dependency_manager.js @@ -0,0 +1,43 @@ +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] of this.dependencies) { + this.dependenciesMap[id] = value; + } + this.dirty = false; + } + + add(id, value) { + // In case the dependency is added after a dependent try to get it + // an event is scheduled to notify the dependent about it. + this.triggerDependencyUpdated(); + this.dependencies.push([id, value]); + 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(); + } +} diff --git a/addons/html_builder/static/src/core/plugins/drag_and_drop/drag_and_drop_plugin.js b/addons/html_builder/static/src/core/plugins/drag_and_drop/drag_and_drop_plugin.js new file mode 100644 index 0000000000000..ecc6cd0768f9e --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/drag_and_drop/drag_and_drop_plugin.js @@ -0,0 +1,42 @@ +import { Plugin } from "@html_editor/plugin"; +import { withSequence } from "@html_editor/utils/resource"; +import { _t } from "@web/core/l10n/translation"; + +export class DragAndDropPlugin extends Plugin { + static id = "dragAndDrop"; + resources = { + has_overlay_options: this.isDraggable.bind(this), + get_overlay_buttons: withSequence(1, this.getActiveOverlayButtons.bind(this)), + }; + + setup() { + this.overlayTarget = null; + } + + isDraggable(el) { + const dropzoneSelectors = []; + this.getResource("dropzone_selector").forEach((selectors) => + dropzoneSelectors.push(selectors) + ); + const isDraggable = dropzoneSelectors + .flat() + .find(({ selector, exclude = false }) => el.matches(selector) && !el.matches(exclude)); + return !!isDraggable; + } + + getActiveOverlayButtons(target) { + if (!this.isDraggable(target)) { + this.overlayTarget = null; + return []; + } + + const buttons = []; + this.overlayTarget = target; + buttons.push({ + class: "o_move_handle fa fa-arrows", + title: _t("Drag and move"), + handler: () => {}, + }); + return buttons; + } +} diff --git a/addons/html_builder/static/src/core/plugins/drop_zone_plugin.inside.scss b/addons/html_builder/static/src/core/plugins/drop_zone_plugin.inside.scss new file mode 100644 index 0000000000000..3df7c950da503 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/drop_zone_plugin.inside.scss @@ -0,0 +1,35 @@ +.oe_drop_zone { + height: 10px; + background: $o-we-dropzone-bg-color; + animation: dropZoneInsert 1s linear 0s infinite alternate; + z-index: 2000; + + &.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; + } + + &.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); + } + } +} diff --git a/addons/html_builder/static/src/core/plugins/drop_zone_plugin.js b/addons/html_builder/static/src/core/plugins/drop_zone_plugin.js new file mode 100644 index 0000000000000..14a01a2a36ee9 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/drop_zone_plugin.js @@ -0,0 +1,165 @@ +import { scrollToWindow } from "@html_builder/utils/utils"; +import { Plugin } from "@html_editor/plugin"; +import { isBlock } from "@html_editor/utils/blocks"; +import { _t } from "@web/core/l10n/translation"; +import { closest, touching } from "@web/core/utils/ui"; + +export class DropZonePlugin extends Plugin { + static id = "dropzone"; + static dependencies = ["history"]; + static shared = ["displayDropZone", "dragElement", "clearDropZone", "getAddElement"]; + + resources = { + savable_mutation_record_predicates: this.isMutationRecordSavable.bind(this), + }; + + setup() { + this.dropZoneElements = []; + } + + /** + * @param {MutationRecord} record + * @return {boolean} + */ + isMutationRecordSavable(record) { + if (record.type === "childList") { + const node = record.addedNodes[0] || record.removedNodes[0]; + if (isBlock(node) && node.classList.contains("oe_drop_zone")) { + return false; + } + } + return true; + } + + isDroppable(el, { selector, exclude = false }) { + return el.matches(selector) && !el.matches(exclude); + } + + getAll(selector) { + return [...this.editable.querySelectorAll(selector)]; + } + + getSelectors(snippet) { + const selectorSiblings = []; + const selectorChildren = []; + + for (const dropZoneSelector of this.getResource("dropzone_selector")) { + const { selector, exclude, dropIn, dropNear } = dropZoneSelector; + if (!this.isDroppable(snippet.content, { selector, exclude })) { + continue; + } + + if (dropNear) { + selectorSiblings.push(...this.getAll(dropNear)); + } + if (dropIn) { + selectorChildren.push(...this.getAll(dropIn)); + } + } + + return { + selectorSiblings, + selectorChildren, + }; + } + + createDropZone() { + const dropZoneEl = this.document.createElement("div"); + dropZoneEl.className = "oe_drop_zone oe_insert"; + dropZoneEl.dataset.editorMessage = _t("DRAG BUILDING BLOCKS HERE"); + this.dropZoneElements.push(dropZoneEl); + return dropZoneEl; + } + + displayDropZone(snippet) { + this.clearDropZone(); + + // TODO need to imp check old website + const { selectorChildren, selectorSiblings } = this.getSelectors(snippet); + const targets = []; + for (const el of selectorChildren) { + targets.push(...el.children); + el.prepend(this.createDropZone()); + } + targets.push(...selectorSiblings); + + for (const target of targets) { + if (!target.nextElementSibling?.classList.contains("oe_drop_zone")) { + target.after(this.createDropZone()); + } + + if (!target.previousElementSibling?.classList.contains("oe_drop_zone")) { + target.before(this.createDropZone()); + } + } + } + + clearDropZone() { + if (this.dropZoneElements.length) { + for (const el of this.editable.querySelectorAll(".oe_drop_zone")) { + el.remove(); + } + } + + this.dropZoneElements = []; + } + + dragElement(element) { + const { x, y, height, width } = element.getClientRects()[0]; + const position = { x, y, height, width }; + const dropzoneEl = closest(touching(this.dropZoneElements, position), position); + if (this.currentDropzoneEl !== dropzoneEl) { + this.currentDropzoneEl?.classList.remove("o_dropzone_highlighted"); + this.currentDropzoneEl = dropzoneEl; + if (dropzoneEl) { + dropzoneEl.classList.add("o_dropzone_highlighted"); + } + } + } + + /** + * @param {Object} [position] - set if drag & drop, not set if click + * @param {Number} position.x + * @param {Number} position.y + * @returns {Function} + */ + getAddElement(position) { + // Drag & drop over sidebar: cancel the action. + if (position && !touching([this.document.body], position).length) { + // TODO: do we want that key with an empty function? Or should we + // check everytime we call getAddElement if the result is undefined + // before continuing? + this.clearDropZone(); + return; + } + const dropZone = position + ? closest(touching(this.dropZoneElements, position), position) || + closest(this.dropZoneElements, position) + : closest(this.dropZoneElements, { + x: window.innerWidth / 2, + y: window.innerHeight / 2, + }); + if (!dropZone) { + this.clearDropZone(); + return; + } + + let target = dropZone.previousSibling; + let insertMethod = "after"; + if (!target) { + target = dropZone.nextSibling; + insertMethod = "before"; + } + if (!target) { + target = dropZone.parentElement; + insertMethod = "appendChild"; + } + this.clearDropZone(); + return (elementToAdd) => { + target[insertMethod](elementToAdd); + scrollToWindow(elementToAdd, { behavior: "smooth", offset: 50 }); + this.dependencies.history.addStep(); + this.dispatchTo("update_interactions", elementToAdd); + }; + } +} diff --git a/addons/html_builder/static/src/core/plugins/dropzone_selector_plugin.js b/addons/html_builder/static/src/core/plugins/dropzone_selector_plugin.js new file mode 100644 index 0000000000000..0cf6db6c6d66e --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/dropzone_selector_plugin.js @@ -0,0 +1,72 @@ +import { Plugin } from "@html_editor/plugin"; + +const so_content_addition_selector = `blockquote, .s_alert, .o_facebook_page, .s_share, .s_social_media, .s_rating, .s_hr, .s_google_map, .s_map, .s_countdown, .s_chart, .s_text_highlight, .s_progress_bar, .s_badge, .s_embed_code, .s_donation, .s_add_to_cart, .s_online_appointment, .o_snippet_drop_in_only, .s_image, .s_cta_badge, .s_accordion`; +const card_parent_handlers = + ".s_three_columns .row > div, .s_comparisons .row > div, .s_cards_grid .row > div, .s_cards_soft .row > div, .s_product_list .row > div"; +const special_cards_selector = `.s_card.s_timeline_card, div:is(${card_parent_handlers}) > .s_card`; + +// TODO need to split by addons + +export class DropZoneSelectorPlugin extends Plugin { + static id = "dropzone_selector"; + resources = { + dropzone_selector: [ + { + selector: ".accordion > .accordion-item", + dropIn: ".accordion:has(> .accordion-item)", + }, + { + selector: "section, .parallax, .s_hr", // TODO check extend so_snippet_addition_selector + dropIn: ":not(p).oe_structure:not(.oe_structure_solo), :not(.o_mega_menu):not(p)[data-oe-type=html], :not(p).oe_structure.oe_structure_solo:not(:has(> section:not(.s_snippet_group), > div:not(.o_hook_drop_zone)))", + }, + { + selector: `${so_content_addition_selector}, .s_card`, // TODO check extend so_content_addition_selector + dropNear: `p, h1, h2, h3, ul, ol, div:not(.o_grid_item_image) > img, .btn, ${so_content_addition_selector}, .s_card:not(${special_cards_selector})`, + exclude: `${special_cards_selector}`, + dropIn: "nav", + }, + { + selector: ".o_mega_menu .nav > .nav-link", + dropIn: ".o_mega_menu nav", + dropNear: ".o_mega_menu .nav-link", + }, + { + selector: ".s_popup", + exclude: "#website_cookies_bar", + dropIn: ":not(p).oe_structure:not(.oe_structure_solo):not([data-snippet] *), :not(.o_mega_menu):not(p)[data-oe-type=html]:not([data-snippet] *)", + }, + { + selector: ".s_website_form_field", + exclude: ".s_website_form_dnone", + dropNear: ".s_website_form_field", + //TODO DROP LOCK WITHIN drop-lock-within="form" + }, + { + selector: ".row > div", + exclude: ".s_col_no_resize.row > div, .s_col_no_resize", + dropNear: ".row:not(.s_col_no_resize) > div", + }, + { + selector: ".row > div", + exclude: ".s_col_no_resize.row > div, .s_col_no_resize", + dropNear: ".row.o_grid_mode > div", + }, + { + selector: ".s_group", + dropNear: "p, h1, h2, h3, blockquote, .card", + }, + { + // TODO Move to website_massmailing + selector: ".js_subscribe", + dropNear: "p, h1, h2, h3, blockquote, .card", + }, + ], + }; +} + +/** TODO add xpath + * + + + + */ diff --git a/addons/html_builder/static/src/core/plugins/editor.inside.scss b/addons/html_builder/static/src/core/plugins/editor.inside.scss new file mode 100644 index 0000000000000..077cad022e456 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/editor.inside.scss @@ -0,0 +1,63 @@ +// This style block is about the "editor message" which highlights the areas +// where the user can drag & drop snippets. +$-editor-messages-margin-x: 2%; + +%o-editor-messages { + background: $o-we-dropzone-bg-color; + text-align: center; + color: #fff; + outline: $o-we-dropzone-border-width dashed $o-we-dropzone-accent-color; + outline-offset: -$o-we-dropzone-border-width; + + &:before { + content: attr(data-editor-message); + display: block; + font-size: 20px; + } + + &:after { + content: attr(data-editor-sub-message); + display: block; + } +} + +.o_editable { + &.oe_structure.oe_empty, &[data-oe-type=html], .oe_structure.oe_empty { + &:empty { + @extend %o-editor-messages; + padding: 112px 0px; + margin: 20px $-editor-messages-margin-x; + border-radius: $border-radius-lg; + } + + .oe_drop_zone.oe_insert:only-child { + @extend %o-editor-messages; + height: auto; + width: 100% - 2 * $-editor-messages-margin-x; + padding: 112px 0; + margin: 20px $-editor-messages-margin-x; + text-shadow: none; + } + + > p:empty:only-child { + color: #aaa; + } + } +} + +.o-we-hint { + position: relative; + + &:after { + content: attr(placeholder); + position: absolute; + top: 0; + left: 0; + display: block; + color: inherit; + opacity: 0.4; + pointer-events: none; + text-align: inherit; + width: 100%; + } +} diff --git a/addons/html_builder/static/src/core/plugins/grid_layout/grid_layout.inside.scss b/addons/html_builder/static/src/core/plugins/grid_layout/grid_layout.inside.scss new file mode 100644 index 0000000000000..1662bf1805de6 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/grid_layout/grid_layout.inside.scss @@ -0,0 +1,77 @@ +// TODO move this file elsewhere + +// GRID LAYOUT +// we-button.o_grid { +// min-width: fit-content; +// padding-left: 4.5px !important; +// padding-right: 4.5px !important; +// } + +// we-select.o_grid we-toggler { +// width: fit-content !important; +// } + +// Background grid. +.o_we_background_grid { + padding: 0 !important; + + .o_we_cell { + fill: $o-we-fg-lighter; + fill-opacity: .1; + stroke: $o-we-bg-darkest; + stroke-opacity: .2; + stroke-width: 1px; + filter: drop-shadow(-1px -1px 0px rgba(255, 255, 255, 0.3)); + } + + // &.o_we_grid_preview { + // @include media-breakpoint-down(lg) { + // // Hiding the preview in mobile view (-> no grid in mobile view). We + // // cannot use `display: none` because it would prevent the animation + // // to be played and so its listener would not remove the preview. + // height: 0; + // } + // pointer-events: none; + + // .o_we_cell { + // animation: gridPreview 2s 0.5s; + // } + // } +} + +// Grid preview. +// @keyframes gridPreview { +// to { +// fill-opacity: 0; +// stroke-opacity: 0; +// } +// } + +// .o_we_drag_helper { +// padding: 0; +// border: $o-we-handle-border-width * 2 solid $o-we-accent; +// border-radius: $o-we-item-border-radius; +// } + +// // Highlight of the grid items padding. +// @keyframes highlightPadding { +// from { +// border: solid rgba($o-we-handles-accent-color, 0.2); +// border-width: var(--grid-item-padding-y) var(--grid-item-padding-x); +// } +// to { +// border: solid rgba($o-we-handles-accent-color, 0); +// border-width: var(--grid-item-padding-y) var(--grid-item-padding-x); +// } +// } + +// .o_we_padding_highlight.o_grid_item { +// position: relative; + +// &::after { +// content: ""; +// @include o-position-absolute(0, 0, 0, 0); +// animation: highlightPadding 2s; +// pointer-events: none; +// } +// } diff --git a/addons/html_builder/static/src/core/plugins/grid_layout/grid_layout.xml b/addons/html_builder/static/src/core/plugins/grid_layout/grid_layout.xml new file mode 100644 index 0000000000000..eda136b934b9e --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/grid_layout/grid_layout.xml @@ -0,0 +1,19 @@ + + + + + +
+ + + + + + + + + +
+
+ +
diff --git a/addons/html_builder/static/src/core/plugins/grid_layout/grid_layout_plugin.js b/addons/html_builder/static/src/core/plugins/grid_layout/grid_layout_plugin.js new file mode 100644 index 0000000000000..be14fd11045b5 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/grid_layout/grid_layout_plugin.js @@ -0,0 +1,69 @@ +import { Plugin } from "@html_editor/plugin"; +import { withSequence } from "@html_editor/utils/resource"; +import { _t } from "@web/core/l10n/translation"; +import { setElementToMaxZindex } from "@html_builder/utils/grid_layout_utils"; +import { isMobileView } from "@html_builder/utils/utils"; + +const gridItemSelector = ".row.o_grid_mode > div.o_grid_item"; + +function isGridItem(el) { + return el.matches(gridItemSelector); +} + +export class GridLayoutPlugin extends Plugin { + static id = "gridLayout"; + static dependencies = ["history"]; + resources = { + get_overlay_buttons: withSequence(0, this.getActiveOverlayButtons.bind(this)), + }; + + setup() { + this.overlayTarget = null; + } + + getActiveOverlayButtons(target) { + if (!isGridItem(target)) { + this.overlayTarget = null; + return []; + } + + const buttons = []; + this.overlayTarget = target; + if (!isMobileView(this.overlayTarget)) { + buttons.push( + { + class: "o_send_back", + title: _t("Send to back"), + handler: this.sendGridItemToBack.bind(this), + }, + { + class: "o_bring_front", + title: _t("Bring to front"), + handler: this.bringGridItemToFront.bind(this), + } + ); + } + return buttons; + } + + sendGridItemToBack() { + const rowEl = this.overlayTarget.parentNode; + const columnEls = [...rowEl.children].filter((el) => el !== this.overlayTarget); + const minZindex = Math.min(...columnEls.map((el) => el.style.zIndex)); + + // While the minimum z-index is not 0, it is OK to decrease it and to + // set the column to it. Otherwise, the column is set to 0 and the + // other columns z-index are increased by one. + if (minZindex > 0) { + this.overlayTarget.style.zIndex = minZindex - 1; + } else { + columnEls.forEach((columnEl) => columnEl.style.zIndex++); + this.overlayTarget.style.zIndex = 0; + } + } + + bringGridItemToFront() { + const rowEl = this.overlayTarget.parentNode; + setElementToMaxZindex(this.overlayTarget, rowEl); + } +} diff --git a/addons/html_builder/static/src/core/plugins/media_website_plugin.js b/addons/html_builder/static/src/core/plugins/media_website_plugin.js new file mode 100644 index 0000000000000..df27acd584ae6 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/media_website_plugin.js @@ -0,0 +1,67 @@ +import { Plugin } from "@html_editor/plugin"; +import { MEDIA_SELECTOR, isProtected } from "@html_editor/utils/dom_info"; +import { closestElement } from "@html_editor/utils/dom_traversal"; + +export class MediaWebsitePlugin extends Plugin { + static id = "media_website"; + static dependencies = ["media", "selection"]; + + setup() { + const basicMediaSelector = `${MEDIA_SELECTOR}, img`; + // (see isImageSupportedForStyle). + const mediaSelector = basicMediaSelector + .split(",") + .map((s) => `${s}:not([data-oe-xpath])`) + .join(","); + this.addDomListener(this.editable, "dblclick", (ev) => { + const targetEl = ev.target; + if (!targetEl.matches(mediaSelector)) { + return; + } + let isEditable = + // TODO that first check is probably useless/wrong: checking if + // the media itself has editable content should not be relevant. + // In fact the content of all media should be marked as non + // editable anyway. + targetEl.isContentEditable || + // For a media to be editable, the base case is to be in a + // container whose content is editable. + (targetEl.parentElement && targetEl.parentElement.isContentEditable); + + if (!isEditable && targetEl.classList.contains("o_editable_media")) { + isEditable = shouldEditableMediaBeEditable(targetEl); + } + if ( + isEditable && + !isProtected(this.dependencies.selection.getEditableSelection().anchorNode) + ) { + this.onDblClickEditableMedia(targetEl); + } + }); + } + + onDblClickEditableMedia(mediaEl) { + const params = { node: mediaEl }; + const sel = this.dependencies.selection.getEditableSelection(); + + const editableEl = + closestElement(params.node || sel.startContainer, ".o_editable") || this.editable; + this.dependencies.media.openMediaDialog(params, editableEl); + } +} + +function shouldEditableMediaBeEditable(mediaEl) { + // Some sections of the DOM are contenteditable="false" (for example with + // the help of the o_not_editable class) but have inner media that should be + // editable (the fact the container is not is to prevent adding text in + // between those medias). This case is complex and the solution to support + // it is not perfect: we mark those media with a class and check that the + // first non editable ancestor is in fact in an editable parent. + const parentEl = mediaEl.parentElement; + const nonEditableAncestorRootEl = parentEl && parentEl.closest("[contenteditable='false']"); + return ( + nonEditableAncestorRootEl && + nonEditableAncestorRootEl.parentElement && + nonEditableAncestorRootEl.parentElement.isContentEditable + ); +} diff --git a/addons/html_builder/static/src/core/plugins/move/move_plugin.js b/addons/html_builder/static/src/core/plugins/move/move_plugin.js new file mode 100644 index 0000000000000..6566b4e29f5ce --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/move/move_plugin.js @@ -0,0 +1,222 @@ +import { Plugin } from "@html_editor/plugin"; +import { withSequence } from "@html_editor/utils/resource"; +import { _t } from "@web/core/l10n/translation"; +import { + addMobileOrders, + fillRemovedItemGap, + removeMobileOrders, +} from "@html_builder/utils/column_layout_utils"; +import { isMobileView } from "@html_builder/utils/utils"; + +const moveUpOrDown = { + selector: [ + "section", + ".s_accordion .accordion-item", + ".s_showcase .row .row:not(.s_col_no_resize) > div", + ".s_hr", + // In snippets files + ".s_pricelist_boxed_item", + ".s_pricelist_cafe_item", + ".s_product_catalog_dish", + ".s_timeline_list_row", + ".s_timeline_row", + ].join(", "), +}; + +const moveLeftOrRight = { + selector: [ + ".row:not(.s_col_no_resize) > div", + ".nav-item", // TODO specific plugin + ].join(", "), + exclude: ".s_showcase .row .row > div", +}; + +export function isMovable(el) { + const canMoveUpOrDown = el.matches(moveUpOrDown.selector); + const canMoveLeftOrRight = + el.matches(moveLeftOrRight.selector) && !el.matches(moveLeftOrRight.exclude); + return canMoveUpOrDown || canMoveLeftOrRight; +} + +function getMoveDirection(el) { + const canMoveVertically = el.matches(moveUpOrDown.selector); + return canMoveVertically ? "vertical" : "horizontal"; +} + +export function getVisibleSibling(target, direction) { + const siblingEls = [...target.parentNode.children]; + const visibleSiblingEls = siblingEls.filter( + (el) => window.getComputedStyle(el).display !== "none" + ); + const targetMobileOrder = target.style.order; + // On mobile, if the target has a mobile order (which is independent + // from desktop), consider these orders instead of the DOM order. + if (targetMobileOrder && isMobileView(target)) { + visibleSiblingEls.sort((a, b) => parseInt(a.style.order) - parseInt(b.style.order)); + } + const targetIndex = visibleSiblingEls.indexOf(target); + const siblingIndex = direction === "prev" ? targetIndex - 1 : targetIndex + 1; + if (siblingIndex === -1 || siblingIndex === visibleSiblingEls.length) { + return false; + } + return visibleSiblingEls[siblingIndex]; +} + +export class MovePlugin extends Plugin { + static id = "move"; + static dependencies = ["history"]; + resources = { + has_overlay_options: (el) => isMovable(el), + get_overlay_buttons: withSequence(0, this.getActiveOverlayButtons.bind(this)), + on_clone_handlers: this.onClone.bind(this), + on_remove_handlers: this.onRemove.bind(this), + }; + + setup() { + this.overlayTarget = null; + this.isMobileView = false; + this.isGridItem = false; + } + + getActiveOverlayButtons(target) { + if (!isMovable(target)) { + this.overlayTarget = null; + return []; + } + + const buttons = []; + this.overlayTarget = target; + this.refreshState(); + if (this.areArrowsDisplayed()) { + if (this.hasPreviousSibling()) { + const direction = + getMoveDirection(this.overlayTarget) === "vertical" ? "up" : "left"; + const button = { + class: `fa fa-fw fa-angle-${direction}`, + title: _t("Move %s", direction), + handler: this.onMoveClick.bind(this, "prev"), + }; + buttons.push(button); + } + if (this.hasNextSibling()) { + const direction = + getMoveDirection(this.overlayTarget) === "vertical" ? "down" : "right"; + const button = { + class: `fa fa-fw fa-angle-${direction}`, + title: _t("Move %s", direction), + handler: this.onMoveClick.bind(this, "next"), + }; + buttons.push(button); + } + } + return buttons; + } + + onClone({ cloneEl, originalEl }) { + if (!isMovable(originalEl)) { + return; + } + // If there is a mobile order, the clone must have an order different + // than the existing ones. + const hasMobileOrder = !!originalEl.style.order; + if (hasMobileOrder) { + const siblingEls = [...originalEl.parentNode.children]; + const maxOrder = Math.max(...siblingEls.map((el) => el.style.order)); + cloneEl.style.order = maxOrder + 1; + } + } + + onRemove(toRemoveEl) { + if (!isMovable(toRemoveEl)) { + return; + } + // If there is a mobile order, the gap created by the removed element + // must be filled in. + const mobileOrder = toRemoveEl.style.order; + if (mobileOrder) { + fillRemovedItemGap(toRemoveEl.parentElement, parseInt(mobileOrder)); + } + } + + refreshState() { + this.isMobileView = isMobileView(this.overlayTarget); + this.isGridItem = this.overlayTarget.classList.contains("o_grid_item"); + } + + // TODO check where to call it (SnippetMove > start). + // refreshTarget() { + // // Needed for compatibility (with already dropped snippets). + // // If the target is a column, check if all the columns are either mobile + // // ordered or not. If they are not consistent, then we remove the mobile + // // order classes from all of them, to avoid issues. + // const parentEl = this.overlayTarget.parentElement; + // if (parentEl.classList.contains("row")) { + // const columnEls = [...parentEl.children]; + // const orderedColumnEls = columnEls.filter((el) => el.style.order); + // if (orderedColumnEls.length && orderedColumnEls.length !== columnEls.length) { + // removeMobileOrders(orderedColumnEls); + // } + // } + // } + + areArrowsDisplayed() { + const siblingsEl = [...this.overlayTarget.parentNode.children]; + const visibleSiblingEl = siblingsEl.find( + (el) => el !== this.overlayTarget && window.getComputedStyle(el).display !== "none" + ); + // The arrows are not displayed if: + // - the target is a grid item and not in mobile view + // - the target has no visible siblings + return !!visibleSiblingEl && !(this.isGridItem && !this.isMobileView); + } + + hasPreviousSibling() { + return !!getVisibleSibling(this.overlayTarget, "prev"); + } + + hasNextSibling() { + return !!getVisibleSibling(this.overlayTarget, "next"); + } + + /** + * Move the element in the given direction + * + * @param {String} direction "prev" or "next" + */ + onMoveClick(direction) { + // TODO nav-item ? (=> specific plugin) + // const isNavItem = this.overlayTarget.classList.contains("nav-item"); + let hasMobileOrder = !!this.overlayTarget.style.order; + const siblingEls = this.overlayTarget.parentNode.children; + + // If the target is a column, the ordering in mobile view is independent + // from the desktop view. If we are in mobile view, we first add the + // mobile order if there is none yet. In the case where we are not in + // mobile view, the mobile order is reset. + const parentEl = this.overlayTarget.parentNode; + if (this.isMobileView && parentEl.classList.contains("row") && !hasMobileOrder) { + addMobileOrders(siblingEls); + hasMobileOrder = true; + } else if (!this.isMobileView && hasMobileOrder) { + removeMobileOrders(siblingEls); + hasMobileOrder = false; + } + + const siblingEl = getVisibleSibling(this.overlayTarget, direction); + if (hasMobileOrder) { + // Swap the mobile orders. + const currentOrder = this.overlayTarget.style.order; + this.overlayTarget.style.order = siblingEl.style.order; + siblingEl.style.order = currentOrder; + } else { + // Swap the DOM elements. + siblingEl.insertAdjacentElement( + direction === "prev" ? "beforebegin" : "afterend", + this.overlayTarget + ); + } + + // TODO scroll (data-no-scroll) + // TODO update invisible dom + } +} diff --git a/addons/html_builder/static/src/core/plugins/operation.js b/addons/html_builder/static/src/core/plugins/operation.js new file mode 100644 index 0000000000000..02379fb75cf67 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/operation.js @@ -0,0 +1,44 @@ +import { Mutex } from "@web/core/utils/concurrency"; + +export class Operation { + constructor() { + this.mutex = new Mutex(); + } + next( + fn, + { load = () => Promise.resolve(), cancellable, cancelPrevious, cancelTime = 50 } = {} + ) { + this.cancelPrevious?.(); + let isCancel = false; + let cancelResolve; + this.cancelPrevious = + cancellable && + (() => { + this.cancelPrevious = null; + isCancel = true; + cancelPrevious?.(); + cancelResolve?.(); + }); + + const cancelTimePromise = new Promise((resolve) => setTimeout(resolve, cancelTime)); + const cancelLoadPromise = new Promise((resolve) => { + cancelResolve = resolve; + }); + + return this.mutex.exec(async () => { + if (isCancel) { + return; + } + return Promise.race([ + Promise.all([cancelLoadPromise, cancelTimePromise]), + load().then((loadResult) => { + if (isCancel) { + return; + } + this.previousLoadResolve = null; + fn?.(loadResult); + }), + ]); + }); + } +} diff --git a/addons/html_builder/static/src/core/plugins/operation_plugin.js b/addons/html_builder/static/src/core/plugins/operation_plugin.js new file mode 100644 index 0000000000000..d89014bb80c52 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/operation_plugin.js @@ -0,0 +1,26 @@ +import { Plugin } from "@html_editor/plugin"; +import { Operation } from "./operation"; +import { useComponent } from "@odoo/owl"; + +export class OperationPlugin extends Plugin { + static id = "operation"; + static dependencies = ["history"]; + static shared = ["next"]; + + setup() { + this.operation = new Operation(); + } + next(...args) { + return this.operation.next(...args); + } +} + +export function useOperation() { + const comp = useComponent(); + return (apply, ...args) => { + comp.env.editor.shared.operation.next(async (...args) => { + await apply(...args); + comp.env.editor.shared.history.addStep(); + }, ...args); + }; +} diff --git a/addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons.js b/addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons.js new file mode 100644 index 0000000000000..75f195f1d0103 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons.js @@ -0,0 +1,12 @@ +import { Component } from "@odoo/owl"; + +export class OverlayButtons extends Component { + static template = "html_builder.OverlayButtons"; + static props = { + state: { type: Object }, + }; + + setup() { + this.state = this.props.state; + } +} diff --git a/addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons.scss b/addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons.scss new file mode 100644 index 0000000000000..6125a2d7c3033 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons.scss @@ -0,0 +1,30 @@ +.o_overlay_options { + + > * { + // @extend %we-generic-button; + margin: 0 1px 0; + min-width: 22px; + padding: 0 $o-we-sidebar-content-field-button-group-button-spacing * .5; + color: $o-we-fg-lighter; + + // TODO hardcoded + height: 22px; + font-size: 16px; + } + + > .o_send_back { + width: 30px; + height: 22px; + background-image: url('/web_editor/static/src/img/snippets_options/bring-backward.svg'); + background-position: center; + background-repeat: no-repeat; + } + + > .o_bring_front { + width: 30px; + height: 22px; + background-image: url('/web_editor/static/src/img/snippets_options/bring-forward.svg'); + background-position: center; + background-repeat: no-repeat; + } +} diff --git a/addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons.xml b/addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons.xml new file mode 100644 index 0000000000000..3e1a0d47eb465 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons.xml @@ -0,0 +1,13 @@ + + + +
+ +
+
+
+ diff --git a/addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons_plugin.js b/addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons_plugin.js new file mode 100644 index 0000000000000..29560fd599bcd --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/overlay_buttons/overlay_buttons_plugin.js @@ -0,0 +1,160 @@ +import { Plugin } from "@html_editor/plugin"; +import { reactive } from "@odoo/owl"; +import { throttleForAnimation } from "@web/core/utils/timing"; +import { getScrollingElement, getScrollingTarget } from "@web/core/utils/scrolling"; +import { OverlayButtons } from "./overlay_buttons"; + +export class OverlayButtonsPlugin extends Plugin { + static id = "overlayButtons"; + static dependencies = ["selection", "overlay", "history", "operation"]; + static shared = [ + "hideOverlayButtons", + "showOverlayButtons", + "hideOverlayButtonsUi", + "showOverlayButtonsUi", + ]; + resources = { + step_added_handlers: this.refreshButtons.bind(this), + change_current_options_containers_listeners: this.addOverlayButtons.bind(this), + on_mobile_preview_clicked: this.refreshButtons.bind(this), + }; + + setup() { + // TODO find how to not overflow the mobile preview. + this.iframe = this.editable.ownerDocument.defaultView.frameElement; + this.overlay = this.dependencies.overlay.createOverlay(OverlayButtons, { + positionOptions: { + position: "top-middle", + onPositioned: (overlayEl, position) => { + const iframeRect = this.iframe.getBoundingClientRect(); + if (this.target && position.top < iframeRect.top) { + const targetRect = this.target.getBoundingClientRect(); + const newTop = iframeRect.top + targetRect.bottom + 15; + position.top = newTop; + overlayEl.style.top = `${newTop}px`; + } + return; + }, + margin: 15, + flip: false, + }, + closeOnPointerdown: false, + }); + this.target = null; + this.state = reactive({ + isVisible: true, + showUi: true, + buttons: [], + }); + + this.resizeObserver = new ResizeObserver(() => { + this.overlay.updatePosition(); + }); + + // TODO duplicate of builderOverlay => extract somewhere + // Recompute the buttons when the window is resized. + this.refresh = throttleForAnimation(this.refreshButtons.bind(this)); + this.addDomListener(window, "resize", this.refresh); + + // On keydown, hide the buttons and then show them again when the mouse + // moves. + const onMouseMoveOrDown = throttleForAnimation((ev) => { + this.showOverlayButtons(); + ev.currentTarget.removeEventListener("mousemove", onMouseMoveOrDown); + ev.currentTarget.removeEventListener("mousedown", onMouseMoveOrDown); + }); + this.addDomListener(this.editable, "keydown", (ev) => { + this.hideOverlayButtons(); + ev.currentTarget.addEventListener("mousemove", onMouseMoveOrDown); + ev.currentTarget.addEventListener("mousedown", onMouseMoveOrDown); + }); + + // Hide the buttons when scrolling. Show them again when the scroll is + // over. + const scrollingElement = getScrollingElement(this.document); + const scrollingTarget = getScrollingTarget(scrollingElement); + this.addDomListener( + scrollingTarget, + "scroll", + throttleForAnimation(() => { + this.hideOverlayButtons(); + clearTimeout(this.scrollingTimeout); + this.scrollingTimeout = setTimeout(() => { + this.showOverlayButtons(); + }, 250); + }), + { capture: true } + ); + + this._cleanups.push(() => { + this.removeOverlayButtons(); + this.resizeObserver.disconnect(); + }); + } + + refreshButtons() { + if (!this.target) { + return; + } + const buttons = []; + for (const getOverlayButtons of this.getResource("get_overlay_buttons")) { + buttons.push(...getOverlayButtons(this.target)); + } + for (const button of buttons) { + const handler = button.handler; + button.handler = (...args) => { + this.dependencies.operation.next(async () => { + await handler(...args); + this.dependencies.history.addStep(); + }); + }; + } + this.state.buttons = buttons; + this.overlay.updatePosition(); + } + + hideOverlayButtons() { + this.state.isVisible = false; + } + + hideOverlayButtonsUi() { + this.state.showUi = false; + } + + showOverlayButtons() { + this.state.isVisible = true; + } + + showOverlayButtonsUi() { + this.state.showUi = true; + } + + addOverlayButtons(optionsContainer) { + this.removeOverlayButtons(); + + // Find the innermost option needing the overlay buttons. + const optionWithOverlayButtons = optionsContainer.findLast( + (option) => option.hasOverlayOptions + ); + if (optionWithOverlayButtons) { + this.target = optionWithOverlayButtons.element; + this.state.isVisible = true; + this.refreshButtons(); + this.overlay.open({ + target: optionWithOverlayButtons.element, + props: { + state: this.state, + }, + }); + this.resizeObserver.observe(this.target, { box: "border-box" }); + } + } + + removeOverlayButtons() { + if (this.target) { + this.resizeObserver.unobserve(this.target); + this.target = null; + } + this.overlay.close(); + } +} diff --git a/addons/html_builder/static/src/core/plugins/remove/remove_plugin.js b/addons/html_builder/static/src/core/plugins/remove/remove_plugin.js new file mode 100644 index 0000000000000..13735cb46ea79 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/remove/remove_plugin.js @@ -0,0 +1,183 @@ +import { Plugin } from "@html_editor/plugin"; +import { withSequence } from "@html_editor/utils/resource"; +import { _t } from "@web/core/l10n/translation"; +import { resizeGrid } from "@html_builder/utils/grid_layout_utils"; +import { getVisibleSibling } from "../move/move_plugin"; +import { unremovableNodePredicates as deletePluginPredicates } from "@html_editor/core/delete_plugin"; +import { isUnremovableQWebElement as qwebPluginPredicate } from "@html_editor/others/qweb_plugin"; + +// TODO (see forceNoDeleteButton) make a resource in the options plugins to not +// duplicate some selectors. +const unremovableSelectors = [ + ".s_carousel .carousel-item", + ".s_quotes_carousel .carousel-item", + ".s_carousel_intro .carousel-item", + ".o_mega_menu > section", + ".s_dynamic_snippet_title", + ".s_table_of_content_navbar_wrap", + ".s_table_of_content_main", + ".nav-item", +].join(", "); + +const unremovableNodePredicates = [ + ...deletePluginPredicates, + qwebPluginPredicate, + (node) => node.parentNode.matches('[data-oe-type="image"]'), + (node) => node.matches(unremovableSelectors), +]; + +export function isRemovable(el) { + return !unremovableNodePredicates.some((p) => p(el)); +} + +const layoutElementsSelector = [".o_we_shape", ".o_we_bg_filter"].join(","); + +export class RemovePlugin extends Plugin { + static id = "remove"; + static dependencies = ["history", "builder-options"]; + resources = { + get_overlay_buttons: withSequence(4, this.getActiveOverlayButtons.bind(this)), + }; + static shared = ["removeElement"]; + + setup() { + this.overlayTarget = null; + } + + getActiveOverlayButtons(target) { + if (!isRemovable(target)) { + this.overlayTarget = null; + return []; + } + + const buttons = []; + this.overlayTarget = target; + buttons.push({ + class: "oe_snippet_remove bg-danger fa fa-trash", + title: _t("Remove"), + handler: () => { + this.removeElement(this.overlayTarget); + }, + }); + return buttons; + } + + isEmptyAndRemovable(el, optionsTargetEls) { + const childrenEls = [...el.children]; + // Consider a
element as empty if it only contains a + //
element (e.g. when its image has just been + // removed). + const isEmptyFigureEl = + el.matches("figure") && + childrenEls.length === 1 && + childrenEls[0].matches("figcaption"); + + const isEmpty = + isEmptyFigureEl || + (el.textContent.trim() === "" && + childrenEls.every((el) => + // Consider layout-only elements (like bg-shapes) as empty + el.matches(layoutElementsSelector) + )); + + return ( + isEmpty && + !el.classList.contains("oe_structure") && + !el.parentElement.classList.contains("carousel-item") && + // TODO check if ok (parent editable) + (!optionsTargetEls.includes(el) || + optionsTargetEls.some((targetEl) => targetEl.contains(el))) && + isRemovable(el) + ); + } + + removeElement(el) { + this.updateContainers(el); + this.removeCurrentTarget(el); + } + + removeCurrentTarget(toRemoveEl) { + // Get the elements having options containers. + let optionsTargetEls = this.getOptionsContainersElements(); + + // TODO invisible element + // TODO will_remove_snippet + this.dispatchTo("on_remove_handlers", toRemoveEl); + + let parentEl = toRemoveEl.parentElement; + const previousSiblingEl = getVisibleSibling(toRemoveEl, "prev"); + const nextSiblingEl = getVisibleSibling(toRemoveEl, "next"); + if (parentEl.matches(".o_editable:not(body)")) { + parentEl = parentEl.closest("body"); + } + + // Remove tooltips. + [toRemoveEl, ...toRemoveEl.querySelectorAll("*")].forEach((el) => { + const tooltip = Tooltip.getInstance(el); + if (tooltip) { + tooltip.dispose(); + } + }); + // Remove the element. + toRemoveEl.remove(); + + // Resize the grid, if any, to have the correct row count. + // Must be done here and not in a dedicated onRemove method because + // onRemove is called before actually removing the element and it + // should be the case in order to resize the grid. + if (toRemoveEl.classList.contains("o_grid_item")) { + resizeGrid(parentEl); + } + + if (parentEl) { + const firstChildEl = parentEl.firstChild; + if (firstChildEl && !firstChildEl.tagName && firstChildEl.textContent === " ") { + parentEl.removeChild(firstChildEl); + } + } + + if (previousSiblingEl || nextSiblingEl) { + // Activate the previous or next visible siblings if any. + this.updateContainers(previousSiblingEl || nextSiblingEl); + } else { + // Remove potential ancestors (like when removing the last column of + // a snippet). + while (!optionsTargetEls.includes(parentEl)) { + const nextParentEl = parentEl.parentElement; + if (!nextParentEl) { + break; + } + if (this.isEmptyAndRemovable(parentEl, optionsTargetEls)) { + parentEl.remove(); + } + parentEl = nextParentEl; + } + this.updateContainers(parentEl); + optionsTargetEls = this.getOptionsContainersElements(); + if (this.isEmptyAndRemovable(parentEl, optionsTargetEls)) { + this.removeCurrentTarget(parentEl); + } + } + + // TODO is it still necessary ? + this.editable + .querySelectorAll(".note-control-selection") + .forEach((el) => (el.style.display = "none")); + this.editable.querySelectorAll(".o_table_handler").forEach((el) => el.remove()); + + // TODO: + // - trigger snippet_removed + // - display message in the editor if no snippets, + // - update invisible (already OK (see onChange)) + // - update undroppable snippets + // - cover update for translation mode + } + + getOptionsContainersElements() { + return this.dependencies["builder-options"].getContainers().map((option) => option.element); + } + + updateContainers(el) { + this.dependencies["builder-options"].updateContainers(el); + } +} diff --git a/addons/html_builder/static/src/core/plugins/replace/replace_plugin.js b/addons/html_builder/static/src/core/plugins/replace/replace_plugin.js new file mode 100644 index 0000000000000..e0fede1b2f274 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/replace/replace_plugin.js @@ -0,0 +1,55 @@ +import { Plugin } from "@html_editor/plugin"; +import { withSequence } from "@html_editor/utils/resource"; +import { _t } from "@web/core/l10n/translation"; + +// Snippets are replaceable only if they are not within another snippet (e.g. a +// "s_countdown" is not replaceable when it is dropped as inner content). +function isReplaceable(el) { + // TODO has snippet group ? + return ( + el.matches("[data-snippet]:not([data-snippet] *), .oe_structure > *") && + !el.matches(".oe_structure_solo *") + ); +} + +export class ReplacePlugin extends Plugin { + static id = "replace"; + static dependencies = ["history", "builder-options"]; + resources = { + get_overlay_buttons: withSequence(3, this.getActiveOverlayButtons.bind(this)), + }; + + setup() { + this.overlayTarget = null; + } + + getActiveOverlayButtons(target) { + if (!isReplaceable(target)) { + this.overlayTarget = null; + return []; + } + + const buttons = []; + this.overlayTarget = target; + buttons.push({ + class: "o_snippet_replace bg-warning fa fa-exchange", + title: _t("Replace"), + handler: this.replaceSnippet.bind(this), + }); + return buttons; + } + + async replaceSnippet() { + const newSnippet = await this.config.replaceSnippet(this.overlayTarget); + if (newSnippet) { + this.overlayTarget = null; + newSnippet.querySelectorAll(".s_dialog_preview").forEach((el) => el.remove()); + // TODO find a way to wait for the images to load before updating or + // to trigger a refresh once the images are loaded afterwards. + // If not possible, call updateContainers with nothing. + this.dependencies["builder-options"].updateContainers(newSnippet); + // TODO post snippet drop (onBuild,...) + this.dispatchTo("update_interactions", newSnippet); + } + } +} diff --git a/addons/html_builder/static/src/core/plugins/save_plugin.js b/addons/html_builder/static/src/core/plugins/save_plugin.js new file mode 100644 index 0000000000000..219fb07d33801 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/save_plugin.js @@ -0,0 +1,119 @@ +import { Plugin } from "@html_editor/plugin"; + +const oeStructureSelector = "#wrapwrap .oe_structure[data-oe-xpath][data-oe-id]"; +const oeFieldSelector = "#wrapwrap [data-oe-field]:not([data-oe-sanitize-prevent-edition])"; +const OE_RECORD_COVER_SELECTOR = "#wrapwrap .o_record_cover_container[data-res-model]"; +const oeCoverSelector = `#wrapwrap .s_cover[data-res-model], ${OE_RECORD_COVER_SELECTOR}`; +const SAVABLE_SELECTOR = `${oeStructureSelector}, ${oeFieldSelector}, ${oeCoverSelector}`; + +export class SavePlugin extends Plugin { + static id = "savePlugin"; + static shared = ["save"]; + + resources = { + handleNewRecords: this.handleMutations, + }; + + async save(isTranslation) { + const proms = []; + for (const fn of this.getResource("before_save_handlers")) { + proms.push(fn()); + } + await Promise.all(proms); + const saveProms = [...this.editable.querySelectorAll(".o_dirty")].map(async (dirtyEl) => { + dirtyEl.classList.remove("o_dirty"); + const cleanedEl = dirtyEl.cloneNode(true); + this.dispatchTo("clean_for_save_handlers", { root: cleanedEl }); + + if (isTranslation) { + await this.saveTranslationElement(cleanedEl); + } else { + await this.saveView(cleanedEl); + } + }); + await Promise.all(saveProms); + } + + /** + * Saves one (dirty) element of the page. + * + * @param {HTMLElement} el - the element to save. + */ + async saveView(el) { + const viewID = Number(el.dataset["oeId"]); + const context = { + website_id: this.services.website.currentWebsite.id, + lang: this.services.website.currentWebsite.metadata.lang, + // TODO: Restore the delay translation feature once it's + // fixed, see commit msg for more info. + delay_translations: false, + }; + + return this.services.orm.call( + "ir.ui.view", + "save", + [viewID, el.outerHTML, (!el.dataset["oeExpression"] && el.dataset["oeXpath"]) || null], + { context } + ); + } + + /** + * If the element holds a translation, saves it. Otherwise, fallback to the + * standard saving but with the lang kept. + * + * @param {HTMLElement} el - the element to save. + */ + async saveTranslationElement(el) { + if (el.dataset["oeTranslationSourceSha"]) { + const translations = {}; + translations[this.services.website.currentWebsite.metadata.lang] = { + [el.dataset["oeTranslationSourceSha"]]: el.innerHTML, + }; + return this.services.orm.call(el.dataset["oeModel"], "web_update_field_translations", [ + [Number(el.dataset["oeId"])], + el.dataset["oeField"], + translations, + ]); + } + // TODO: check what we want to modify in translate mode + return this.saveView(el); + } + + /** + * Handles the flag of the closest savable element to the mutation as dirty + * + * @param {Object} records - The observed mutations + * @param {String} currentOperation - The name of the current operation + */ + handleMutations(records, currentOperation) { + if (currentOperation === "undo" || currentOperation === "redo") { + // Do nothing as `o_dirty` has already been handled by the history + // plugin. + return; + } + for (const record of records) { + if (record.attributeName === "contenteditable") { + continue; + } + let targetEl = record.target; + if (!targetEl.isConnected) { + continue; + } + if (targetEl.nodeType !== Node.ELEMENT_NODE) { + targetEl = targetEl.parentElement; + } + if (!targetEl) { + continue; + } + const savableEl = targetEl.closest(SAVABLE_SELECTOR); + if ( + !savableEl || + savableEl.classList.contains("o_dirty") || + savableEl.hasAttribute("data-oe-readonly") + ) { + continue; + } + savableEl.classList.add("o_dirty"); + } + } +} diff --git a/addons/html_builder/static/src/core/plugins/save_snippet/save_snippet_plugin.js b/addons/html_builder/static/src/core/plugins/save_snippet/save_snippet_plugin.js new file mode 100644 index 0000000000000..58f38022b9dc3 --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/save_snippet/save_snippet_plugin.js @@ -0,0 +1,54 @@ +import { Plugin } from "@html_editor/plugin"; +import { withSequence } from "@html_editor/utils/resource"; +import { markup } from "@odoo/owl"; +import { _t } from "@web/core/l10n/translation"; + +const savableSelector = "[data-snippet], a.btn"; +// TODO `so_submit_button_selector` ? +const savableExclude = ".o_no_save, .s_donation_donate_btn, .s_website_form_send"; + +// Checks if the element can be saved as a custom snippet. +function isSavable(el) { + return el.matches(savableSelector) && !el.matches(savableExclude); +} + +export class SaveSnippetPlugin extends Plugin { + static id = "saveSnippet"; + resources = { + get_options_container_top_buttons: withSequence( + 1, + this.getOptionsContainerTopButtons.bind(this) + ), + }; + + getOptionsContainerTopButtons(el) { + if (!isSavable(el)) { + return []; + } + + return [ + { + class: "fa fa-fw fa-save oe_snippet_save o_we_hover_warning btn btn-outline-warning", + title: _t("Save this block to use it elsewhere"), + handler: this.saveSnippet.bind(this), + }, + ]; + } + + async saveSnippet(el) { + const cleanForSaveHandlers = this.getResource("clean_for_save_handlers"); + const savedName = await this.config.saveSnippet(el, cleanForSaveHandlers); + if (savedName) { + const message = markup( + _t( + "Your custom snippet was successfully saved as %s. Find it in your snippets collection.", + savedName + ) + ); + this.services.notification.add(message, { + type: "success", + autocloseDelay: 5000, + }); + } + } +} diff --git a/addons/html_builder/static/src/core/plugins/setup_editor_plugin.js b/addons/html_builder/static/src/core/plugins/setup_editor_plugin.js new file mode 100644 index 0000000000000..8bc7dd406b0dc --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/setup_editor_plugin.js @@ -0,0 +1,77 @@ +import { Plugin } from "@html_editor/plugin"; +import { _t } from "@web/core/l10n/translation"; + +export class SetupEditorPlugin extends Plugin { + static id = "setup_editor_plugin"; + + resources = { + clean_for_save_handlers: this.cleanForSave.bind(this), + normalize_handlers: this.setContenteditable.bind(this), + }; + + setup() { + this.editable.setAttribute("contenteditable", false); + + // Add the `o_editable` class on the editable elements + let editableEls = this.getEditableElements("[data-oe-model]") + .filter((el) => !el.matches("link, script")) + .filter((el) => !el.hasAttribute("data-oe-readonly")) + .filter( + (el) => + !el.matches( + 'img[data-oe-field="arch"], br[data-oe-field="arch"], input[data-oe-field="arch"]' + ) + ) + .filter((el) => !el.classList.contains("oe_snippet_editor")) + .filter((el) => !el.matches("hr, br, input, textarea")) + .filter((el) => !el.hasAttribute("data-oe-sanitize-prevent-edition")); + editableEls.concat(Array.from(this.editable.querySelectorAll(".o_editable"))); + editableEls.forEach((el) => el.classList.add("o_editable")); + + // Add automatic editor message on the editables where we can drag and + // drop elements. + editableEls = this.getEditableElements('.oe_structure.oe_empty, [data-oe-type="html"]'); + editableEls.forEach((el) => { + if (!el.hasAttribute("data-editor-message")) { + el.setAttribute("data-editor-message-default", true); + el.setAttribute("data-editor-message", _t("DRAG BUILDING BLOCKS HERE")); + } + }); + + // Set the `contenteditable` attribute on the editables. + this.setContenteditable(); + } + + getEditableElements(selector) { + const editableEls = [...this.editable.querySelectorAll(selector)] + .filter((el) => !el.matches(".o_not_editable")) + .filter((el) => { + const parent = el.closest(".o_editable, .o_not_editable"); + return !parent || parent.matches(".o_editable"); + }); + return editableEls; + } + + cleanForSave({ root }) { + root.classList.remove("o_editable"); + root.querySelectorAll(".o_editable").forEach((el) => { + el.classList.remove("o_editable"); + }); + + [root, ...root.querySelectorAll("[data-editor-message]")].forEach((el) => { + el.removeAttribute("data-editor-message"); + el.removeAttribute("data-editor-message-default"); + }); + + [root, ...root.querySelectorAll("[contenteditable]")].forEach((el) => + el.removeAttribute("contenteditable") + ); + } + + setContenteditable() { + const editableEls = this.getEditableElements( + '.oe_structure.oe_empty, [data-oe-type="html"]' + ); + editableEls.forEach((el) => el.setAttribute("contenteditable", !el.matches(":empty"))); + } +} diff --git a/addons/html_builder/static/src/core/plugins/visibility_plugin.js b/addons/html_builder/static/src/core/plugins/visibility_plugin.js new file mode 100644 index 0000000000000..354308180a61d --- /dev/null +++ b/addons/html_builder/static/src/core/plugins/visibility_plugin.js @@ -0,0 +1,83 @@ +import { Plugin } from "@html_editor/plugin"; +import { isMobileView } from "@html_builder/utils/utils"; + +export class VisibilityPlugin extends Plugin { + static id = "visibility"; + static shared = [ + "toggleTargetVisibility", + "cleanForSaveVisibility", + "hideInvisibleEl", + "showInvisibleEl", + ]; + + resources = { + on_option_visibility_update: onOptionVisibilityUpdate, + on_mobile_preview_clicked: this.onMobilePreviewClicked.bind(this), + }; + + cleanForSaveVisibility(editingEl) { + const show = + !editingEl.classList.contains("o_snippet_invisible") && + !editingEl.classList.contains("o_snippet_mobile_invisible") && + !editingEl.classList.contains("o_snippet_desktop_invisible"); + this.toggleTargetVisibility(editingEl, show); + const overrideInvisibleEls = [ + editingEl, + ...editingEl.querySelectorAll(".o_snippet_override_invisible"), + ]; + for (const overrideInvisibleEl of overrideInvisibleEls) { + overrideInvisibleEl.classList.remove("o_snippet_override_invisible"); + } + } + + onMobilePreviewClicked() { + const isMobilePreview = isMobileView(this.editable); + const invisibleOverrideEls = this.editable.querySelectorAll( + ".o_snippet_mobile_invisible, .o_snippet_desktop_invisible" + ); + for (const invisibleOverrideEl of [...invisibleOverrideEls]) { + const isMobileHidden = invisibleOverrideEl.classList.contains( + "o_snippet_mobile_invisible" + ); + invisibleOverrideEl.classList.remove("o_snippet_override_invisible"); + const show = isMobilePreview != isMobileHidden; + onOptionVisibilityUpdate({ editingEl: invisibleOverrideEl, show: show }); + } + } + + toggleTargetVisibility(editingEl, show) { + show = onOptionVisibilityUpdate({ editingEl: editingEl, show: show }); + const dispatchName = show ? "target_show" : "target_hide"; + this.dispatchTo(dispatchName, editingEl); + return show; + } + + hideInvisibleEl(snippetEl) { + snippetEl.classList.remove("o_snippet_override_invisible"); + } + + showInvisibleEl(snippetEl) { + const isMobilePreview = isMobileView(snippetEl); + const isMobileHidden = snippetEl.classList.contains("o_snippet_mobile_invisible"); + const isDesktopHidden = snippetEl.classList.contains("o_snippet_desktop_invisible"); + if ((isMobileHidden && isMobilePreview) || (isDesktopHidden && !isMobilePreview)) { + snippetEl.classList.add("o_snippet_override_invisible"); + } + } +} + +function isTargetVisible(editingEl) { + return editingEl.dataset.invisible !== "1"; +} + +function onOptionVisibilityUpdate({ editingEl, show }) { + if (show === undefined) { + show = !isTargetVisible(editingEl); + } + if (show) { + delete editingEl.dataset.invisible; + } else { + editingEl.dataset.invisible = "1"; + } + return show; +} diff --git a/addons/html_builder/static/src/plugins/accordion_option.js b/addons/html_builder/static/src/plugins/accordion_option.js new file mode 100644 index 0000000000000..f8acc9502a90d --- /dev/null +++ b/addons/html_builder/static/src/plugins/accordion_option.js @@ -0,0 +1,21 @@ +import { Plugin } from "@html_editor/plugin"; +import { registry } from "@web/core/registry"; + +class AccordionOptionPlugin extends Plugin { + static id = "AccordionOptionPlugin"; + static dependencies = ["clone"]; + resources = { + builder_options: [ + { + template: "html_builder.AccordionOption", + selector: ".s_accordion", + }, + { + template: "html_builder.AccordionItemOption", + selector: ".s_accordion .accordion-item", + }, + ], + }; +} + +registry.category("website-plugins").add(AccordionOptionPlugin.id, AccordionOptionPlugin); diff --git a/addons/html_builder/static/src/plugins/accordion_option.xml b/addons/html_builder/static/src/plugins/accordion_option.xml new file mode 100644 index 0000000000000..7e260c87479dc --- /dev/null +++ b/addons/html_builder/static/src/plugins/accordion_option.xml @@ -0,0 +1,45 @@ + + + + + + + Add New + + + + + + + Boxed + Highlight Active + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/src/plugins/add_element_option.js b/addons/html_builder/static/src/plugins/add_element_option.js new file mode 100644 index 0000000000000..eeeb764682a65 --- /dev/null +++ b/addons/html_builder/static/src/plugins/add_element_option.js @@ -0,0 +1,22 @@ +import { Component } from "@odoo/owl"; +import { defaultBuilderComponents } from "../core/default_builder_components"; +import { Button } from "./button"; + +export class AddElementOption extends Component { + static template = "html_builder.AddElementOption"; + static components = { + ...defaultBuilderComponents, + Button, + }; + static props = {}; + + addText() { + console.log("addText"); + } + addImage() { + console.log("addImage"); + } + addButton() { + console.log("addButton"); + } +} diff --git a/addons/html_builder/static/src/plugins/add_element_option.xml b/addons/html_builder/static/src/plugins/add_element_option.xml new file mode 100644 index 0000000000000..23d4921a391df --- /dev/null +++ b/addons/html_builder/static/src/plugins/add_element_option.xml @@ -0,0 +1,14 @@ + + + + + + + + +
+
+ +
+ +
diff --git a/addons/html_builder/static/src/plugins/background_option/background_shape.js b/addons/html_builder/static/src/plugins/background_option/background_shape.js new file mode 100644 index 0000000000000..5c8f1f290fc87 --- /dev/null +++ b/addons/html_builder/static/src/plugins/background_option/background_shape.js @@ -0,0 +1,455 @@ +import { useIsActiveItem } from "@html_builder/core/building_blocks/utils"; +import { defaultBuilderComponents } from "@html_builder/core/default_builder_components"; +import { getValueFromVar, isMobileView } from "@html_builder/utils/utils"; +import { + getBgImageURLFromEl, + getBgImageURLFromURL, + normalizeColor, +} from "@html_builder/utils/utils_css"; +import { Plugin } from "@html_editor/plugin"; +import { Component } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { pick } from "@web/core/utils/objects"; + +/** + * Returns the default colors for the currently selected shape. + * + * @param {HTMLElement} editingElement the element on which to read the + * shape data. + */ +const getDefaultColors = function (editingElement) { + const shapeContainerEl = editingElement.querySelector(":scope > .o_we_shape"); + if (!shapeContainerEl) { + return {}; + } + const shapeContainerClonedEl = shapeContainerEl.cloneNode(true); + shapeContainerClonedEl.classList.add("d-none"); + // Needs to be in document for bg-image class to take effect + editingElement.ownerDocument.body.appendChild(shapeContainerClonedEl); + shapeContainerClonedEl.style.setProperty("background-image", ""); + const shapeSrc = shapeContainerClonedEl && getBgImageURLFromEl(shapeContainerClonedEl); + shapeContainerClonedEl.remove(); + if (!shapeSrc) { + return {}; + } + const url = new URL(shapeSrc, window.location.origin); + return Object.fromEntries(url.searchParams.entries()); +}; + +class BackgroundShapePlugin extends Plugin { + static id = "backgroundShape"; + resources = { + builder_actions: this.getActions(), + }; + static shared = ["getShapeStyleUrl", "getShapeData"]; + setup() { + // TODO: update shapeBackgroundImagePerClass if a stylesheet value + // changes. + this.shapeBackgroundImagePerClass = {}; + for (const styleSheet of this.document.styleSheets) { + if (styleSheet.href && new URL(styleSheet.href).host !== location.host) { + // In some browsers, if a stylesheet is loaded from a different + // domain accessing cssRules results in a SecurityError. + continue; + } + for (const rule of [...styleSheet.cssRules]) { + if (rule.selectorText && rule.selectorText.startsWith(".o_we_shape.")) { + this.shapeBackgroundImagePerClass[rule.selectorText] = + rule.style.backgroundImage; + } + } + } + // Flip classes should no longer be used but are still present in some + // theme snippets. + const flipEls = [...this.editable.querySelectorAll(".o_we_flip_x, .o_we_flip_y")]; + for (const flipEl of flipEls) { + this.applyShape(flipEl, () => ({ flip: this.getShapeData(flipEl).flip })); + } + } + getActions() { + return { + applyShape: { + apply: ({ editingElement, param, value }) => { + param = param || {}; + const shapeData = this.getShapeData(editingElement); + const applyShapeParams = { + shape: value, + colors: this.getImplicitColors(editingElement, value, shapeData.colors), + flip: [], + animated: param.animated, + shapeAnimationSpeed: shapeData.shapeAnimationSpeed, + }; + this.applyShape(editingElement, () => applyShapeParams); + }, + isApplied: ({ editingElement, value }) => { + const currentShapeApplied = this.getShapeData(editingElement).shape; + return currentShapeApplied === value; + }, + }, + toggleBgShape: { + apply: ({ editingElement, param: showBackgroundShapes }) => { + const previousSibling = editingElement.previousElementSibling; + let shapeToSelect; + const allPossiblesShapesUrl = this.getResource("allPossiblesShapes").map( + (possiblesShape) => possiblesShape.shapeUrl + ); + if (previousSibling) { + const previousShape = this.getShapeData(previousSibling).shape; + shapeToSelect = allPossiblesShapesUrl.find( + (shape, i) => allPossiblesShapesUrl[i - 1] === previousShape + ); + } + // If there is no previous sibling, if the previous sibling + // had the last shape selected or if the previous shape + // could not be found in the possible shapes, default to the + // first shape. ([0] being no shapes selected.) + if (!shapeToSelect) { + shapeToSelect = allPossiblesShapesUrl[1]; + } + // Only show on mobile by default if toggled from mobile + // view. + const showOnMobile = isMobileView(editingElement); + this.createShapeContainer(editingElement, shapeToSelect); + const applyShapeParams = { + shape: shapeToSelect, + colors: this.getImplicitColors(editingElement, shapeToSelect), + showOnMobile, + }; + this.applyShape(editingElement, () => applyShapeParams); + showBackgroundShapes(); + }, + clean: ({ editingElement }) => { + this.applyShape(editingElement, () => ({ shape: "" })); + }, + isApplied: ({ editingElement }) => !!this.getShapeData(editingElement).shape, + }, + showOnMobile: { + apply: ({ editingElement }) => { + this.applyShape(editingElement, () => ({ + showOnMobile: false, + })); + }, + clean: ({ editingElement }) => { + this.applyShape(editingElement, () => ({ + showOnMobile: true, + })); + }, + isApplied: ({ editingElement }) => !this.getShapeData(editingElement).showOnMobile, + }, + flipShape: { + apply: ({ editingElement, param }) => { + this.applyShape(editingElement, () => { + const flip = new Set(this.getShapeData(editingElement).flip); + flip.add(param); + return { flip: [...flip] }; + }); + }, + clean: ({ editingElement, param }) => { + this.applyShape(editingElement, () => { + const flip = new Set(this.getShapeData(editingElement).flip); + flip.delete(param); + return { flip: [...flip] }; + }); + }, + isApplied: ({ editingElement, param }) => { + // Compat: flip classes are no longer used but may be + // present in client db. + const selector = `.o_we_flip_${param}`; + const hasFlipClass = !!editingElement.querySelector( + `:scope > .o_we_shape${selector}` + ); + return hasFlipClass || this.getShapeData(editingElement).flip.includes(param); + }, + }, + setBgAnimationSpeed: { + apply: ({ editingElement, value }) => { + this.applyShape(editingElement, () => ({ shapeAnimationSpeed: value })); + }, + getValue: ({ editingElement }) => + this.getShapeData(editingElement).shapeAnimationSpeed, + }, + backgroundShapeColor: { + getValue: ({ editingElement, param: colorName }) => { + // TODO check if it works when the colorpicker is + // implemented. + const { shape, colors: customColors } = this.getShapeData(editingElement); + const colors = Object.assign(getDefaultColors(editingElement), customColors); + const color = shape && colors[colorName]; + return (color && normalizeColor(color)) || ""; + }, + apply: ({ editingElement, param: colorName, value }) => { + this.applyShape(editingElement, () => { + value = getValueFromVar(value); + const { colors: previousColors } = this.getShapeData(editingElement); + const newColor = value || getDefaultColors(editingElement)[colorName]; + const newColors = Object.assign(previousColors, { [colorName]: newColor }); + return { colors: newColors }; + }); + }, + }, + }; + } + /** + * Handles everything related to saving state before preview and restoring + * it after a preview or locking in the changes when not in preview. + * + * @param {HTMLElement} editingElement + * @param {Function} computeShapeData function to compute the new shape + * data. + */ + applyShape(editingElement, computeShapeData) { + const newShapeData = computeShapeData(); + const changedShape = !!newShapeData.shape; + this.markShape(editingElement, newShapeData); + + // Updates/removes the shape container as needed and gives it the + // correct background shape + const json = editingElement.dataset.oeShapeData; + const { + shape, + colors, + flip = [], + animated = "false", + showOnMobile, + shapeAnimationSpeed, + } = json ? JSON.parse(json) : {}; + let shapeContainerEl = editingElement.querySelector(":scope > .o_we_shape"); + if (!shape) { + return this.insertShapeContainer(editingElement, null); + } + // When changing shape we want to reset the shape container (for + // transparency color). + if (changedShape) { + shapeContainerEl = this.createShapeContainer(editingElement, shape); + } + // Compat: remove old flip classes as flipping is now done inside the + // svg. + shapeContainerEl.classList.remove("o_we_flip_x", "o_we_flip_y"); + + shapeContainerEl.classList.toggle("o_we_animated", animated === "true"); + if (colors || flip.length || parseFloat(shapeAnimationSpeed) !== 0) { + // Custom colors/flip/speed, overwrite shape that is set by the + // class. + shapeContainerEl.style.setProperty( + "background-image", + `url("${this.getShapeSrc(editingElement)}")` + ); + shapeContainerEl.style.backgroundPosition = ""; + if (flip.length) { + let [xPos, yPos] = getComputedStyle(shapeContainerEl) + .backgroundPosition.split(" ") + .map((p) => parseFloat(p)); + // -X + 2*Y is a symmetry of X around Y, this is a symmetry + // around 50%. + xPos = flip.includes("x") ? -xPos + 100 : xPos; + yPos = flip.includes("y") ? -yPos + 100 : yPos; + shapeContainerEl.style.backgroundPosition = `${xPos}% ${yPos}%`; + } + } else { + // Remove custom bg image and let the shape class set the bg shape + shapeContainerEl.style.setProperty("background-image", ""); + shapeContainerEl.style.setProperty("background-position", ""); + } + shapeContainerEl.classList.toggle("o_shape_show_mobile", !!showOnMobile); + } + + /** + * Creates and inserts a container for the shape with the right classes. + * + * @param {HTMLElement} editingElement + * @param {String} shape the shape name for which to create a container + */ + createShapeContainer(editingElement, shape) { + const shapeContainer = this.insertShapeContainer( + editingElement, + document.createElement("div") + ); + editingElement.style.setProperty("position", "relative"); + shapeContainer.className = `o_we_shape o_${shape.replace(/\//g, "_")}`; + return shapeContainer; + } + /** + * Returns the implicit colors for the currently selected shape. + * + * The implicit colors are use upon shape selection. They are computed as: + * - the default colors + * - patched with each set of colors of previous siblings shape + * - patched with the colors of the previously selected shape + * - filtered to only keep the colors involved in the current shape + * + * @param {HTMLElement} editingElement + * @param {String} shapeName identifier of the selected shape. + * @param {Object} previousColors colors of the shape before its + * replacement. + */ + getImplicitColors(editingElement, shapeName, previousColors = {}) { + const selectedBackgroundUrl = this.getShapeStyleUrl(shapeName); + const defaultColors = this.getShapeDefaultColors(selectedBackgroundUrl); + let colors = previousColors; + let sibling = editingElement.previousElementSibling; + while (sibling) { + colors = Object.assign(this.getShapeData(sibling).colors || {}, colors); + sibling = sibling.previousElementSibling; + } + const defaultKeys = Object.keys(defaultColors); + colors = Object.assign(defaultColors, colors); + return pick(colors, ...defaultKeys); + } + /** + * + * @param {HTMLElement} editingElement + */ + getLastPreShapeLayerElement(editingElement) { + return editingElement.querySelector(":scope > .o_we_bg_filter"); + } + /** + * Returns the default colors for the a shape in the selector. + * + * @param {String} selectedBackgroundUrl + */ + getShapeDefaultColors(selectedBackgroundUrl) { + const shapeSrc = selectedBackgroundUrl && getBgImageURLFromURL(selectedBackgroundUrl); + const url = new URL(shapeSrc, window.location.origin); + return Object.fromEntries(url.searchParams.entries()); + } + /** + * Retrieves current shape data from the target's dataset. + * + * @param {HTMLElement} editingElement the target on which to read the shape + * data. + */ + getShapeData(editingElement) { + const defaultData = { + shape: "", + colors: getDefaultColors(editingElement), + flip: [], + showOnMobile: false, + shapeAnimationSpeed: "0", + }; + const json = editingElement.dataset.oeShapeData; + return json ? Object.assign(defaultData, JSON.parse(json.replace(/'/g, '"'))) : defaultData; + } + /** + * Returns the src of the shape corresponding to the current parameters. + * + * @param {HTMLElement} editingElement + */ + getShapeSrc(editingElement) { + const { shape, colors, flip, shapeAnimationSpeed } = this.getShapeData(editingElement); + if (!shape) { + return ""; + } + const searchParams = Object.entries(colors).map(([colorName, colorValue]) => { + const encodedCol = encodeURIComponent(colorValue); + return `${colorName}=${encodedCol}`; + }); + if (flip.length) { + searchParams.push(`flip=${encodeURIComponent(flip.sort().join(""))}`); + } + if (Number(shapeAnimationSpeed)) { + searchParams.push(`shapeAnimationSpeed=${encodeURIComponent(shapeAnimationSpeed)}`); + } + return `/web_editor/shape/${encodeURIComponent(shape)}.svg?${searchParams.join("&")}`; + } + /** + * + * @param {String} shapeName + */ + getShapeStyleUrl(shapeName) { + const shapeClassName = `o_${shapeName.replace(/\//g, "_")}`; + // Match current palette + return this.shapeBackgroundImagePerClass[`.o_we_shape.${shapeClassName}`]; + } + /** + * Inserts or removes the given container at the right position in the + * document. + * + * @param {HTMLElement} editingElement + * @param {HTMLElement} newContainer container to insert, null to remove + */ + insertShapeContainer(editingElement, newContainer) { + const shapeContainerEl = editingElement.querySelector(":scope > .o_we_shape"); + if (shapeContainerEl) { + this.removeShapeEl(shapeContainerEl); + } + if (newContainer) { + const preShapeLayerElement = this.getLastPreShapeLayerElement(editingElement); + if (preShapeLayerElement) { + preShapeLayerElement.insertAdjacentElement("afterend", newContainer); + } else { + editingElement.prepend(newContainer); + } + } + return newContainer; + } + /** + * Overwrites shape properties with the specified data. + * + * @param {HTMLElement} editingElement + * @param {Object} newData an object with the new data + */ + markShape(editingElement, newData) { + const defaultColors = getDefaultColors(editingElement); + const shapeData = Object.assign(this.getShapeData(editingElement), newData); + const areColorsDefault = Object.entries(shapeData.colors).every( + ([colorName, colorValue]) => + defaultColors[colorName] && + colorValue.toLowerCase() === defaultColors[colorName].toLowerCase() + ); + if (areColorsDefault) { + delete shapeData.colors; + } + if (!shapeData.shape) { + delete editingElement.dataset.oeShapeData; + } else { + editingElement.dataset.oeShapeData = JSON.stringify(shapeData); + } + } + /** + * + * @param {HTMLElement} shapeEl + */ + removeShapeEl(shapeEl) { + shapeEl.remove(); + } +} + +registry.category("website-plugins").add(BackgroundShapePlugin.id, BackgroundShapePlugin); + +export class BackgroundShape extends Component { + static template = "html_builder.BackgroundShape"; + static components = { + ...defaultBuilderComponents, + }; + static props = { + getShapeData: { type: Function }, + showBackgroundShapes: { type: Function }, + allPossiblesShapes: { type: Array }, + }; + setup() { + this.isActiveItem = useIsActiveItem(); + } + getCurrentBgShapeName() { + return this.getShapeInfo().label; + } + isShapeAnimated() { + return !!this.getShapeInfo().animated; + } + getShapeInfo() { + const editingEl = this.env.getEditingElement(); + const currentShapeUrl = this.props.getShapeData(editingEl).shape; + return this.props.allPossiblesShapes.find( + (possiblesShape) => possiblesShape.shapeUrl === currentShapeUrl + ); + } + getBgAnimationSpeed(speed) { + const inputValueAsNumber = Number(speed); + const ratio = + inputValueAsNumber >= 0 ? 1 + inputValueAsNumber : 1 / (1 - inputValueAsNumber); + return `${ratio.toFixed(2)}x`; + } + getDefaultColorNames() { + const editingEl = this.env.getEditingElement(); + return Object.keys(getDefaultColors(editingEl)); + } +} diff --git a/addons/html_builder/static/src/plugins/background_option/background_shape.xml b/addons/html_builder/static/src/plugins/background_option/background_shape.xml new file mode 100644 index 0000000000000..d76d8db1a18c1 --- /dev/null +++ b/addons/html_builder/static/src/plugins/background_option/background_shape.xml @@ -0,0 +1,41 @@ + + + + + + + + + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + + + + +
+ +
+ +
+
+ Animated +
+ +
+ +
+
+ + + diff --git a/addons/html_builder/static/src/plugins/badge_option.js b/addons/html_builder/static/src/plugins/badge_option.js new file mode 100644 index 0000000000000..40275b97650fe --- /dev/null +++ b/addons/html_builder/static/src/plugins/badge_option.js @@ -0,0 +1,16 @@ +import { Plugin } from "@html_editor/plugin"; +import { withSequence } from "@html_editor/utils/resource"; +import { registry } from "@web/core/registry"; + +class BadgeOptionPlugin extends Plugin { + static id = "BadgeOption"; + resources = { + builder_options: [ + withSequence(10, { + template: "html_builder.BadgeOption", + selector: ".s_badge", + }), + ], + }; +} +registry.category("website-plugins").add(BadgeOptionPlugin.id, BadgeOptionPlugin); diff --git a/addons/html_builder/static/src/plugins/badge_option.xml b/addons/html_builder/static/src/plugins/badge_option.xml new file mode 100644 index 0000000000000..928af4fe3497e --- /dev/null +++ b/addons/html_builder/static/src/plugins/badge_option.xml @@ -0,0 +1,19 @@ + + + + + + + Primary + Secondary + Success + Info + Warning + Danger + Light + Dark + + + + + diff --git a/addons/html_builder/static/src/plugins/block_alignment_option.js b/addons/html_builder/static/src/plugins/block_alignment_option.js new file mode 100644 index 0000000000000..33b34cb3de66b --- /dev/null +++ b/addons/html_builder/static/src/plugins/block_alignment_option.js @@ -0,0 +1,17 @@ +import { Plugin } from "@html_editor/plugin"; +import { registry } from "@web/core/registry"; +import { withSequence } from "@html_editor/utils/resource"; + +class BlockAlignmentOptionPlugin extends Plugin { + static id = "BlockAlignmentOption"; + resources = { + builder_options: [ + withSequence(30, { + template: "html_builder.BlockAlignmentOption", + selector: ".s_alert, .s_blockquote, .s_text_highlight", + }), + ], + }; +} + +registry.category("website-plugins").add(BlockAlignmentOptionPlugin.id, BlockAlignmentOptionPlugin); diff --git a/addons/html_builder/static/src/plugins/block_alignment_option.xml b/addons/html_builder/static/src/plugins/block_alignment_option.xml new file mode 100644 index 0000000000000..5b1d83a48f8a3 --- /dev/null +++ b/addons/html_builder/static/src/plugins/block_alignment_option.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/src/plugins/blockquote_option.js b/addons/html_builder/static/src/plugins/blockquote_option.js new file mode 100644 index 0000000000000..4215c23a8591d --- /dev/null +++ b/addons/html_builder/static/src/plugins/blockquote_option.js @@ -0,0 +1,17 @@ +import { Plugin } from "@html_editor/plugin"; +import { registry } from "@web/core/registry"; +import { withSequence } from "@html_editor/utils/resource"; + +class BlockquoteOptionPlugin extends Plugin { + static id = "BlockquoteOption"; + resources = { + builder_options: [ + withSequence(10, { + template: "html_builder.BlockquoteOption", + selector: ".s_blockquote", + }), + ], + }; +} + +registry.category("website-plugins").add(BlockquoteOptionPlugin.id, BlockquoteOptionPlugin); diff --git a/addons/html_builder/static/src/plugins/blockquote_option.xml b/addons/html_builder/static/src/plugins/blockquote_option.xml new file mode 100644 index 0000000000000..ac2a0e16b961d --- /dev/null +++ b/addons/html_builder/static/src/plugins/blockquote_option.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + None + Left line + Icon + + + + + + + + + + + Left + Center + Right + + + + + + + + diff --git a/addons/html_builder/static/src/plugins/border_configurator.js b/addons/html_builder/static/src/plugins/border_configurator.js new file mode 100644 index 0000000000000..d478e3df31842 --- /dev/null +++ b/addons/html_builder/static/src/plugins/border_configurator.js @@ -0,0 +1,36 @@ +import { Component } from "@odoo/owl"; +import { defaultBuilderComponents } from "../core/default_builder_components"; +import { useDomState } from "../core/building_blocks/utils"; + +export class BorderConfigurator extends Component { + static template = "html_builder.BorderConfigurator"; + static components = { ...defaultBuilderComponents }; + static props = { + label: { type: String }, + direction: { type: String, optional: true }, + withRoundCorner: { type: Boolean, optional: true }, + }; + static defaultProps = { + withRoundCorner: true, + }; + + setup() { + this.state = useDomState((editingElement) => ({ + hasBorder: this.hasBorder(editingElement), + })); + } + getStyleActionParam(param) { + return `border-${this.props.direction ? this.props.direction + "-" : ""}${param}`; + } + hasBorder(editingElement) { + if (!editingElement) { + return false; + } + const getAction = this.env.editor.shared.builderActions.getAction; + const styleActionValue = getAction("styleAction").getValue({ + editingElement, + param: this.getStyleActionParam("width"), + }); + return parseInt(styleActionValue.match(/\d+/g)[0]) > 0; + } +} diff --git a/addons/html_builder/static/src/plugins/border_configurator.xml b/addons/html_builder/static/src/plugins/border_configurator.xml new file mode 100644 index 0000000000000..4f7a03bf9910d --- /dev/null +++ b/addons/html_builder/static/src/plugins/border_configurator.xml @@ -0,0 +1,20 @@ + + + + + + + +
+
+
+
+ + + + + + + + + diff --git a/addons/html_builder/static/src/plugins/border_option.js b/addons/html_builder/static/src/plugins/border_option.js new file mode 100644 index 0000000000000..b52bee5555075 --- /dev/null +++ b/addons/html_builder/static/src/plugins/border_option.js @@ -0,0 +1,17 @@ +import { Plugin } from "@html_editor/plugin"; +import { registry } from "@web/core/registry"; +import { card_parent_handlers } from "./card_option"; + +class BorderOptionPlugin extends Plugin { + static id = "BorderOption"; + resources = { + builder_options: [ + { + template: "html_builder.BorderOption", + selector: "section .row > div", + exclude: `.s_col_no_bgcolor, .s_col_no_bgcolor.row > div, .s_image_gallery .row > div, .s_masonry_block .s_col_no_resize, .s_text_cover .row > .o_not_editable, ${card_parent_handlers}`, + }, + ], + }; +} +registry.category("website-plugins").add(BorderOptionPlugin.id, BorderOptionPlugin); diff --git a/addons/html_builder/static/src/plugins/border_option.xml b/addons/html_builder/static/src/plugins/border_option.xml new file mode 100644 index 0000000000000..3fd86b51387b7 --- /dev/null +++ b/addons/html_builder/static/src/plugins/border_option.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/addons/html_builder/static/src/plugins/button.js b/addons/html_builder/static/src/plugins/button.js new file mode 100644 index 0000000000000..d2cf0535a0e20 --- /dev/null +++ b/addons/html_builder/static/src/plugins/button.js @@ -0,0 +1,13 @@ +import { Component } from "@odoo/owl"; + +export class Button extends Component { + static template = "html_builder.Button"; + static props = { + label: { type: String, optional: true }, + title: { type: String, optional: true }, + iconImg: { type: String, optional: true }, + iconImgAlt: { type: String, optional: true }, + onClick: Function, + isActive: { Boolean, optional: true }, + }; +} diff --git a/addons/html_builder/static/src/plugins/button.xml b/addons/html_builder/static/src/plugins/button.xml new file mode 100644 index 0000000000000..b9904a9ae5c1a --- /dev/null +++ b/addons/html_builder/static/src/plugins/button.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/addons/html_builder/static/src/plugins/card_option.js b/addons/html_builder/static/src/plugins/card_option.js new file mode 100644 index 0000000000000..9fed2166eacbb --- /dev/null +++ b/addons/html_builder/static/src/plugins/card_option.js @@ -0,0 +1,50 @@ +import { classAction } from "@html_builder/core/plugins/core_builder_action_plugin"; +import { Plugin } from "@html_editor/plugin"; +import { registry } from "@web/core/registry"; + +export const card_parent_handlers = + ".s_three_columns .row > div, .s_comparisons .row > div, .s_cards_grid .row > div, .s_cards_soft .row > div, .s_product_list .row > div"; + +class CardOptionPlugin extends Plugin { + static id = "CardOption"; + static dependencies = ["builderActions"]; + resources = { + builder_options: [ + { + template: "html_builder.CardOption", + selector: ".s_card", + exclude: `div:is(${card_parent_handlers}) > .s_card`, + }, + ], + builder_actions: { + p: this, + get setCardWidth() { + return this.p.getCardWidthAction(); + }, + setCardAlignment: { + ...classAction, + isApplied: (...args) => { + const { editingElement: el, param: classNames } = args[0]; + // Align-left button is active by default + if (classNames === "me-auto") { + return !["mx-auto", "ms-auto"].some((cls) => el.classList.contains(cls)); + } + return classAction.isApplied(...args); + }, + }, + }, + }; + + getCardWidthAction() { + const styleAction = this.dependencies.builderActions.getAction("styleAction"); + return { + ...styleAction, + getValue: (...args) => { + const value = styleAction.getValue(...args); + return value.includes("%") ? value : "100%"; + }, + }; + } +} + +registry.category("website-plugins").add(CardOptionPlugin.id, CardOptionPlugin); diff --git a/addons/html_builder/static/src/plugins/card_option.xml b/addons/html_builder/static/src/plugins/card_option.xml new file mode 100644 index 0000000000000..2cac02c010a26 --- /dev/null +++ b/addons/html_builder/static/src/plugins/card_option.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/src/plugins/content_width_option.js b/addons/html_builder/static/src/plugins/content_width_option.js new file mode 100644 index 0000000000000..e0a1253e0633d --- /dev/null +++ b/addons/html_builder/static/src/plugins/content_width_option.js @@ -0,0 +1,18 @@ +import { Plugin } from "@html_editor/plugin"; +import { registry } from "@web/core/registry"; + +class ContentWidthOptionPlugin extends Plugin { + static id = "ContentWidthOption"; + resources = { + builder_options: [ + { + template: "html_builder.ContentWidthOption", + selector: "section, .s_carousel .carousel-item, .s_carousel_intro_item", + exclude: "[data-snippet] :not(.oe_structure) > [data-snippet]", + // TODO add target and remove applyTo in the template of ContentWidthOption ? + // target: "> .container, > .container-fluid, > .o_container_small", + }, + ], + }; +} +registry.category("website-plugins").add(ContentWidthOptionPlugin.id, ContentWidthOptionPlugin); diff --git a/addons/html_builder/static/src/plugins/content_width_option.xml b/addons/html_builder/static/src/plugins/content_width_option.xml new file mode 100644 index 0000000000000..06ba06ffac52e --- /dev/null +++ b/addons/html_builder/static/src/plugins/content_width_option.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/src/plugins/cta_badge_option.js b/addons/html_builder/static/src/plugins/cta_badge_option.js new file mode 100644 index 0000000000000..7a0fe061887a9 --- /dev/null +++ b/addons/html_builder/static/src/plugins/cta_badge_option.js @@ -0,0 +1,15 @@ +import { Plugin } from "@html_editor/plugin"; +import { registry } from "@web/core/registry"; + +class CTABadgeOptionPlugin extends Plugin { + static id = "CTABadgeOption"; + resources = { + builder_options: [ + { + template: "html_builder.CTABadgeOption", + selector: ".s_cta_badge", + }, + ], + }; +} +registry.category("website-plugins").add(CTABadgeOptionPlugin.id, CTABadgeOptionPlugin); diff --git a/addons/html_builder/static/src/plugins/cta_badge_option.xml b/addons/html_builder/static/src/plugins/cta_badge_option.xml new file mode 100644 index 0000000000000..a024b619151a9 --- /dev/null +++ b/addons/html_builder/static/src/plugins/cta_badge_option.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/addons/html_builder/static/src/plugins/dot_option.xml b/addons/html_builder/static/src/plugins/dot_option.xml new file mode 100644 index 0000000000000..431950345b09f --- /dev/null +++ b/addons/html_builder/static/src/plugins/dot_option.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/src/plugins/dynamic_snippet_carousel_option.js b/addons/html_builder/static/src/plugins/dynamic_snippet_carousel_option.js new file mode 100644 index 0000000000000..608439610af1e --- /dev/null +++ b/addons/html_builder/static/src/plugins/dynamic_snippet_carousel_option.js @@ -0,0 +1,31 @@ +import { Plugin } from "@html_editor/plugin"; +import { registry } from "@web/core/registry"; +import { defaultBuilderComponents } from "../core/default_builder_components"; +import { DynamicSnippetOption } from "./dynamic_snippet_option"; + +class DynamicSnippetCarouselOptionPlugin extends Plugin { + static id = "DynamicSnippetCarouselOption"; + static shared = ["getComponentProps"]; + static dependencies = ["DynamicSnippetOption"]; + resources = { + builder_options: { + OptionComponent: DynamicSnippetCarouselOption, + props: this.getComponentProps(), + selector: ".s_dynamic_snippet_carousel", + }, + }; + getComponentProps() { + return { + ...this.dependencies.DynamicSnippetOption.getComponentProps(), + }; + } +} + +registry + .category("website-plugins") + .add(DynamicSnippetCarouselOptionPlugin.id, DynamicSnippetCarouselOptionPlugin); + +export class DynamicSnippetCarouselOption extends DynamicSnippetOption { + static template = "html_builder.DynamicSnippetCarouselOption"; + static components = { ...defaultBuilderComponents }; +} diff --git a/addons/html_builder/static/src/plugins/dynamic_snippet_carousel_option.xml b/addons/html_builder/static/src/plugins/dynamic_snippet_carousel_option.xml new file mode 100644 index 0000000000000..225680c811083 --- /dev/null +++ b/addons/html_builder/static/src/plugins/dynamic_snippet_carousel_option.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/html_builder/static/src/plugins/dynamic_snippet_option.js b/addons/html_builder/static/src/plugins/dynamic_snippet_option.js new file mode 100644 index 0000000000000..c147288a9a0a5 --- /dev/null +++ b/addons/html_builder/static/src/plugins/dynamic_snippet_option.js @@ -0,0 +1,209 @@ +import { Plugin } from "@html_editor/plugin"; +import { Component, onWillStart, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { defaultBuilderComponents } from "../core/default_builder_components"; +import { useDomState, useIsActiveItem } from "@html_builder/core/building_blocks/utils"; +import { withSequence } from "@html_editor/utils/resource"; +import { rpc } from "@web/core/network/rpc"; +import { Cache } from "@web/core/utils/cache"; + +class DynamicSnippetOptionPlugin extends Plugin { + static id = "DynamicSnippetOption"; + static shared = ["getComponentProps"]; + resources = { + builder_options: [ + withSequence(10, { + OptionComponent: DynamicSnippetOption, + props: this.getComponentProps(), + selector: ".s_dynamic_snippet", + }), + ], + builder_actions: this.getActions(), + }; + setup() { + this.dynamicFiltersCache = new Cache(this._fetchDynamicFilters, JSON.stringify); + this.dynamicFilterTemplatesCache = new Cache( + this._fetchDynamicFilterTemplates, + JSON.stringify + ); + } + destroy() { + super.destroy(); + this.dynamicFiltersCache.invalidate(); + this.dynamicFilterTemplatesCache.invalidate(); + } + getComponentProps() { + return { + fetchDynamicFilters: this.fetchDynamicFilters.bind(this), + fetchDynamicFilterTemplates: this.fetchDynamicFilterTemplates.bind(this), + }; + } + getActions() { + return { + dynamicFilter: { + isApplied: ({ editingElement: el, param }) => + parseInt(el.dataset.filterId) === param.id, + apply: ({ editingElement: el, param }) => { + el.dataset.filterId = param.id; + if ( + !el.dataset.templateKey || + !el.dataset.templateKey.includes( + `_${param.model_name.replaceAll(".", "_")}_` + ) + ) { + // Only if filter's model name changed + this.updateTemplate(el, param.defaultTemplate); + } + }, + }, + dynamicFilterTemplate: { + isApplied: ({ editingElement: el, param }) => el.dataset.templateKey === param.key, + apply: ({ editingElement: el, param }) => { + this.updateTemplate(el, param); + }, + }, + customizeTemplate: { + isApplied: ({ editingElement: el, param }) => { + const customData = JSON.parse(el.dataset.customTemplateData); + return customData[param]; + }, + apply: ({ editingElement: el, param, value }) => { + const customData = JSON.parse(el.dataset.customTemplateData); + customData[param] = true; + el.dataset.customTemplateData = JSON.stringify(customData); + }, + clean: ({ editingElement: el, param, value }) => { + const customData = JSON.parse(el.dataset.customTemplateData); + customData[param] = false; + el.dataset.customTemplateData = JSON.stringify(customData); + }, + }, + }; + } + getTemplateClass(templateKey) { + return templateKey.replace(/.*\.dynamic_filter_template_/, "s_"); + } + updateTemplate(el, template) { + const newTemplateKey = template.key; + const oldTemplateKey = el.dataset.templateKey; + el.dataset.templateKey = newTemplateKey; + if (oldTemplateKey) { + el.classList.remove(this.getTemplateClass(oldTemplateKey)); + } + el.classList.add(this.getTemplateClass(newTemplateKey)); + + if (template.numOfEl) { + el.dataset.numberOfElements = template.numOfEl; + } else { + delete el.dataset.numberOfElements; + } + if (template.numOfElSm) { + el.dataset.numberOfElementsSmallDevices = template.numOfElSm; + } else { + delete el.dataset.numberOfElementsSmallDevices; + } + if (template.numOfElFetch) { + el.dataset.numberOfRecords = template.numOfElFetch; + } + if (template.extraClasses) { + el.dataset.extraClasses = template.extraClasses; + } else { + delete el.dataset.extraClasses; + } + if (template.columnClasses) { + el.dataset.columnClasses = template.columnClasses; + } else { + delete el.dataset.columnClasses; + } + } + async fetchDynamicFilters(params) { + return this.dynamicFiltersCache.read(params); + } + async _fetchDynamicFilters(params) { + return rpc("/website/snippet/options_filters", params); + } + async fetchDynamicFilterTemplates(params) { + return this.dynamicFilterTemplatesCache.read(params); + } + async _fetchDynamicFilterTemplates(params) { + return rpc("/website/snippet/filter_templates", params); + } +} + +registry.category("website-plugins").add(DynamicSnippetOptionPlugin.id, DynamicSnippetOptionPlugin); + +export class DynamicSnippetOption extends Component { + static template = "html_builder.DynamicSnippetOption"; + static components = { ...defaultBuilderComponents }; + static props = { + fetchDynamicFilters: Function, + fetchDynamicFilterTemplates: Function, + slots: { type: Object, optional: true }, + }; + + setup() { + this.isActiveItem = useIsActiveItem(); + // specify model name in subclasses to filter the list of available model record filters + this.modelNameFilter = undefined; + this.contextualFilterDomain = []; + // Indicates that some current options are a default selection. + this.isOptionDefault = {}; + + onWillStart(async () => { + await this.fetchDynamicFiltersAndTemplates(); + }); + this.state = useState({ + defaultFilterId: undefined, + dynamicFilters: {}, // per id, to locate default filter + dynamicFilterTemplates: [], + }); + this.domState = useDomState(() => ({ + filterId: this.env.getEditingElement().dataset.filterId, + })); + } + + async fetchDynamicFiltersAndTemplates() { + const dynamicFilters = await this.props.fetchDynamicFilters({ + model_name: this.modelNameFilter, + search_domain: this.contextualFilterDomain, + }); + if (!dynamicFilters.length) { + // Additional modules are needed for dynamic filters to be defined. + return; + } + const uniqueModelName = new Set(); + for (const dynamicFilter of dynamicFilters) { + this.state.dynamicFilters[dynamicFilter.id] = dynamicFilter; + uniqueModelName.add(dynamicFilter.model_name); + } + this.state.defaultFilterId = dynamicFilters[0].id; + const templateFilter = uniqueModelName.length === 1 ? dynamicFilters[0].model_name : ""; + const dynamicFilterTemplates = await this.props.fetchDynamicFilterTemplates({ + filter_name: templateFilter.replaceAll(".", "_"), + }); + this.state.dynamicFilterTemplates.push(...dynamicFilterTemplates); + const defaultTemplatePerModel = {}; + for (const modelName of uniqueModelName) { + for (const template of dynamicFilterTemplates) { + if (template.key.includes(`_${modelName.replaceAll(".", "_")}_`)) { + defaultTemplatePerModel[modelName] = template; + break; + } + } + } + for (const dynamicFilter of dynamicFilters) { + dynamicFilter.defaultTemplate = defaultTemplatePerModel[dynamicFilter.model_name]; + } + } + getFilteredTemplates() { + const namePattern = + "_" + + this.state.dynamicFilters[ + this.domState.filterId || this.state.defaultFilterId + ].model_name.replaceAll(".", "_") + + "_"; + return this.state.dynamicFilterTemplates.filter((template) => + template.key.includes(namePattern) + ); + } +} diff --git a/addons/html_builder/static/src/plugins/dynamic_snippet_option.xml b/addons/html_builder/static/src/plugins/dynamic_snippet_option.xml new file mode 100644 index 0000000000000..d00fdcd62a0ac --- /dev/null +++ b/addons/html_builder/static/src/plugins/dynamic_snippet_option.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/src/plugins/embed_code_option.js b/addons/html_builder/static/src/plugins/embed_code_option.js new file mode 100644 index 0000000000000..0e5c2100cff3e --- /dev/null +++ b/addons/html_builder/static/src/plugins/embed_code_option.js @@ -0,0 +1,79 @@ +import { Plugin } from "@html_editor/plugin"; +import { EmbedCodeOptionDialog } from "@html_builder/plugins/embed_code_option_dialog"; +import { withSequence } from "@html_editor/utils/resource"; +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { cloneContentEls } from "@website/js/utils"; + +class EmbedCodeOptionPlugin extends Plugin { + static id = "EmbedCodeOption"; + + resources = { + builder_options: [ + withSequence(5, { + template: "html_builder.EmbedCodeOption", + selector: ".s_embed_code", + }), + ], + builder_actions: this.getActions(), + clean_for_save_handlers: this.cleanForSave.bind(this), + }; + + getTemplateEl(editingElement) { + return editingElement.querySelector("template.s_embed_code_saved"); + } + + getActions() { + return { + editCode: { + load: async ({ editingElement }) => { + let newContent; + await new Promise((resolve) => { + this.services.dialog.add( + EmbedCodeOptionDialog, + { + title: _t("Edit embedded code"), + value: this.getTemplateEl(editingElement).innerHTML.trim(), + mode: "xml", + confirm: (newValue) => { + newContent = newValue; + }, + }, + { onClose: resolve } + ); + }); + return newContent; + }, + apply: ({ editingElement, loadResult: content }) => { + if (!content) { + return; + } + // Remove scripts tags from the DOM as we don't want them to + // interfere during edition, but keeps them in a + // `