summaryrefslogtreecommitdiff
path: root/addons/web_editor/static
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web_editor/static
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web_editor/static')
-rw-r--r--addons/web_editor/static/lib/cropperjs/LICENSE21
-rw-r--r--addons/web_editor/static/lib/cropperjs/cropper.css304
-rw-r--r--addons/web_editor/static/lib/cropperjs/cropper.js3566
-rw-r--r--addons/web_editor/static/lib/jQuery.transfo.js453
-rw-r--r--addons/web_editor/static/lib/jabberwock/jabberwock.css0
-rw-r--r--addons/web_editor/static/lib/jabberwock/jabberwock.js0
-rw-r--r--addons/web_editor/static/lib/jquery-cropper/LICENSE21
-rw-r--r--addons/web_editor/static/lib/jquery-cropper/jquery-cropper.js75
-rw-r--r--addons/web_editor/static/lib/summernote/src/css/summernote.css447
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/EventHandler.js572
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/Renderer.js1026
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/app.js42
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/core/agent.js170
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/core/async.js68
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/core/dom.js1120
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/core/func.js130
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/core/key.js96
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/core/list.js191
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/core/range.js796
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/defaults.js416
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/editing/Bullet.js235
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/editing/History.js118
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/editing/Style.js161
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/editing/Table.js52
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/editing/Typing.js86
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/enable_summernote.js1
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/intro.js21
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/module/Button.js169
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js42
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/module/Codeview.js137
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/module/DragAndDrop.js108
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/module/Editor.js910
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/module/Fullscreen.js51
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/module/Handle.js101
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/module/HelpDialog.js35
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/module/ImageDialog.js110
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/module/LinkDialog.js129
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/module/Popover.js129
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/module/Statusbar.js44
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/module/Toolbar.js101
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/outro.js1
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/summernote.js328
-rw-r--r--addons/web_editor/static/lib/summernote/src/less/summernote.less467
-rw-r--r--addons/web_editor/static/lib/vkbeautify/vkbeautify.0.99.00.beta.js358
-rw-r--r--addons/web_editor/static/lib/webgl-image-filter/LICENSE21
-rw-r--r--addons/web_editor/static/lib/webgl-image-filter/webgl-image-filter.js640
-rw-r--r--addons/web_editor/static/shapes/Airy/01.svg42
-rw-r--r--addons/web_editor/static/shapes/Airy/02.svg66
-rw-r--r--addons/web_editor/static/shapes/Airy/03.svg69
-rw-r--r--addons/web_editor/static/shapes/Airy/04.svg32
-rw-r--r--addons/web_editor/static/shapes/Airy/05.svg34
-rw-r--r--addons/web_editor/static/shapes/Airy/06.svg303
-rw-r--r--addons/web_editor/static/shapes/Airy/07.svg278
-rw-r--r--addons/web_editor/static/shapes/Airy/08.svg28
-rw-r--r--addons/web_editor/static/shapes/Airy/09.svg28
-rw-r--r--addons/web_editor/static/shapes/Airy/10.svg46
-rw-r--r--addons/web_editor/static/shapes/Airy/11.svg65
-rw-r--r--addons/web_editor/static/shapes/Airy/12.svg19
-rw-r--r--addons/web_editor/static/shapes/Airy/13.svg38
-rw-r--r--addons/web_editor/static/shapes/Airy/14.svg58
-rw-r--r--addons/web_editor/static/shapes/Blobs/01.svg3
-rw-r--r--addons/web_editor/static/shapes/Blobs/02.svg4
-rw-r--r--addons/web_editor/static/shapes/Blobs/03.svg3
-rw-r--r--addons/web_editor/static/shapes/Blobs/04.svg3
-rw-r--r--addons/web_editor/static/shapes/Blobs/05.svg3
-rw-r--r--addons/web_editor/static/shapes/Blobs/06.svg3
-rw-r--r--addons/web_editor/static/shapes/Blobs/07.svg3
-rw-r--r--addons/web_editor/static/shapes/Blobs/08.svg3
-rw-r--r--addons/web_editor/static/shapes/Blobs/09.svg6
-rw-r--r--addons/web_editor/static/shapes/Blobs/10.svg5
-rw-r--r--addons/web_editor/static/shapes/Blobs/11.svg3
-rw-r--r--addons/web_editor/static/shapes/Blobs/12.svg3
-rw-r--r--addons/web_editor/static/shapes/Blocks/01.svg26
-rw-r--r--addons/web_editor/static/shapes/Blocks/01_001.svg26
-rw-r--r--addons/web_editor/static/shapes/Blocks/02.svg25
-rw-r--r--addons/web_editor/static/shapes/Blocks/02_001.svg25
-rw-r--r--addons/web_editor/static/shapes/Blocks/03.svg16
-rw-r--r--addons/web_editor/static/shapes/Blocks/04.svg12
-rw-r--r--addons/web_editor/static/shapes/Bold/01.svg3
-rw-r--r--addons/web_editor/static/shapes/Bold/02.svg5
-rw-r--r--addons/web_editor/static/shapes/Bold/03.svg5
-rw-r--r--addons/web_editor/static/shapes/Bold/04.svg4
-rw-r--r--addons/web_editor/static/shapes/Bold/05.svg3
-rw-r--r--addons/web_editor/static/shapes/Bold/05_001.svg3
-rw-r--r--addons/web_editor/static/shapes/Bold/06.svg3
-rw-r--r--addons/web_editor/static/shapes/Bold/06_001.svg3
-rw-r--r--addons/web_editor/static/shapes/Bold/07.svg4
-rw-r--r--addons/web_editor/static/shapes/Bold/08.svg3
-rw-r--r--addons/web_editor/static/shapes/Bold/09.svg4
-rw-r--r--addons/web_editor/static/shapes/Bold/10.svg6
-rw-r--r--addons/web_editor/static/shapes/Bold/10_001.svg5
-rw-r--r--addons/web_editor/static/shapes/Bold/11.svg17
-rw-r--r--addons/web_editor/static/shapes/Bold/11_001.svg4
-rw-r--r--addons/web_editor/static/shapes/Bold/12.svg5
-rw-r--r--addons/web_editor/static/shapes/Origins/01.svg7
-rw-r--r--addons/web_editor/static/shapes/Origins/02.svg3
-rw-r--r--addons/web_editor/static/shapes/Origins/03.svg3
-rw-r--r--addons/web_editor/static/shapes/Origins/04.svg3
-rw-r--r--addons/web_editor/static/shapes/Origins/05.svg9
-rw-r--r--addons/web_editor/static/shapes/Origins/06.svg9
-rw-r--r--addons/web_editor/static/shapes/Origins/07.svg17
-rw-r--r--addons/web_editor/static/shapes/Origins/08.svg3
-rw-r--r--addons/web_editor/static/shapes/Origins/09.svg5
-rw-r--r--addons/web_editor/static/shapes/Origins/10.svg7
-rw-r--r--addons/web_editor/static/shapes/Origins/11.svg13
-rw-r--r--addons/web_editor/static/shapes/Origins/12.svg9
-rw-r--r--addons/web_editor/static/shapes/Origins/13.svg13
-rw-r--r--addons/web_editor/static/shapes/Origins/14.svg9
-rw-r--r--addons/web_editor/static/shapes/Origins/15.svg3
-rw-r--r--addons/web_editor/static/shapes/Rainy/01.svg58
-rw-r--r--addons/web_editor/static/shapes/Rainy/02.svg77
-rw-r--r--addons/web_editor/static/shapes/Rainy/03.svg32
-rw-r--r--addons/web_editor/static/shapes/Rainy/04.svg26
-rw-r--r--addons/web_editor/static/shapes/Rainy/05.svg53
-rw-r--r--addons/web_editor/static/shapes/Rainy/05_001.svg25
-rw-r--r--addons/web_editor/static/shapes/Rainy/06.svg29
-rw-r--r--addons/web_editor/static/shapes/Rainy/07.svg30
-rw-r--r--addons/web_editor/static/shapes/Rainy/08.svg21
-rw-r--r--addons/web_editor/static/shapes/Rainy/09.svg12
-rw-r--r--addons/web_editor/static/shapes/Wavy/01.svg30
-rw-r--r--addons/web_editor/static/shapes/Wavy/02.svg20
-rw-r--r--addons/web_editor/static/shapes/Wavy/03.svg20
-rw-r--r--addons/web_editor/static/shapes/Wavy/04.svg19
-rw-r--r--addons/web_editor/static/shapes/Wavy/05.svg20
-rw-r--r--addons/web_editor/static/shapes/Wavy/06.svg44
-rw-r--r--addons/web_editor/static/shapes/Wavy/06_001.svg45
-rw-r--r--addons/web_editor/static/shapes/Wavy/07.svg13
-rw-r--r--addons/web_editor/static/shapes/Wavy/08.svg29
-rw-r--r--addons/web_editor/static/shapes/Wavy/09.svg19
-rw-r--r--addons/web_editor/static/shapes/Wavy/10.svg19
-rw-r--r--addons/web_editor/static/shapes/Wavy/11.svg153
-rw-r--r--addons/web_editor/static/shapes/Wavy/12.svg10
-rw-r--r--addons/web_editor/static/shapes/Wavy/13.svg20
-rw-r--r--addons/web_editor/static/shapes/Wavy/14.svg26
-rw-r--r--addons/web_editor/static/shapes/Wavy/15.svg9
-rw-r--r--addons/web_editor/static/shapes/Wavy/16.svg3
-rw-r--r--addons/web_editor/static/shapes/Wavy/17.svg3
-rw-r--r--addons/web_editor/static/shapes/Wavy/18.svg3
-rw-r--r--addons/web_editor/static/shapes/Wavy/19.svg4
-rw-r--r--addons/web_editor/static/shapes/Wavy/20.svg4
-rw-r--r--addons/web_editor/static/shapes/Wavy/21.svg5
-rw-r--r--addons/web_editor/static/shapes/Wavy/22.svg4
-rw-r--r--addons/web_editor/static/shapes/Wavy/23.svg3
-rw-r--r--addons/web_editor/static/shapes/Zigs/01.svg47
-rw-r--r--addons/web_editor/static/shapes/Zigs/02.svg19
-rw-r--r--addons/web_editor/static/shapes/Zigs/03.svg55
-rw-r--r--addons/web_editor/static/shapes/Zigs/04.svg41
-rw-r--r--addons/web_editor/static/shapes/Zigs/05.svg10
-rw-r--r--addons/web_editor/static/shapes/convert.js97
-rw-r--r--addons/web_editor/static/src/img/curved_arrow.svg14
-rw-r--r--addons/web_editor/static/src/img/snippet_disabled.svg7
-rw-r--r--addons/web_editor/static/src/img/snippets_options/bg_shape.svg11
-rw-r--r--addons/web_editor/static/src/img/snippets_options/o_overlay_move_drag.svg12
-rw-r--r--addons/web_editor/static/src/js/backend/convert_inline.js485
-rw-r--r--addons/web_editor/static/src/js/backend/field_html.js536
-rw-r--r--addons/web_editor/static/src/js/base.js173
-rw-r--r--addons/web_editor/static/src/js/common/ace.js944
-rw-r--r--addons/web_editor/static/src/js/common/utils.js266
-rw-r--r--addons/web_editor/static/src/js/editor/custom_colors.js0
-rw-r--r--addons/web_editor/static/src/js/editor/editor.js289
-rw-r--r--addons/web_editor/static/src/js/editor/image_processing.js335
-rw-r--r--addons/web_editor/static/src/js/editor/rte.js816
-rw-r--r--addons/web_editor/static/src/js/editor/rte.summernote.js1280
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.editor.js2776
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.options.js4908
-rw-r--r--addons/web_editor/static/src/js/editor/summernote.js2527
-rw-r--r--addons/web_editor/static/src/js/frontend/loader.js28
-rw-r--r--addons/web_editor/static/src/js/frontend/loader_loading.js33
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/fonts.js99
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/root.js91
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js62
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js410
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js81
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js213
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js339
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/media.js1463
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js279
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js29
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/wysiwyg.js274
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js132
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/wysiwyg_snippets.js56
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/wysiwyg_translate_attributes.js0
-rw-r--r--addons/web_editor/static/src/scss/13_0_color_system_support_primary_variables.scss1
-rw-r--r--addons/web_editor/static/src/scss/bootstrap_overridden.scss76
-rw-r--r--addons/web_editor/static/src/scss/bootstrap_overridden_backend.scss14
-rw-r--r--addons/web_editor/static/src/scss/secondary_variables.scss137
-rw-r--r--addons/web_editor/static/src/scss/web_editor.backend.scss69
-rw-r--r--addons/web_editor/static/src/scss/web_editor.common.scss782
-rw-r--r--addons/web_editor/static/src/scss/web_editor.frontend.scss74
-rw-r--r--addons/web_editor/static/src/scss/web_editor.variables.scss728
-rw-r--r--addons/web_editor/static/src/scss/wysiwyg.scss522
-rw-r--r--addons/web_editor/static/src/scss/wysiwyg_iframe.scss27
-rw-r--r--addons/web_editor/static/src/scss/wysiwyg_snippets.scss1951
-rw-r--r--addons/web_editor/static/src/xml/ace.xml63
-rw-r--r--addons/web_editor/static/src/xml/backend.xml20
-rw-r--r--addons/web_editor/static/src/xml/editor.xml42
-rw-r--r--addons/web_editor/static/src/xml/snippets.xml102
-rw-r--r--addons/web_editor/static/src/xml/wysiwyg.xml579
-rw-r--r--addons/web_editor/static/src/xml/wysiwyg_colorpicker.xml33
-rw-r--r--addons/web_editor/static/tests/field_html_tests.js528
-rw-r--r--addons/web_editor/static/tests/test_utils.js722
201 files changed, 42132 insertions, 0 deletions
diff --git a/addons/web_editor/static/lib/cropperjs/LICENSE b/addons/web_editor/static/lib/cropperjs/LICENSE
new file mode 100644
index 00000000..4ca99acc
--- /dev/null
+++ b/addons/web_editor/static/lib/cropperjs/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright 2015-present Chen Fengyuan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/addons/web_editor/static/lib/cropperjs/cropper.css b/addons/web_editor/static/lib/cropperjs/cropper.css
new file mode 100644
index 00000000..4aba1421
--- /dev/null
+++ b/addons/web_editor/static/lib/cropperjs/cropper.css
@@ -0,0 +1,304 @@
+/*!
+ * Cropper.js v1.5.5
+ * https://fengyuanchen.github.io/cropperjs
+ *
+ * Copyright 2015-present Chen Fengyuan
+ * Released under the MIT license
+ *
+ * Date: 2019-08-04T02:26:27.232Z
+ */
+
+ .cropper-container {
+ direction: ltr;
+ font-size: 0;
+ line-height: 0;
+ position: relative;
+ -ms-touch-action: none;
+ touch-action: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+.cropper-container img {
+ display: block;
+ height: 100%;
+ image-orientation: 0deg;
+ max-height: none !important;
+ max-width: none !important;
+ min-height: 0 !important;
+ min-width: 0 !important;
+ width: 100%;
+}
+
+.cropper-wrap-box,
+.cropper-canvas,
+.cropper-drag-box,
+.cropper-crop-box,
+.cropper-modal {
+ bottom: 0;
+ left: 0;
+ position: absolute;
+ right: 0;
+ top: 0;
+}
+
+.cropper-wrap-box,
+.cropper-canvas {
+ overflow: hidden;
+}
+
+.cropper-drag-box {
+ background-color: #fff;
+ opacity: 0;
+}
+
+.cropper-modal {
+ background-color: #000;
+ opacity: 0.5;
+}
+
+.cropper-view-box {
+ display: block;
+ height: 100%;
+ outline: 1px solid #39f;
+ outline-color: rgba(51, 153, 255, 0.75);
+ overflow: hidden;
+ width: 100%;
+}
+
+.cropper-dashed {
+ border: 0 dashed #eee;
+ display: block;
+ opacity: 0.5;
+ position: absolute;
+}
+
+.cropper-dashed.dashed-h {
+ border-bottom-width: 1px;
+ border-top-width: 1px;
+ height: calc(100% / 3);
+ left: 0;
+ top: calc(100% / 3);
+ width: 100%;
+}
+
+.cropper-dashed.dashed-v {
+ border-left-width: 1px;
+ border-right-width: 1px;
+ height: 100%;
+ left: calc(100% / 3);
+ top: 0;
+ width: calc(100% / 3);
+}
+
+.cropper-center {
+ display: block;
+ height: 0;
+ left: 50%;
+ opacity: 0.75;
+ position: absolute;
+ top: 50%;
+ width: 0;
+}
+
+.cropper-center::before,
+.cropper-center::after {
+ background-color: #eee;
+ content: ' ';
+ display: block;
+ position: absolute;
+}
+
+.cropper-center::before {
+ height: 1px;
+ left: -3px;
+ top: 0;
+ width: 7px;
+}
+
+.cropper-center::after {
+ height: 7px;
+ left: 0;
+ top: -3px;
+ width: 1px;
+}
+
+.cropper-face,
+.cropper-line,
+.cropper-point {
+ display: block;
+ height: 100%;
+ opacity: 0.1;
+ position: absolute;
+ width: 100%;
+}
+
+.cropper-face {
+ background-color: #fff;
+ left: 0;
+ top: 0;
+}
+
+.cropper-line {
+ background-color: #39f;
+}
+
+.cropper-line.line-e {
+ cursor: ew-resize;
+ right: -3px;
+ top: 0;
+ width: 5px;
+}
+
+.cropper-line.line-n {
+ cursor: ns-resize;
+ height: 5px;
+ left: 0;
+ top: -3px;
+}
+
+.cropper-line.line-w {
+ cursor: ew-resize;
+ left: -3px;
+ top: 0;
+ width: 5px;
+}
+
+.cropper-line.line-s {
+ bottom: -3px;
+ cursor: ns-resize;
+ height: 5px;
+ left: 0;
+}
+
+.cropper-point {
+ background-color: #39f;
+ height: 5px;
+ opacity: 0.75;
+ width: 5px;
+}
+
+.cropper-point.point-e {
+ cursor: ew-resize;
+ margin-top: -3px;
+ right: -3px;
+ top: 50%;
+}
+
+.cropper-point.point-n {
+ cursor: ns-resize;
+ left: 50%;
+ margin-left: -3px;
+ top: -3px;
+}
+
+.cropper-point.point-w {
+ cursor: ew-resize;
+ left: -3px;
+ margin-top: -3px;
+ top: 50%;
+}
+
+.cropper-point.point-s {
+ bottom: -3px;
+ cursor: s-resize;
+ left: 50%;
+ margin-left: -3px;
+}
+
+.cropper-point.point-ne {
+ cursor: nesw-resize;
+ right: -3px;
+ top: -3px;
+}
+
+.cropper-point.point-nw {
+ cursor: nwse-resize;
+ left: -3px;
+ top: -3px;
+}
+
+.cropper-point.point-sw {
+ bottom: -3px;
+ cursor: nesw-resize;
+ left: -3px;
+}
+
+.cropper-point.point-se {
+ bottom: -3px;
+ cursor: nwse-resize;
+ height: 20px;
+ opacity: 1;
+ right: -3px;
+ width: 20px;
+}
+
+@media (min-width: 768px) {
+ .cropper-point.point-se {
+ height: 15px;
+ width: 15px;
+ }
+}
+
+@media (min-width: 992px) {
+ .cropper-point.point-se {
+ height: 10px;
+ width: 10px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .cropper-point.point-se {
+ height: 5px;
+ opacity: 0.75;
+ width: 5px;
+ }
+}
+
+.cropper-point.point-se::before {
+ background-color: #39f;
+ bottom: -50%;
+ content: ' ';
+ display: block;
+ height: 200%;
+ opacity: 0;
+ position: absolute;
+ right: -50%;
+ width: 200%;
+}
+
+.cropper-invisible {
+ opacity: 0;
+}
+
+.cropper-bg {
+ background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');
+}
+
+.cropper-hide {
+ display: block;
+ height: 0;
+ position: absolute;
+ width: 0;
+}
+
+.cropper-hidden {
+ display: none !important;
+}
+
+.cropper-move {
+ cursor: move;
+}
+
+.cropper-crop {
+ cursor: crosshair;
+}
+
+.cropper-disabled .cropper-drag-box,
+.cropper-disabled .cropper-face,
+.cropper-disabled .cropper-line,
+.cropper-disabled .cropper-point {
+ cursor: not-allowed;
+}
diff --git a/addons/web_editor/static/lib/cropperjs/cropper.js b/addons/web_editor/static/lib/cropperjs/cropper.js
new file mode 100644
index 00000000..b4495132
--- /dev/null
+++ b/addons/web_editor/static/lib/cropperjs/cropper.js
@@ -0,0 +1,3566 @@
+/*!
+ * Cropper.js v1.5.5
+ * https://fengyuanchen.github.io/cropperjs
+ *
+ * Copyright 2015-present Chen Fengyuan
+ * Released under the MIT license
+ *
+ * Date: 2019-08-04T02:26:31.160Z
+ */
+
+(function (global, factory) {
+ typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
+ typeof define === 'function' && define.amd ? define(factory) :
+ (global = global || self, global.Cropper = factory());
+}(this, function () { 'use strict';
+
+ function _typeof(obj) {
+ if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") {
+ _typeof = function (obj) {
+ return typeof obj;
+ };
+ } else {
+ _typeof = function (obj) {
+ return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj;
+ };
+ }
+
+ return _typeof(obj);
+ }
+
+ function _classCallCheck(instance, Constructor) {
+ if (!(instance instanceof Constructor)) {
+ throw new TypeError("Cannot call a class as a function");
+ }
+ }
+
+ function _defineProperties(target, props) {
+ for (var i = 0; i < props.length; i++) {
+ var descriptor = props[i];
+ descriptor.enumerable = descriptor.enumerable || false;
+ descriptor.configurable = true;
+ if ("value" in descriptor) descriptor.writable = true;
+ Object.defineProperty(target, descriptor.key, descriptor);
+ }
+ }
+
+ function _createClass(Constructor, protoProps, staticProps) {
+ if (protoProps) _defineProperties(Constructor.prototype, protoProps);
+ if (staticProps) _defineProperties(Constructor, staticProps);
+ return Constructor;
+ }
+
+ function _toConsumableArray(arr) {
+ return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread();
+ }
+
+ function _arrayWithoutHoles(arr) {
+ if (Array.isArray(arr)) {
+ for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) arr2[i] = arr[i];
+
+ return arr2;
+ }
+ }
+
+ function _iterableToArray(iter) {
+ if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter);
+ }
+
+ function _nonIterableSpread() {
+ throw new TypeError("Invalid attempt to spread non-iterable instance");
+ }
+
+ var IS_BROWSER = typeof window !== 'undefined' && typeof window.document !== 'undefined';
+ var WINDOW = IS_BROWSER ? window : {};
+ var IS_TOUCH_DEVICE = IS_BROWSER ? 'ontouchstart' in WINDOW.document.documentElement : false;
+ var HAS_POINTER_EVENT = IS_BROWSER ? 'PointerEvent' in WINDOW : false;
+ var NAMESPACE = 'cropper'; // Actions
+
+ var ACTION_ALL = 'all';
+ var ACTION_CROP = 'crop';
+ var ACTION_MOVE = 'move';
+ var ACTION_ZOOM = 'zoom';
+ var ACTION_EAST = 'e';
+ var ACTION_WEST = 'w';
+ var ACTION_SOUTH = 's';
+ var ACTION_NORTH = 'n';
+ var ACTION_NORTH_EAST = 'ne';
+ var ACTION_NORTH_WEST = 'nw';
+ var ACTION_SOUTH_EAST = 'se';
+ var ACTION_SOUTH_WEST = 'sw'; // Classes
+
+ var CLASS_CROP = "".concat(NAMESPACE, "-crop");
+ var CLASS_DISABLED = "".concat(NAMESPACE, "-disabled");
+ var CLASS_HIDDEN = "".concat(NAMESPACE, "-hidden");
+ var CLASS_HIDE = "".concat(NAMESPACE, "-hide");
+ var CLASS_INVISIBLE = "".concat(NAMESPACE, "-invisible");
+ var CLASS_MODAL = "".concat(NAMESPACE, "-modal");
+ var CLASS_MOVE = "".concat(NAMESPACE, "-move"); // Data keys
+
+ var DATA_ACTION = "".concat(NAMESPACE, "Action");
+ var DATA_PREVIEW = "".concat(NAMESPACE, "Preview"); // Drag modes
+
+ var DRAG_MODE_CROP = 'crop';
+ var DRAG_MODE_MOVE = 'move';
+ var DRAG_MODE_NONE = 'none'; // Events
+
+ var EVENT_CROP = 'crop';
+ var EVENT_CROP_END = 'cropend';
+ var EVENT_CROP_MOVE = 'cropmove';
+ var EVENT_CROP_START = 'cropstart';
+ var EVENT_DBLCLICK = 'dblclick';
+ var EVENT_TOUCH_START = IS_TOUCH_DEVICE ? 'touchstart' : 'mousedown';
+ var EVENT_TOUCH_MOVE = IS_TOUCH_DEVICE ? 'touchmove' : 'mousemove';
+ var EVENT_TOUCH_END = IS_TOUCH_DEVICE ? 'touchend touchcancel' : 'mouseup';
+ var EVENT_POINTER_DOWN = HAS_POINTER_EVENT ? 'pointerdown' : EVENT_TOUCH_START;
+ var EVENT_POINTER_MOVE = HAS_POINTER_EVENT ? 'pointermove' : EVENT_TOUCH_MOVE;
+ var EVENT_POINTER_UP = HAS_POINTER_EVENT ? 'pointerup pointercancel' : EVENT_TOUCH_END;
+ var EVENT_READY = 'ready';
+ var EVENT_RESIZE = 'resize';
+ var EVENT_WHEEL = 'wheel';
+ var EVENT_ZOOM = 'zoom'; // Mime types
+
+ var MIME_TYPE_JPEG = 'image/jpeg'; // RegExps
+
+ var REGEXP_ACTIONS = /^e|w|s|n|se|sw|ne|nw|all|crop|move|zoom$/;
+ var REGEXP_DATA_URL = /^data:/;
+ var REGEXP_DATA_URL_JPEG = /^data:image\/jpeg;base64,/;
+ var REGEXP_TAG_NAME = /^img|canvas$/i; // Misc
+ // Inspired by the default width and height of a canvas element.
+
+ var MIN_CONTAINER_WIDTH = 200;
+ var MIN_CONTAINER_HEIGHT = 100;
+
+ var DEFAULTS = {
+ // Define the view mode of the cropper
+ viewMode: 0,
+ // 0, 1, 2, 3
+ // Define the dragging mode of the cropper
+ dragMode: DRAG_MODE_CROP,
+ // 'crop', 'move' or 'none'
+ // Define the initial aspect ratio of the crop box
+ initialAspectRatio: NaN,
+ // Define the aspect ratio of the crop box
+ aspectRatio: NaN,
+ // An object with the previous cropping result data
+ data: null,
+ // A selector for adding extra containers to preview
+ preview: '',
+ // Re-render the cropper when resize the window
+ responsive: true,
+ // Restore the cropped area after resize the window
+ restore: true,
+ // Check if the current image is a cross-origin image
+ checkCrossOrigin: true,
+ // Check the current image's Exif Orientation information
+ checkOrientation: true,
+ // Show the black modal
+ modal: true,
+ // Show the dashed lines for guiding
+ guides: true,
+ // Show the center indicator for guiding
+ center: true,
+ // Show the white modal to highlight the crop box
+ highlight: true,
+ // Show the grid background
+ background: true,
+ // Enable to crop the image automatically when initialize
+ autoCrop: true,
+ // Define the percentage of automatic cropping area when initializes
+ autoCropArea: 0.8,
+ // Enable to move the image
+ movable: true,
+ // Enable to rotate the image
+ rotatable: true,
+ // Enable to scale the image
+ scalable: true,
+ // Enable to zoom the image
+ zoomable: true,
+ // Enable to zoom the image by dragging touch
+ zoomOnTouch: true,
+ // Enable to zoom the image by wheeling mouse
+ zoomOnWheel: true,
+ // Define zoom ratio when zoom the image by wheeling mouse
+ wheelZoomRatio: 0.1,
+ // Enable to move the crop box
+ cropBoxMovable: true,
+ // Enable to resize the crop box
+ cropBoxResizable: true,
+ // Toggle drag mode between "crop" and "move" when click twice on the cropper
+ toggleDragModeOnDblclick: true,
+ // Size limitation
+ minCanvasWidth: 0,
+ minCanvasHeight: 0,
+ minCropBoxWidth: 0,
+ minCropBoxHeight: 0,
+ minContainerWidth: 200,
+ minContainerHeight: 100,
+ // Shortcuts of events
+ ready: null,
+ cropstart: null,
+ cropmove: null,
+ cropend: null,
+ crop: null,
+ zoom: null
+ };
+
+ var TEMPLATE = '<div class="cropper-container" touch-action="none">' + '<div class="cropper-wrap-box">' + '<div class="cropper-canvas"></div>' + '</div>' + '<div class="cropper-drag-box"></div>' + '<div class="cropper-crop-box">' + '<span class="cropper-view-box"></span>' + '<span class="cropper-dashed dashed-h"></span>' + '<span class="cropper-dashed dashed-v"></span>' + '<span class="cropper-center"></span>' + '<span class="cropper-face"></span>' + '<span class="cropper-line line-e" data-cropper-action="e"></span>' + '<span class="cropper-line line-n" data-cropper-action="n"></span>' + '<span class="cropper-line line-w" data-cropper-action="w"></span>' + '<span class="cropper-line line-s" data-cropper-action="s"></span>' + '<span class="cropper-point point-e" data-cropper-action="e"></span>' + '<span class="cropper-point point-n" data-cropper-action="n"></span>' + '<span class="cropper-point point-w" data-cropper-action="w"></span>' + '<span class="cropper-point point-s" data-cropper-action="s"></span>' + '<span class="cropper-point point-ne" data-cropper-action="ne"></span>' + '<span class="cropper-point point-nw" data-cropper-action="nw"></span>' + '<span class="cropper-point point-sw" data-cropper-action="sw"></span>' + '<span class="cropper-point point-se" data-cropper-action="se"></span>' + '</div>' + '</div>';
+
+ /**
+ * Check if the given value is not a number.
+ */
+
+ var isNaN = Number.isNaN || WINDOW.isNaN;
+ /**
+ * Check if the given value is a number.
+ * @param {*} value - The value to check.
+ * @returns {boolean} Returns `true` if the given value is a number, else `false`.
+ */
+
+ function isNumber(value) {
+ return typeof value === 'number' && !isNaN(value);
+ }
+ /**
+ * Check if the given value is a positive number.
+ * @param {*} value - The value to check.
+ * @returns {boolean} Returns `true` if the given value is a positive number, else `false`.
+ */
+
+ var isPositiveNumber = function isPositiveNumber(value) {
+ return value > 0 && value < Infinity;
+ };
+ /**
+ * Check if the given value is undefined.
+ * @param {*} value - The value to check.
+ * @returns {boolean} Returns `true` if the given value is undefined, else `false`.
+ */
+
+ function isUndefined(value) {
+ return typeof value === 'undefined';
+ }
+ /**
+ * Check if the given value is an object.
+ * @param {*} value - The value to check.
+ * @returns {boolean} Returns `true` if the given value is an object, else `false`.
+ */
+
+ function isObject(value) {
+ return _typeof(value) === 'object' && value !== null;
+ }
+ var hasOwnProperty = Object.prototype.hasOwnProperty;
+ /**
+ * Check if the given value is a plain object.
+ * @param {*} value - The value to check.
+ * @returns {boolean} Returns `true` if the given value is a plain object, else `false`.
+ */
+
+ function isPlainObject(value) {
+ if (!isObject(value)) {
+ return false;
+ }
+
+ try {
+ var _constructor = value.constructor;
+ var prototype = _constructor.prototype;
+ return _constructor && prototype && hasOwnProperty.call(prototype, 'isPrototypeOf');
+ } catch (error) {
+ return false;
+ }
+ }
+ /**
+ * Check if the given value is a function.
+ * @param {*} value - The value to check.
+ * @returns {boolean} Returns `true` if the given value is a function, else `false`.
+ */
+
+ function isFunction(value) {
+ return typeof value === 'function';
+ }
+ var slice = Array.prototype.slice;
+ /**
+ * Convert array-like or iterable object to an array.
+ * @param {*} value - The value to convert.
+ * @returns {Array} Returns a new array.
+ */
+
+ function toArray(value) {
+ return Array.from ? Array.from(value) : slice.call(value);
+ }
+ /**
+ * Iterate the given data.
+ * @param {*} data - The data to iterate.
+ * @param {Function} callback - The process function for each element.
+ * @returns {*} The original data.
+ */
+
+ function forEach(data, callback) {
+ if (data && isFunction(callback)) {
+ if (Array.isArray(data) || isNumber(data.length)
+ /* array-like */
+ ) {
+ toArray(data).forEach(function (value, key) {
+ callback.call(data, value, key, data);
+ });
+ } else if (isObject(data)) {
+ Object.keys(data).forEach(function (key) {
+ callback.call(data, data[key], key, data);
+ });
+ }
+ }
+
+ return data;
+ }
+ /**
+ * Extend the given object.
+ * @param {*} target - The target object to extend.
+ * @param {*} args - The rest objects for merging to the target object.
+ * @returns {Object} The extended object.
+ */
+
+ var assign = Object.assign || function assign(target) {
+ for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
+ args[_key - 1] = arguments[_key];
+ }
+
+ if (isObject(target) && args.length > 0) {
+ args.forEach(function (arg) {
+ if (isObject(arg)) {
+ Object.keys(arg).forEach(function (key) {
+ target[key] = arg[key];
+ });
+ }
+ });
+ }
+
+ return target;
+ };
+ var REGEXP_DECIMALS = /\.\d*(?:0|9){12}\d*$/;
+ /**
+ * Normalize decimal number.
+ * Check out {@link http://0.30000000000000004.com/}
+ * @param {number} value - The value to normalize.
+ * @param {number} [times=100000000000] - The times for normalizing.
+ * @returns {number} Returns the normalized number.
+ */
+
+ function normalizeDecimalNumber(value) {
+ var times = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 100000000000;
+ return REGEXP_DECIMALS.test(value) ? Math.round(value * times) / times : value;
+ }
+ var REGEXP_SUFFIX = /^width|height|left|top|marginLeft|marginTop$/;
+ /**
+ * Apply styles to the given element.
+ * @param {Element} element - The target element.
+ * @param {Object} styles - The styles for applying.
+ */
+
+ function setStyle(element, styles) {
+ var style = element.style;
+ forEach(styles, function (value, property) {
+ if (REGEXP_SUFFIX.test(property) && isNumber(value)) {
+ value = "".concat(value, "px");
+ }
+
+ style[property] = value;
+ });
+ }
+ /**
+ * Check if the given element has a special class.
+ * @param {Element} element - The element to check.
+ * @param {string} value - The class to search.
+ * @returns {boolean} Returns `true` if the special class was found.
+ */
+
+ function hasClass(element, value) {
+ return element.classList ? element.classList.contains(value) : element.className.indexOf(value) > -1;
+ }
+ /**
+ * Add classes to the given element.
+ * @param {Element} element - The target element.
+ * @param {string} value - The classes to be added.
+ */
+
+ function addClass(element, value) {
+ if (!value) {
+ return;
+ }
+
+ if (isNumber(element.length)) {
+ forEach(element, function (elem) {
+ addClass(elem, value);
+ });
+ return;
+ }
+
+ if (element.classList) {
+ element.classList.add(value);
+ return;
+ }
+
+ var className = element.className.trim();
+
+ if (!className) {
+ element.className = value;
+ } else if (className.indexOf(value) < 0) {
+ element.className = "".concat(className, " ").concat(value);
+ }
+ }
+ /**
+ * Remove classes from the given element.
+ * @param {Element} element - The target element.
+ * @param {string} value - The classes to be removed.
+ */
+
+ function removeClass(element, value) {
+ if (!value) {
+ return;
+ }
+
+ if (isNumber(element.length)) {
+ forEach(element, function (elem) {
+ removeClass(elem, value);
+ });
+ return;
+ }
+
+ if (element.classList) {
+ element.classList.remove(value);
+ return;
+ }
+
+ if (element.className.indexOf(value) >= 0) {
+ element.className = element.className.replace(value, '');
+ }
+ }
+ /**
+ * Add or remove classes from the given element.
+ * @param {Element} element - The target element.
+ * @param {string} value - The classes to be toggled.
+ * @param {boolean} added - Add only.
+ */
+
+ function toggleClass(element, value, added) {
+ if (!value) {
+ return;
+ }
+
+ if (isNumber(element.length)) {
+ forEach(element, function (elem) {
+ toggleClass(elem, value, added);
+ });
+ return;
+ } // IE10-11 doesn't support the second parameter of `classList.toggle`
+
+
+ if (added) {
+ addClass(element, value);
+ } else {
+ removeClass(element, value);
+ }
+ }
+ var REGEXP_CAMEL_CASE = /([a-z\d])([A-Z])/g;
+ /**
+ * Transform the given string from camelCase to kebab-case
+ * @param {string} value - The value to transform.
+ * @returns {string} The transformed value.
+ */
+
+ function toParamCase(value) {
+ return value.replace(REGEXP_CAMEL_CASE, '$1-$2').toLowerCase();
+ }
+ /**
+ * Get data from the given element.
+ * @param {Element} element - The target element.
+ * @param {string} name - The data key to get.
+ * @returns {string} The data value.
+ */
+
+ function getData(element, name) {
+ if (isObject(element[name])) {
+ return element[name];
+ }
+
+ if (element.dataset) {
+ return element.dataset[name];
+ }
+
+ return element.getAttribute("data-".concat(toParamCase(name)));
+ }
+ /**
+ * Set data to the given element.
+ * @param {Element} element - The target element.
+ * @param {string} name - The data key to set.
+ * @param {string} data - The data value.
+ */
+
+ function setData(element, name, data) {
+ if (isObject(data)) {
+ element[name] = data;
+ } else if (element.dataset) {
+ element.dataset[name] = data;
+ } else {
+ element.setAttribute("data-".concat(toParamCase(name)), data);
+ }
+ }
+ /**
+ * Remove data from the given element.
+ * @param {Element} element - The target element.
+ * @param {string} name - The data key to remove.
+ */
+
+ function removeData(element, name) {
+ if (isObject(element[name])) {
+ try {
+ delete element[name];
+ } catch (error) {
+ element[name] = undefined;
+ }
+ } else if (element.dataset) {
+ // #128 Safari not allows to delete dataset property
+ try {
+ delete element.dataset[name];
+ } catch (error) {
+ element.dataset[name] = undefined;
+ }
+ } else {
+ element.removeAttribute("data-".concat(toParamCase(name)));
+ }
+ }
+ var REGEXP_SPACES = /\s\s*/;
+
+ var onceSupported = function () {
+ var supported = false;
+
+ if (IS_BROWSER) {
+ var once = false;
+
+ var listener = function listener() {};
+
+ var options = Object.defineProperty({}, 'once', {
+ get: function get() {
+ supported = true;
+ return once;
+ },
+
+ /**
+ * This setter can fix a `TypeError` in strict mode
+ * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Getter_only}
+ * @param {boolean} value - The value to set
+ */
+ set: function set(value) {
+ once = value;
+ }
+ });
+ WINDOW.addEventListener('test', listener, options);
+ WINDOW.removeEventListener('test', listener, options);
+ }
+
+ return supported;
+ }();
+ /**
+ * Remove event listener from the target element.
+ * @param {Element} element - The event target.
+ * @param {string} type - The event type(s).
+ * @param {Function} listener - The event listener.
+ * @param {Object} options - The event options.
+ */
+
+
+ function removeListener(element, type, listener) {
+ var options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {};
+ var handler = listener;
+ type.trim().split(REGEXP_SPACES).forEach(function (event) {
+ if (!onceSupported) {
+ var listeners = element.listeners;
+
+ if (listeners && listeners[event] && listeners[event][listener]) {
+ handler = listeners[event][listener];
+ delete listeners[event][listener];
+
+ if (Object.keys(listeners[event]).length === 0) {
+ delete listeners[event];
+ }
+
+ if (Object.keys(listeners).length === 0) {
+ delete element.listeners;
+ }
+ }
+ }
+
+ element.removeEventListener(event, handler, options);
+ });
+ }
+ /**
+ * Add event listener to the target element.
+ * @param {Element} element - The event target.
+ * @param {string} type - The event type(s).
+ * @param {Function} listener - The event listener.
+ * @param {Object} options - The event options.
+ */
+
+ function addListener(element, type, listener) {
+ var options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {};
+ var _handler = listener;
+ type.trim().split(REGEXP_SPACES).forEach(function (event) {
+ if (options.once && !onceSupported) {
+ var _element$listeners = element.listeners,
+ listeners = _element$listeners === void 0 ? {} : _element$listeners;
+
+ _handler = function handler() {
+ delete listeners[event][listener];
+ element.removeEventListener(event, _handler, options);
+
+ for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
+ args[_key2] = arguments[_key2];
+ }
+
+ listener.apply(element, args);
+ };
+
+ if (!listeners[event]) {
+ listeners[event] = {};
+ }
+
+ if (listeners[event][listener]) {
+ element.removeEventListener(event, listeners[event][listener], options);
+ }
+
+ listeners[event][listener] = _handler;
+ element.listeners = listeners;
+ }
+
+ element.addEventListener(event, _handler, options);
+ });
+ }
+ /**
+ * Dispatch event on the target element.
+ * @param {Element} element - The event target.
+ * @param {string} type - The event type(s).
+ * @param {Object} data - The additional event data.
+ * @returns {boolean} Indicate if the event is default prevented or not.
+ */
+
+ function dispatchEvent(element, type, data) {
+ var event; // Event and CustomEvent on IE9-11 are global objects, not constructors
+
+ if (isFunction(Event) && isFunction(CustomEvent)) {
+ event = new CustomEvent(type, {
+ detail: data,
+ bubbles: true,
+ cancelable: true
+ });
+ } else {
+ event = document.createEvent('CustomEvent');
+ event.initCustomEvent(type, true, true, data);
+ }
+
+ return element.dispatchEvent(event);
+ }
+ /**
+ * Get the offset base on the document.
+ * @param {Element} element - The target element.
+ * @returns {Object} The offset data.
+ */
+
+ function getOffset(element) {
+ var box = element.getBoundingClientRect();
+ return {
+ left: box.left + (window.pageXOffset - document.documentElement.clientLeft),
+ top: box.top + (window.pageYOffset - document.documentElement.clientTop)
+ };
+ }
+ var location = WINDOW.location;
+ var REGEXP_ORIGINS = /^(\w+:)\/\/([^:/?#]*):?(\d*)/i;
+ /**
+ * Check if the given URL is a cross origin URL.
+ * @param {string} url - The target URL.
+ * @returns {boolean} Returns `true` if the given URL is a cross origin URL, else `false`.
+ */
+
+ function isCrossOriginURL(url) {
+ var parts = url.match(REGEXP_ORIGINS);
+ return parts !== null && (parts[1] !== location.protocol || parts[2] !== location.hostname || parts[3] !== location.port);
+ }
+ /**
+ * Add timestamp to the given URL.
+ * @param {string} url - The target URL.
+ * @returns {string} The result URL.
+ */
+
+ function addTimestamp(url) {
+ var timestamp = "timestamp=".concat(new Date().getTime());
+ return url + (url.indexOf('?') === -1 ? '?' : '&') + timestamp;
+ }
+ /**
+ * Get transforms base on the given object.
+ * @param {Object} obj - The target object.
+ * @returns {string} A string contains transform values.
+ */
+
+ function getTransforms(_ref) {
+ var rotate = _ref.rotate,
+ scaleX = _ref.scaleX,
+ scaleY = _ref.scaleY,
+ translateX = _ref.translateX,
+ translateY = _ref.translateY;
+ var values = [];
+
+ if (isNumber(translateX) && translateX !== 0) {
+ values.push("translateX(".concat(translateX, "px)"));
+ }
+
+ if (isNumber(translateY) && translateY !== 0) {
+ values.push("translateY(".concat(translateY, "px)"));
+ } // Rotate should come first before scale to match orientation transform
+
+
+ if (isNumber(rotate) && rotate !== 0) {
+ values.push("rotate(".concat(rotate, "deg)"));
+ }
+
+ if (isNumber(scaleX) && scaleX !== 1) {
+ values.push("scaleX(".concat(scaleX, ")"));
+ }
+
+ if (isNumber(scaleY) && scaleY !== 1) {
+ values.push("scaleY(".concat(scaleY, ")"));
+ }
+
+ var transform = values.length ? values.join(' ') : 'none';
+ return {
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform: transform
+ };
+ }
+ /**
+ * Get the max ratio of a group of pointers.
+ * @param {string} pointers - The target pointers.
+ * @returns {number} The result ratio.
+ */
+
+ function getMaxZoomRatio(pointers) {
+ var pointers2 = assign({}, pointers);
+ var ratios = [];
+ forEach(pointers, function (pointer, pointerId) {
+ delete pointers2[pointerId];
+ forEach(pointers2, function (pointer2) {
+ var x1 = Math.abs(pointer.startX - pointer2.startX);
+ var y1 = Math.abs(pointer.startY - pointer2.startY);
+ var x2 = Math.abs(pointer.endX - pointer2.endX);
+ var y2 = Math.abs(pointer.endY - pointer2.endY);
+ var z1 = Math.sqrt(x1 * x1 + y1 * y1);
+ var z2 = Math.sqrt(x2 * x2 + y2 * y2);
+ var ratio = (z2 - z1) / z1;
+ ratios.push(ratio);
+ });
+ });
+ ratios.sort(function (a, b) {
+ return Math.abs(a) < Math.abs(b);
+ });
+ return ratios[0];
+ }
+ /**
+ * Get a pointer from an event object.
+ * @param {Object} event - The target event object.
+ * @param {boolean} endOnly - Indicates if only returns the end point coordinate or not.
+ * @returns {Object} The result pointer contains start and/or end point coordinates.
+ */
+
+ function getPointer(_ref2, endOnly) {
+ var pageX = _ref2.pageX,
+ pageY = _ref2.pageY;
+ var end = {
+ endX: pageX,
+ endY: pageY
+ };
+ return endOnly ? end : assign({
+ startX: pageX,
+ startY: pageY
+ }, end);
+ }
+ /**
+ * Get the center point coordinate of a group of pointers.
+ * @param {Object} pointers - The target pointers.
+ * @returns {Object} The center point coordinate.
+ */
+
+ function getPointersCenter(pointers) {
+ var pageX = 0;
+ var pageY = 0;
+ var count = 0;
+ forEach(pointers, function (_ref3) {
+ var startX = _ref3.startX,
+ startY = _ref3.startY;
+ pageX += startX;
+ pageY += startY;
+ count += 1;
+ });
+ pageX /= count;
+ pageY /= count;
+ return {
+ pageX: pageX,
+ pageY: pageY
+ };
+ }
+ /**
+ * Get the max sizes in a rectangle under the given aspect ratio.
+ * @param {Object} data - The original sizes.
+ * @param {string} [type='contain'] - The adjust type.
+ * @returns {Object} The result sizes.
+ */
+
+ function getAdjustedSizes(_ref4) // or 'cover'
+ {
+ var aspectRatio = _ref4.aspectRatio,
+ height = _ref4.height,
+ width = _ref4.width;
+ var type = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'contain';
+ var isValidWidth = isPositiveNumber(width);
+ var isValidHeight = isPositiveNumber(height);
+
+ if (isValidWidth && isValidHeight) {
+ var adjustedWidth = height * aspectRatio;
+
+ if (type === 'contain' && adjustedWidth > width || type === 'cover' && adjustedWidth < width) {
+ height = width / aspectRatio;
+ } else {
+ width = height * aspectRatio;
+ }
+ } else if (isValidWidth) {
+ height = width / aspectRatio;
+ } else if (isValidHeight) {
+ width = height * aspectRatio;
+ }
+
+ return {
+ width: width,
+ height: height
+ };
+ }
+ /**
+ * Get the new sizes of a rectangle after rotated.
+ * @param {Object} data - The original sizes.
+ * @returns {Object} The result sizes.
+ */
+
+ function getRotatedSizes(_ref5) {
+ var width = _ref5.width,
+ height = _ref5.height,
+ degree = _ref5.degree;
+ degree = Math.abs(degree) % 180;
+
+ if (degree === 90) {
+ return {
+ width: height,
+ height: width
+ };
+ }
+
+ var arc = degree % 90 * Math.PI / 180;
+ var sinArc = Math.sin(arc);
+ var cosArc = Math.cos(arc);
+ var newWidth = width * cosArc + height * sinArc;
+ var newHeight = width * sinArc + height * cosArc;
+ return degree > 90 ? {
+ width: newHeight,
+ height: newWidth
+ } : {
+ width: newWidth,
+ height: newHeight
+ };
+ }
+ /**
+ * Get a canvas which drew the given image.
+ * @param {HTMLImageElement} image - The image for drawing.
+ * @param {Object} imageData - The image data.
+ * @param {Object} canvasData - The canvas data.
+ * @param {Object} options - The options.
+ * @returns {HTMLCanvasElement} The result canvas.
+ */
+
+ function getSourceCanvas(image, _ref6, _ref7, _ref8) {
+ var imageAspectRatio = _ref6.aspectRatio,
+ imageNaturalWidth = _ref6.naturalWidth,
+ imageNaturalHeight = _ref6.naturalHeight,
+ _ref6$rotate = _ref6.rotate,
+ rotate = _ref6$rotate === void 0 ? 0 : _ref6$rotate,
+ _ref6$scaleX = _ref6.scaleX,
+ scaleX = _ref6$scaleX === void 0 ? 1 : _ref6$scaleX,
+ _ref6$scaleY = _ref6.scaleY,
+ scaleY = _ref6$scaleY === void 0 ? 1 : _ref6$scaleY;
+ var aspectRatio = _ref7.aspectRatio,
+ naturalWidth = _ref7.naturalWidth,
+ naturalHeight = _ref7.naturalHeight;
+ var _ref8$fillColor = _ref8.fillColor,
+ fillColor = _ref8$fillColor === void 0 ? 'transparent' : _ref8$fillColor,
+ _ref8$imageSmoothingE = _ref8.imageSmoothingEnabled,
+ imageSmoothingEnabled = _ref8$imageSmoothingE === void 0 ? true : _ref8$imageSmoothingE,
+ _ref8$imageSmoothingQ = _ref8.imageSmoothingQuality,
+ imageSmoothingQuality = _ref8$imageSmoothingQ === void 0 ? 'low' : _ref8$imageSmoothingQ,
+ _ref8$maxWidth = _ref8.maxWidth,
+ maxWidth = _ref8$maxWidth === void 0 ? Infinity : _ref8$maxWidth,
+ _ref8$maxHeight = _ref8.maxHeight,
+ maxHeight = _ref8$maxHeight === void 0 ? Infinity : _ref8$maxHeight,
+ _ref8$minWidth = _ref8.minWidth,
+ minWidth = _ref8$minWidth === void 0 ? 0 : _ref8$minWidth,
+ _ref8$minHeight = _ref8.minHeight,
+ minHeight = _ref8$minHeight === void 0 ? 0 : _ref8$minHeight;
+ var canvas = document.createElement('canvas');
+ var context = canvas.getContext('2d');
+ var maxSizes = getAdjustedSizes({
+ aspectRatio: aspectRatio,
+ width: maxWidth,
+ height: maxHeight
+ });
+ var minSizes = getAdjustedSizes({
+ aspectRatio: aspectRatio,
+ width: minWidth,
+ height: minHeight
+ }, 'cover');
+ var width = Math.min(maxSizes.width, Math.max(minSizes.width, naturalWidth));
+ var height = Math.min(maxSizes.height, Math.max(minSizes.height, naturalHeight)); // Note: should always use image's natural sizes for drawing as
+ // imageData.naturalWidth === canvasData.naturalHeight when rotate % 180 === 90
+
+ var destMaxSizes = getAdjustedSizes({
+ aspectRatio: imageAspectRatio,
+ width: maxWidth,
+ height: maxHeight
+ });
+ var destMinSizes = getAdjustedSizes({
+ aspectRatio: imageAspectRatio,
+ width: minWidth,
+ height: minHeight
+ }, 'cover');
+ var destWidth = Math.min(destMaxSizes.width, Math.max(destMinSizes.width, imageNaturalWidth));
+ var destHeight = Math.min(destMaxSizes.height, Math.max(destMinSizes.height, imageNaturalHeight));
+ var params = [-destWidth / 2, -destHeight / 2, destWidth, destHeight];
+ canvas.width = normalizeDecimalNumber(width);
+ canvas.height = normalizeDecimalNumber(height);
+ context.fillStyle = fillColor;
+ context.fillRect(0, 0, width, height);
+ context.save();
+ context.translate(width / 2, height / 2);
+ context.rotate(rotate * Math.PI / 180);
+ context.scale(scaleX, scaleY);
+ context.imageSmoothingEnabled = imageSmoothingEnabled;
+ context.imageSmoothingQuality = imageSmoothingQuality;
+ context.drawImage.apply(context, [image].concat(_toConsumableArray(params.map(function (param) {
+ return Math.floor(normalizeDecimalNumber(param));
+ }))));
+ context.restore();
+ return canvas;
+ }
+ var fromCharCode = String.fromCharCode;
+ /**
+ * Get string from char code in data view.
+ * @param {DataView} dataView - The data view for read.
+ * @param {number} start - The start index.
+ * @param {number} length - The read length.
+ * @returns {string} The read result.
+ */
+
+ function getStringFromCharCode(dataView, start, length) {
+ var str = '';
+ length += start;
+
+ for (var i = start; i < length; i += 1) {
+ str += fromCharCode(dataView.getUint8(i));
+ }
+
+ return str;
+ }
+ var REGEXP_DATA_URL_HEAD = /^data:.*,/;
+ /**
+ * Transform Data URL to array buffer.
+ * @param {string} dataURL - The Data URL to transform.
+ * @returns {ArrayBuffer} The result array buffer.
+ */
+
+ function dataURLToArrayBuffer(dataURL) {
+ var base64 = dataURL.replace(REGEXP_DATA_URL_HEAD, '');
+ var binary = atob(base64);
+ var arrayBuffer = new ArrayBuffer(binary.length);
+ var uint8 = new Uint8Array(arrayBuffer);
+ forEach(uint8, function (value, i) {
+ uint8[i] = binary.charCodeAt(i);
+ });
+ return arrayBuffer;
+ }
+ /**
+ * Transform array buffer to Data URL.
+ * @param {ArrayBuffer} arrayBuffer - The array buffer to transform.
+ * @param {string} mimeType - The mime type of the Data URL.
+ * @returns {string} The result Data URL.
+ */
+
+ function arrayBufferToDataURL(arrayBuffer, mimeType) {
+ var chunks = []; // Chunk Typed Array for better performance (#435)
+
+ var chunkSize = 8192;
+ var uint8 = new Uint8Array(arrayBuffer);
+
+ while (uint8.length > 0) {
+ // XXX: Babel's `toConsumableArray` helper will throw error in IE or Safari 9
+ // eslint-disable-next-line prefer-spread
+ chunks.push(fromCharCode.apply(null, toArray(uint8.subarray(0, chunkSize))));
+ uint8 = uint8.subarray(chunkSize);
+ }
+
+ return "data:".concat(mimeType, ";base64,").concat(btoa(chunks.join('')));
+ }
+ /**
+ * Get orientation value from given array buffer.
+ * @param {ArrayBuffer} arrayBuffer - The array buffer to read.
+ * @returns {number} The read orientation value.
+ */
+
+ function resetAndGetOrientation(arrayBuffer) {
+ var dataView = new DataView(arrayBuffer);
+ var orientation; // Ignores range error when the image does not have correct Exif information
+
+ try {
+ var littleEndian;
+ var app1Start;
+ var ifdStart; // Only handle JPEG image (start by 0xFFD8)
+
+ if (dataView.getUint8(0) === 0xFF && dataView.getUint8(1) === 0xD8) {
+ var length = dataView.byteLength;
+ var offset = 2;
+
+ while (offset + 1 < length) {
+ if (dataView.getUint8(offset) === 0xFF && dataView.getUint8(offset + 1) === 0xE1) {
+ app1Start = offset;
+ break;
+ }
+
+ offset += 1;
+ }
+ }
+
+ if (app1Start) {
+ var exifIDCode = app1Start + 4;
+ var tiffOffset = app1Start + 10;
+
+ if (getStringFromCharCode(dataView, exifIDCode, 4) === 'Exif') {
+ var endianness = dataView.getUint16(tiffOffset);
+ littleEndian = endianness === 0x4949;
+
+ if (littleEndian || endianness === 0x4D4D
+ /* bigEndian */
+ ) {
+ if (dataView.getUint16(tiffOffset + 2, littleEndian) === 0x002A) {
+ var firstIFDOffset = dataView.getUint32(tiffOffset + 4, littleEndian);
+
+ if (firstIFDOffset >= 0x00000008) {
+ ifdStart = tiffOffset + firstIFDOffset;
+ }
+ }
+ }
+ }
+ }
+
+ if (ifdStart) {
+ var _length = dataView.getUint16(ifdStart, littleEndian);
+
+ var _offset;
+
+ var i;
+
+ for (i = 0; i < _length; i += 1) {
+ _offset = ifdStart + i * 12 + 2;
+
+ if (dataView.getUint16(_offset, littleEndian) === 0x0112
+ /* Orientation */
+ ) {
+ // 8 is the offset of the current tag's value
+ _offset += 8; // Get the original orientation value
+
+ orientation = dataView.getUint16(_offset, littleEndian); // Override the orientation with its default value
+
+ dataView.setUint16(_offset, 1, littleEndian);
+ break;
+ }
+ }
+ }
+ } catch (error) {
+ orientation = 1;
+ }
+
+ return orientation;
+ }
+ /**
+ * Parse Exif Orientation value.
+ * @param {number} orientation - The orientation to parse.
+ * @returns {Object} The parsed result.
+ */
+
+ function parseOrientation(orientation) {
+ var rotate = 0;
+ var scaleX = 1;
+ var scaleY = 1;
+
+ switch (orientation) {
+ // Flip horizontal
+ case 2:
+ scaleX = -1;
+ break;
+ // Rotate left 180°
+
+ case 3:
+ rotate = -180;
+ break;
+ // Flip vertical
+
+ case 4:
+ scaleY = -1;
+ break;
+ // Flip vertical and rotate right 90°
+
+ case 5:
+ rotate = 90;
+ scaleY = -1;
+ break;
+ // Rotate right 90°
+
+ case 6:
+ rotate = 90;
+ break;
+ // Flip horizontal and rotate right 90°
+
+ case 7:
+ rotate = 90;
+ scaleX = -1;
+ break;
+ // Rotate left 90°
+
+ case 8:
+ rotate = -90;
+ break;
+
+ default:
+ }
+
+ return {
+ rotate: rotate,
+ scaleX: scaleX,
+ scaleY: scaleY
+ };
+ }
+
+ var render = {
+ render: function render() {
+ this.initContainer();
+ this.initCanvas();
+ this.initCropBox();
+ this.renderCanvas();
+
+ if (this.cropped) {
+ this.renderCropBox();
+ }
+ },
+ initContainer: function initContainer() {
+ var element = this.element,
+ options = this.options,
+ container = this.container,
+ cropper = this.cropper;
+ addClass(cropper, CLASS_HIDDEN);
+ removeClass(element, CLASS_HIDDEN);
+ var containerData = {
+ width: Math.max(container.offsetWidth, Number(options.minContainerWidth) || 200),
+ height: Math.max(container.offsetHeight, Number(options.minContainerHeight) || 100)
+ };
+ this.containerData = containerData;
+ setStyle(cropper, {
+ width: containerData.width,
+ height: containerData.height
+ });
+ addClass(element, CLASS_HIDDEN);
+ removeClass(cropper, CLASS_HIDDEN);
+ },
+ // Canvas (image wrapper)
+ initCanvas: function initCanvas() {
+ var containerData = this.containerData,
+ imageData = this.imageData;
+ var viewMode = this.options.viewMode;
+ var rotated = Math.abs(imageData.rotate) % 180 === 90;
+ var naturalWidth = rotated ? imageData.naturalHeight : imageData.naturalWidth;
+ var naturalHeight = rotated ? imageData.naturalWidth : imageData.naturalHeight;
+ var aspectRatio = naturalWidth / naturalHeight;
+ var canvasWidth = containerData.width;
+ var canvasHeight = containerData.height;
+
+ if (containerData.height * aspectRatio > containerData.width) {
+ if (viewMode === 3) {
+ canvasWidth = containerData.height * aspectRatio;
+ } else {
+ canvasHeight = containerData.width / aspectRatio;
+ }
+ } else if (viewMode === 3) {
+ canvasHeight = containerData.width / aspectRatio;
+ } else {
+ canvasWidth = containerData.height * aspectRatio;
+ }
+
+ var canvasData = {
+ aspectRatio: aspectRatio,
+ naturalWidth: naturalWidth,
+ naturalHeight: naturalHeight,
+ width: canvasWidth,
+ height: canvasHeight
+ };
+ canvasData.left = (containerData.width - canvasWidth) / 2;
+ canvasData.top = (containerData.height - canvasHeight) / 2;
+ canvasData.oldLeft = canvasData.left;
+ canvasData.oldTop = canvasData.top;
+ this.canvasData = canvasData;
+ this.limited = viewMode === 1 || viewMode === 2;
+ this.limitCanvas(true, true);
+ this.initialImageData = assign({}, imageData);
+ this.initialCanvasData = assign({}, canvasData);
+ },
+ limitCanvas: function limitCanvas(sizeLimited, positionLimited) {
+ var options = this.options,
+ containerData = this.containerData,
+ canvasData = this.canvasData,
+ cropBoxData = this.cropBoxData;
+ var viewMode = options.viewMode;
+ var aspectRatio = canvasData.aspectRatio;
+ var cropped = this.cropped && cropBoxData;
+
+ if (sizeLimited) {
+ var minCanvasWidth = Number(options.minCanvasWidth) || 0;
+ var minCanvasHeight = Number(options.minCanvasHeight) || 0;
+
+ if (viewMode > 1) {
+ minCanvasWidth = Math.max(minCanvasWidth, containerData.width);
+ minCanvasHeight = Math.max(minCanvasHeight, containerData.height);
+
+ if (viewMode === 3) {
+ if (minCanvasHeight * aspectRatio > minCanvasWidth) {
+ minCanvasWidth = minCanvasHeight * aspectRatio;
+ } else {
+ minCanvasHeight = minCanvasWidth / aspectRatio;
+ }
+ }
+ } else if (viewMode > 0) {
+ if (minCanvasWidth) {
+ minCanvasWidth = Math.max(minCanvasWidth, cropped ? cropBoxData.width : 0);
+ } else if (minCanvasHeight) {
+ minCanvasHeight = Math.max(minCanvasHeight, cropped ? cropBoxData.height : 0);
+ } else if (cropped) {
+ minCanvasWidth = cropBoxData.width;
+ minCanvasHeight = cropBoxData.height;
+
+ if (minCanvasHeight * aspectRatio > minCanvasWidth) {
+ minCanvasWidth = minCanvasHeight * aspectRatio;
+ } else {
+ minCanvasHeight = minCanvasWidth / aspectRatio;
+ }
+ }
+ }
+
+ var _getAdjustedSizes = getAdjustedSizes({
+ aspectRatio: aspectRatio,
+ width: minCanvasWidth,
+ height: minCanvasHeight
+ });
+
+ minCanvasWidth = _getAdjustedSizes.width;
+ minCanvasHeight = _getAdjustedSizes.height;
+ canvasData.minWidth = minCanvasWidth;
+ canvasData.minHeight = minCanvasHeight;
+ canvasData.maxWidth = Infinity;
+ canvasData.maxHeight = Infinity;
+ }
+
+ if (positionLimited) {
+ if (viewMode > (cropped ? 0 : 1)) {
+ var newCanvasLeft = containerData.width - canvasData.width;
+ var newCanvasTop = containerData.height - canvasData.height;
+ canvasData.minLeft = Math.min(0, newCanvasLeft);
+ canvasData.minTop = Math.min(0, newCanvasTop);
+ canvasData.maxLeft = Math.max(0, newCanvasLeft);
+ canvasData.maxTop = Math.max(0, newCanvasTop);
+
+ if (cropped && this.limited) {
+ canvasData.minLeft = Math.min(cropBoxData.left, cropBoxData.left + (cropBoxData.width - canvasData.width));
+ canvasData.minTop = Math.min(cropBoxData.top, cropBoxData.top + (cropBoxData.height - canvasData.height));
+ canvasData.maxLeft = cropBoxData.left;
+ canvasData.maxTop = cropBoxData.top;
+
+ if (viewMode === 2) {
+ if (canvasData.width >= containerData.width) {
+ canvasData.minLeft = Math.min(0, newCanvasLeft);
+ canvasData.maxLeft = Math.max(0, newCanvasLeft);
+ }
+
+ if (canvasData.height >= containerData.height) {
+ canvasData.minTop = Math.min(0, newCanvasTop);
+ canvasData.maxTop = Math.max(0, newCanvasTop);
+ }
+ }
+ }
+ } else {
+ canvasData.minLeft = -canvasData.width;
+ canvasData.minTop = -canvasData.height;
+ canvasData.maxLeft = containerData.width;
+ canvasData.maxTop = containerData.height;
+ }
+ }
+ },
+ renderCanvas: function renderCanvas(changed, transformed) {
+ var canvasData = this.canvasData,
+ imageData = this.imageData;
+
+ if (transformed) {
+ var _getRotatedSizes = getRotatedSizes({
+ width: imageData.naturalWidth * Math.abs(imageData.scaleX || 1),
+ height: imageData.naturalHeight * Math.abs(imageData.scaleY || 1),
+ degree: imageData.rotate || 0
+ }),
+ naturalWidth = _getRotatedSizes.width,
+ naturalHeight = _getRotatedSizes.height;
+
+ var width = canvasData.width * (naturalWidth / canvasData.naturalWidth);
+ var height = canvasData.height * (naturalHeight / canvasData.naturalHeight);
+ canvasData.left -= (width - canvasData.width) / 2;
+ canvasData.top -= (height - canvasData.height) / 2;
+ canvasData.width = width;
+ canvasData.height = height;
+ canvasData.aspectRatio = naturalWidth / naturalHeight;
+ canvasData.naturalWidth = naturalWidth;
+ canvasData.naturalHeight = naturalHeight;
+ this.limitCanvas(true, false);
+ }
+
+ if (canvasData.width > canvasData.maxWidth || canvasData.width < canvasData.minWidth) {
+ canvasData.left = canvasData.oldLeft;
+ }
+
+ if (canvasData.height > canvasData.maxHeight || canvasData.height < canvasData.minHeight) {
+ canvasData.top = canvasData.oldTop;
+ }
+
+ canvasData.width = Math.min(Math.max(canvasData.width, canvasData.minWidth), canvasData.maxWidth);
+ canvasData.height = Math.min(Math.max(canvasData.height, canvasData.minHeight), canvasData.maxHeight);
+ this.limitCanvas(false, true);
+ canvasData.left = Math.min(Math.max(canvasData.left, canvasData.minLeft), canvasData.maxLeft);
+ canvasData.top = Math.min(Math.max(canvasData.top, canvasData.minTop), canvasData.maxTop);
+ canvasData.oldLeft = canvasData.left;
+ canvasData.oldTop = canvasData.top;
+ setStyle(this.canvas, assign({
+ width: canvasData.width,
+ height: canvasData.height
+ }, getTransforms({
+ translateX: canvasData.left,
+ translateY: canvasData.top
+ })));
+ this.renderImage(changed);
+
+ if (this.cropped && this.limited) {
+ this.limitCropBox(true, true);
+ }
+ },
+ renderImage: function renderImage(changed) {
+ var canvasData = this.canvasData,
+ imageData = this.imageData;
+ var width = imageData.naturalWidth * (canvasData.width / canvasData.naturalWidth);
+ var height = imageData.naturalHeight * (canvasData.height / canvasData.naturalHeight);
+ assign(imageData, {
+ width: width,
+ height: height,
+ left: (canvasData.width - width) / 2,
+ top: (canvasData.height - height) / 2
+ });
+ setStyle(this.image, assign({
+ width: imageData.width,
+ height: imageData.height
+ }, getTransforms(assign({
+ translateX: imageData.left,
+ translateY: imageData.top
+ }, imageData))));
+
+ if (changed) {
+ this.output();
+ }
+ },
+ initCropBox: function initCropBox() {
+ var options = this.options,
+ canvasData = this.canvasData;
+ var aspectRatio = options.aspectRatio || options.initialAspectRatio;
+ var autoCropArea = Number(options.autoCropArea) || 0.8;
+ var cropBoxData = {
+ width: canvasData.width,
+ height: canvasData.height
+ };
+
+ if (aspectRatio) {
+ if (canvasData.height * aspectRatio > canvasData.width) {
+ cropBoxData.height = cropBoxData.width / aspectRatio;
+ } else {
+ cropBoxData.width = cropBoxData.height * aspectRatio;
+ }
+ }
+
+ this.cropBoxData = cropBoxData;
+ this.limitCropBox(true, true); // Initialize auto crop area
+
+ cropBoxData.width = Math.min(Math.max(cropBoxData.width, cropBoxData.minWidth), cropBoxData.maxWidth);
+ cropBoxData.height = Math.min(Math.max(cropBoxData.height, cropBoxData.minHeight), cropBoxData.maxHeight); // The width/height of auto crop area must large than "minWidth/Height"
+
+ cropBoxData.width = Math.max(cropBoxData.minWidth, cropBoxData.width * autoCropArea);
+ cropBoxData.height = Math.max(cropBoxData.minHeight, cropBoxData.height * autoCropArea);
+ cropBoxData.left = canvasData.left + (canvasData.width - cropBoxData.width) / 2;
+ cropBoxData.top = canvasData.top + (canvasData.height - cropBoxData.height) / 2;
+ cropBoxData.oldLeft = cropBoxData.left;
+ cropBoxData.oldTop = cropBoxData.top;
+ this.initialCropBoxData = assign({}, cropBoxData);
+ },
+ limitCropBox: function limitCropBox(sizeLimited, positionLimited) {
+ var options = this.options,
+ containerData = this.containerData,
+ canvasData = this.canvasData,
+ cropBoxData = this.cropBoxData,
+ limited = this.limited;
+ var aspectRatio = options.aspectRatio;
+
+ if (sizeLimited) {
+ var minCropBoxWidth = Number(options.minCropBoxWidth) || 0;
+ var minCropBoxHeight = Number(options.minCropBoxHeight) || 0;
+ var maxCropBoxWidth = limited ? Math.min(containerData.width, canvasData.width, canvasData.width + canvasData.left, containerData.width - canvasData.left) : containerData.width;
+ var maxCropBoxHeight = limited ? Math.min(containerData.height, canvasData.height, canvasData.height + canvasData.top, containerData.height - canvasData.top) : containerData.height; // The min/maxCropBoxWidth/Height must be less than container's width/height
+
+ minCropBoxWidth = Math.min(minCropBoxWidth, containerData.width);
+ minCropBoxHeight = Math.min(minCropBoxHeight, containerData.height);
+
+ if (aspectRatio) {
+ if (minCropBoxWidth && minCropBoxHeight) {
+ if (minCropBoxHeight * aspectRatio > minCropBoxWidth) {
+ minCropBoxHeight = minCropBoxWidth / aspectRatio;
+ } else {
+ minCropBoxWidth = minCropBoxHeight * aspectRatio;
+ }
+ } else if (minCropBoxWidth) {
+ minCropBoxHeight = minCropBoxWidth / aspectRatio;
+ } else if (minCropBoxHeight) {
+ minCropBoxWidth = minCropBoxHeight * aspectRatio;
+ }
+
+ if (maxCropBoxHeight * aspectRatio > maxCropBoxWidth) {
+ maxCropBoxHeight = maxCropBoxWidth / aspectRatio;
+ } else {
+ maxCropBoxWidth = maxCropBoxHeight * aspectRatio;
+ }
+ } // The minWidth/Height must be less than maxWidth/Height
+
+
+ cropBoxData.minWidth = Math.min(minCropBoxWidth, maxCropBoxWidth);
+ cropBoxData.minHeight = Math.min(minCropBoxHeight, maxCropBoxHeight);
+ cropBoxData.maxWidth = maxCropBoxWidth;
+ cropBoxData.maxHeight = maxCropBoxHeight;
+ }
+
+ if (positionLimited) {
+ if (limited) {
+ cropBoxData.minLeft = Math.max(0, canvasData.left);
+ cropBoxData.minTop = Math.max(0, canvasData.top);
+ cropBoxData.maxLeft = Math.min(containerData.width, canvasData.left + canvasData.width) - cropBoxData.width;
+ cropBoxData.maxTop = Math.min(containerData.height, canvasData.top + canvasData.height) - cropBoxData.height;
+ } else {
+ cropBoxData.minLeft = 0;
+ cropBoxData.minTop = 0;
+ cropBoxData.maxLeft = containerData.width - cropBoxData.width;
+ cropBoxData.maxTop = containerData.height - cropBoxData.height;
+ }
+ }
+ },
+ renderCropBox: function renderCropBox() {
+ var options = this.options,
+ containerData = this.containerData,
+ cropBoxData = this.cropBoxData;
+
+ if (cropBoxData.width > cropBoxData.maxWidth || cropBoxData.width < cropBoxData.minWidth) {
+ cropBoxData.left = cropBoxData.oldLeft;
+ }
+
+ if (cropBoxData.height > cropBoxData.maxHeight || cropBoxData.height < cropBoxData.minHeight) {
+ cropBoxData.top = cropBoxData.oldTop;
+ }
+
+ cropBoxData.width = Math.min(Math.max(cropBoxData.width, cropBoxData.minWidth), cropBoxData.maxWidth);
+ cropBoxData.height = Math.min(Math.max(cropBoxData.height, cropBoxData.minHeight), cropBoxData.maxHeight);
+ this.limitCropBox(false, true);
+ cropBoxData.left = Math.min(Math.max(cropBoxData.left, cropBoxData.minLeft), cropBoxData.maxLeft);
+ cropBoxData.top = Math.min(Math.max(cropBoxData.top, cropBoxData.minTop), cropBoxData.maxTop);
+ cropBoxData.oldLeft = cropBoxData.left;
+ cropBoxData.oldTop = cropBoxData.top;
+
+ if (options.movable && options.cropBoxMovable) {
+ // Turn to move the canvas when the crop box is equal to the container
+ setData(this.face, DATA_ACTION, cropBoxData.width >= containerData.width && cropBoxData.height >= containerData.height ? ACTION_MOVE : ACTION_ALL);
+ }
+
+ setStyle(this.cropBox, assign({
+ width: cropBoxData.width,
+ height: cropBoxData.height
+ }, getTransforms({
+ translateX: cropBoxData.left,
+ translateY: cropBoxData.top
+ })));
+
+ if (this.cropped && this.limited) {
+ this.limitCanvas(true, true);
+ }
+
+ if (!this.disabled) {
+ this.output();
+ }
+ },
+ output: function output() {
+ this.preview();
+ dispatchEvent(this.element, EVENT_CROP, this.getData());
+ }
+ };
+
+ var preview = {
+ initPreview: function initPreview() {
+ var element = this.element,
+ crossOrigin = this.crossOrigin;
+ var preview = this.options.preview;
+ var url = crossOrigin ? this.crossOriginUrl : this.url;
+ var alt = element.alt || 'The image to preview';
+ var image = document.createElement('img');
+
+ if (crossOrigin) {
+ image.crossOrigin = crossOrigin;
+ }
+
+ image.src = url;
+ image.alt = alt;
+ this.viewBox.appendChild(image);
+ this.viewBoxImage = image;
+
+ if (!preview) {
+ return;
+ }
+
+ var previews = preview;
+
+ if (typeof preview === 'string') {
+ previews = element.ownerDocument.querySelectorAll(preview);
+ } else if (preview.querySelector) {
+ previews = [preview];
+ }
+
+ this.previews = previews;
+ forEach(previews, function (el) {
+ var img = document.createElement('img'); // Save the original size for recover
+
+ setData(el, DATA_PREVIEW, {
+ width: el.offsetWidth,
+ height: el.offsetHeight,
+ html: el.innerHTML
+ });
+
+ if (crossOrigin) {
+ img.crossOrigin = crossOrigin;
+ }
+
+ img.src = url;
+ img.alt = alt;
+ /**
+ * Override img element styles
+ * Add `display:block` to avoid margin top issue
+ * Add `height:auto` to override `height` attribute on IE8
+ * (Occur only when margin-top <= -height)
+ */
+
+ img.style.cssText = 'display:block;' + 'width:100%;' + 'height:auto;' + 'min-width:0!important;' + 'min-height:0!important;' + 'max-width:none!important;' + 'max-height:none!important;' + 'image-orientation:0deg!important;"';
+ el.innerHTML = '';
+ el.appendChild(img);
+ });
+ },
+ resetPreview: function resetPreview() {
+ forEach(this.previews, function (element) {
+ var data = getData(element, DATA_PREVIEW);
+ setStyle(element, {
+ width: data.width,
+ height: data.height
+ });
+ element.innerHTML = data.html;
+ removeData(element, DATA_PREVIEW);
+ });
+ },
+ preview: function preview() {
+ var imageData = this.imageData,
+ canvasData = this.canvasData,
+ cropBoxData = this.cropBoxData;
+ var cropBoxWidth = cropBoxData.width,
+ cropBoxHeight = cropBoxData.height;
+ var width = imageData.width,
+ height = imageData.height;
+ var left = cropBoxData.left - canvasData.left - imageData.left;
+ var top = cropBoxData.top - canvasData.top - imageData.top;
+
+ if (!this.cropped || this.disabled) {
+ return;
+ }
+
+ setStyle(this.viewBoxImage, assign({
+ width: width,
+ height: height
+ }, getTransforms(assign({
+ translateX: -left,
+ translateY: -top
+ }, imageData))));
+ forEach(this.previews, function (element) {
+ var data = getData(element, DATA_PREVIEW);
+ var originalWidth = data.width;
+ var originalHeight = data.height;
+ var newWidth = originalWidth;
+ var newHeight = originalHeight;
+ var ratio = 1;
+
+ if (cropBoxWidth) {
+ ratio = originalWidth / cropBoxWidth;
+ newHeight = cropBoxHeight * ratio;
+ }
+
+ if (cropBoxHeight && newHeight > originalHeight) {
+ ratio = originalHeight / cropBoxHeight;
+ newWidth = cropBoxWidth * ratio;
+ newHeight = originalHeight;
+ }
+
+ setStyle(element, {
+ width: newWidth,
+ height: newHeight
+ });
+ setStyle(element.getElementsByTagName('img')[0], assign({
+ width: width * ratio,
+ height: height * ratio
+ }, getTransforms(assign({
+ translateX: -left * ratio,
+ translateY: -top * ratio
+ }, imageData))));
+ });
+ }
+ };
+
+ var events = {
+ bind: function bind() {
+ var element = this.element,
+ options = this.options,
+ cropper = this.cropper;
+
+ if (isFunction(options.cropstart)) {
+ addListener(element, EVENT_CROP_START, options.cropstart);
+ }
+
+ if (isFunction(options.cropmove)) {
+ addListener(element, EVENT_CROP_MOVE, options.cropmove);
+ }
+
+ if (isFunction(options.cropend)) {
+ addListener(element, EVENT_CROP_END, options.cropend);
+ }
+
+ if (isFunction(options.crop)) {
+ addListener(element, EVENT_CROP, options.crop);
+ }
+
+ if (isFunction(options.zoom)) {
+ addListener(element, EVENT_ZOOM, options.zoom);
+ }
+
+ addListener(cropper, EVENT_POINTER_DOWN, this.onCropStart = this.cropStart.bind(this));
+
+ if (options.zoomable && options.zoomOnWheel) {
+ addListener(cropper, EVENT_WHEEL, this.onWheel = this.wheel.bind(this), {
+ passive: false,
+ capture: true
+ });
+ }
+
+ if (options.toggleDragModeOnDblclick) {
+ addListener(cropper, EVENT_DBLCLICK, this.onDblclick = this.dblclick.bind(this));
+ }
+
+ addListener(element.ownerDocument, EVENT_POINTER_MOVE, this.onCropMove = this.cropMove.bind(this));
+ addListener(element.ownerDocument, EVENT_POINTER_UP, this.onCropEnd = this.cropEnd.bind(this));
+
+ if (options.responsive) {
+ addListener(window, EVENT_RESIZE, this.onResize = this.resize.bind(this));
+ }
+ },
+ unbind: function unbind() {
+ var element = this.element,
+ options = this.options,
+ cropper = this.cropper;
+
+ if (isFunction(options.cropstart)) {
+ removeListener(element, EVENT_CROP_START, options.cropstart);
+ }
+
+ if (isFunction(options.cropmove)) {
+ removeListener(element, EVENT_CROP_MOVE, options.cropmove);
+ }
+
+ if (isFunction(options.cropend)) {
+ removeListener(element, EVENT_CROP_END, options.cropend);
+ }
+
+ if (isFunction(options.crop)) {
+ removeListener(element, EVENT_CROP, options.crop);
+ }
+
+ if (isFunction(options.zoom)) {
+ removeListener(element, EVENT_ZOOM, options.zoom);
+ }
+
+ removeListener(cropper, EVENT_POINTER_DOWN, this.onCropStart);
+
+ if (options.zoomable && options.zoomOnWheel) {
+ removeListener(cropper, EVENT_WHEEL, this.onWheel, {
+ passive: false,
+ capture: true
+ });
+ }
+
+ if (options.toggleDragModeOnDblclick) {
+ removeListener(cropper, EVENT_DBLCLICK, this.onDblclick);
+ }
+
+ removeListener(element.ownerDocument, EVENT_POINTER_MOVE, this.onCropMove);
+ removeListener(element.ownerDocument, EVENT_POINTER_UP, this.onCropEnd);
+
+ if (options.responsive) {
+ removeListener(window, EVENT_RESIZE, this.onResize);
+ }
+ }
+ };
+
+ var handlers = {
+ resize: function resize() {
+ var options = this.options,
+ container = this.container,
+ containerData = this.containerData;
+ var minContainerWidth = Number(options.minContainerWidth) || MIN_CONTAINER_WIDTH;
+ var minContainerHeight = Number(options.minContainerHeight) || MIN_CONTAINER_HEIGHT;
+
+ if (this.disabled || containerData.width <= minContainerWidth || containerData.height <= minContainerHeight) {
+ return;
+ }
+
+ var ratio = container.offsetWidth / containerData.width; // Resize when width changed or height changed
+
+ if (ratio !== 1 || container.offsetHeight !== containerData.height) {
+ var canvasData;
+ var cropBoxData;
+
+ if (options.restore) {
+ canvasData = this.getCanvasData();
+ cropBoxData = this.getCropBoxData();
+ }
+
+ this.render();
+
+ if (options.restore) {
+ this.setCanvasData(forEach(canvasData, function (n, i) {
+ canvasData[i] = n * ratio;
+ }));
+ this.setCropBoxData(forEach(cropBoxData, function (n, i) {
+ cropBoxData[i] = n * ratio;
+ }));
+ }
+ }
+ },
+ dblclick: function dblclick() {
+ if (this.disabled || this.options.dragMode === DRAG_MODE_NONE) {
+ return;
+ }
+
+ this.setDragMode(hasClass(this.dragBox, CLASS_CROP) ? DRAG_MODE_MOVE : DRAG_MODE_CROP);
+ },
+ wheel: function wheel(event) {
+ var _this = this;
+
+ var ratio = Number(this.options.wheelZoomRatio) || 0.1;
+ var delta = 1;
+
+ if (this.disabled) {
+ return;
+ }
+
+ event.preventDefault(); // Limit wheel speed to prevent zoom too fast (#21)
+
+ if (this.wheeling) {
+ return;
+ }
+
+ this.wheeling = true;
+ setTimeout(function () {
+ _this.wheeling = false;
+ }, 50);
+
+ if (event.deltaY) {
+ delta = event.deltaY > 0 ? 1 : -1;
+ } else if (event.wheelDelta) {
+ delta = -event.wheelDelta / 120;
+ } else if (event.detail) {
+ delta = event.detail > 0 ? 1 : -1;
+ }
+
+ this.zoom(-delta * ratio, event);
+ },
+ cropStart: function cropStart(event) {
+ var buttons = event.buttons,
+ button = event.button;
+
+ if (this.disabled // No primary button (Usually the left button)
+ // Note that touch events have no `buttons` or `button` property
+ || isNumber(buttons) && buttons !== 1 || isNumber(button) && button !== 0 // Open context menu
+ || event.ctrlKey) {
+ return;
+ }
+
+ var options = this.options,
+ pointers = this.pointers;
+ var action;
+
+ if (event.changedTouches) {
+ // Handle touch event
+ forEach(event.changedTouches, function (touch) {
+ pointers[touch.identifier] = getPointer(touch);
+ });
+ } else {
+ // Handle mouse event and pointer event
+ pointers[event.pointerId || 0] = getPointer(event);
+ }
+
+ if (Object.keys(pointers).length > 1 && options.zoomable && options.zoomOnTouch) {
+ action = ACTION_ZOOM;
+ } else {
+ action = getData(event.target, DATA_ACTION);
+ }
+
+ if (!REGEXP_ACTIONS.test(action)) {
+ return;
+ }
+
+ if (dispatchEvent(this.element, EVENT_CROP_START, {
+ originalEvent: event,
+ action: action
+ }) === false) {
+ return;
+ } // This line is required for preventing page zooming in iOS browsers
+
+
+ event.preventDefault();
+ this.action = action;
+ this.cropping = false;
+
+ if (action === ACTION_CROP) {
+ this.cropping = true;
+ addClass(this.dragBox, CLASS_MODAL);
+ }
+ },
+ cropMove: function cropMove(event) {
+ var action = this.action;
+
+ if (this.disabled || !action) {
+ return;
+ }
+
+ var pointers = this.pointers;
+ event.preventDefault();
+
+ if (dispatchEvent(this.element, EVENT_CROP_MOVE, {
+ originalEvent: event,
+ action: action
+ }) === false) {
+ return;
+ }
+
+ if (event.changedTouches) {
+ forEach(event.changedTouches, function (touch) {
+ // The first parameter should not be undefined (#432)
+ assign(pointers[touch.identifier] || {}, getPointer(touch, true));
+ });
+ } else {
+ assign(pointers[event.pointerId || 0] || {}, getPointer(event, true));
+ }
+
+ this.change(event);
+ },
+ cropEnd: function cropEnd(event) {
+ if (this.disabled) {
+ return;
+ }
+
+ var action = this.action,
+ pointers = this.pointers;
+
+ if (event.changedTouches) {
+ forEach(event.changedTouches, function (touch) {
+ delete pointers[touch.identifier];
+ });
+ } else {
+ delete pointers[event.pointerId || 0];
+ }
+
+ if (!action) {
+ return;
+ }
+
+ event.preventDefault();
+
+ if (!Object.keys(pointers).length) {
+ this.action = '';
+ }
+
+ if (this.cropping) {
+ this.cropping = false;
+ toggleClass(this.dragBox, CLASS_MODAL, this.cropped && this.options.modal);
+ }
+
+ dispatchEvent(this.element, EVENT_CROP_END, {
+ originalEvent: event,
+ action: action
+ });
+ }
+ };
+
+ var change = {
+ change: function change(event) {
+ var options = this.options,
+ canvasData = this.canvasData,
+ containerData = this.containerData,
+ cropBoxData = this.cropBoxData,
+ pointers = this.pointers;
+ var action = this.action;
+ var aspectRatio = options.aspectRatio;
+ var left = cropBoxData.left,
+ top = cropBoxData.top,
+ width = cropBoxData.width,
+ height = cropBoxData.height;
+ var right = left + width;
+ var bottom = top + height;
+ var minLeft = 0;
+ var minTop = 0;
+ var maxWidth = containerData.width;
+ var maxHeight = containerData.height;
+ var renderable = true;
+ var offset; // Locking aspect ratio in "free mode" by holding shift key
+
+ if (!aspectRatio && event.shiftKey) {
+ aspectRatio = width && height ? width / height : 1;
+ }
+
+ if (this.limited) {
+ minLeft = cropBoxData.minLeft;
+ minTop = cropBoxData.minTop;
+ maxWidth = minLeft + Math.min(containerData.width, canvasData.width, canvasData.left + canvasData.width);
+ maxHeight = minTop + Math.min(containerData.height, canvasData.height, canvasData.top + canvasData.height);
+ }
+
+ var pointer = pointers[Object.keys(pointers)[0]];
+ var range = {
+ x: pointer.endX - pointer.startX,
+ y: pointer.endY - pointer.startY
+ };
+
+ var check = function check(side) {
+ switch (side) {
+ case ACTION_EAST:
+ if (right + range.x > maxWidth) {
+ range.x = maxWidth - right;
+ }
+
+ break;
+
+ case ACTION_WEST:
+ if (left + range.x < minLeft) {
+ range.x = minLeft - left;
+ }
+
+ break;
+
+ case ACTION_NORTH:
+ if (top + range.y < minTop) {
+ range.y = minTop - top;
+ }
+
+ break;
+
+ case ACTION_SOUTH:
+ if (bottom + range.y > maxHeight) {
+ range.y = maxHeight - bottom;
+ }
+
+ break;
+
+ default:
+ }
+ };
+
+ switch (action) {
+ // Move crop box
+ case ACTION_ALL:
+ left += range.x;
+ top += range.y;
+ break;
+ // Resize crop box
+
+ case ACTION_EAST:
+ if (range.x >= 0 && (right >= maxWidth || aspectRatio && (top <= minTop || bottom >= maxHeight))) {
+ renderable = false;
+ break;
+ }
+
+ check(ACTION_EAST);
+ width += range.x;
+
+ if (width < 0) {
+ action = ACTION_WEST;
+ width = -width;
+ left -= width;
+ }
+
+ if (aspectRatio) {
+ height = width / aspectRatio;
+ top += (cropBoxData.height - height) / 2;
+ }
+
+ break;
+
+ case ACTION_NORTH:
+ if (range.y <= 0 && (top <= minTop || aspectRatio && (left <= minLeft || right >= maxWidth))) {
+ renderable = false;
+ break;
+ }
+
+ check(ACTION_NORTH);
+ height -= range.y;
+ top += range.y;
+
+ if (height < 0) {
+ action = ACTION_SOUTH;
+ height = -height;
+ top -= height;
+ }
+
+ if (aspectRatio) {
+ width = height * aspectRatio;
+ left += (cropBoxData.width - width) / 2;
+ }
+
+ break;
+
+ case ACTION_WEST:
+ if (range.x <= 0 && (left <= minLeft || aspectRatio && (top <= minTop || bottom >= maxHeight))) {
+ renderable = false;
+ break;
+ }
+
+ check(ACTION_WEST);
+ width -= range.x;
+ left += range.x;
+
+ if (width < 0) {
+ action = ACTION_EAST;
+ width = -width;
+ left -= width;
+ }
+
+ if (aspectRatio) {
+ height = width / aspectRatio;
+ top += (cropBoxData.height - height) / 2;
+ }
+
+ break;
+
+ case ACTION_SOUTH:
+ if (range.y >= 0 && (bottom >= maxHeight || aspectRatio && (left <= minLeft || right >= maxWidth))) {
+ renderable = false;
+ break;
+ }
+
+ check(ACTION_SOUTH);
+ height += range.y;
+
+ if (height < 0) {
+ action = ACTION_NORTH;
+ height = -height;
+ top -= height;
+ }
+
+ if (aspectRatio) {
+ width = height * aspectRatio;
+ left += (cropBoxData.width - width) / 2;
+ }
+
+ break;
+
+ case ACTION_NORTH_EAST:
+ if (aspectRatio) {
+ if (range.y <= 0 && (top <= minTop || right >= maxWidth)) {
+ renderable = false;
+ break;
+ }
+
+ check(ACTION_NORTH);
+ height -= range.y;
+ top += range.y;
+ width = height * aspectRatio;
+ } else {
+ check(ACTION_NORTH);
+ check(ACTION_EAST);
+
+ if (range.x >= 0) {
+ if (right < maxWidth) {
+ width += range.x;
+ } else if (range.y <= 0 && top <= minTop) {
+ renderable = false;
+ }
+ } else {
+ width += range.x;
+ }
+
+ if (range.y <= 0) {
+ if (top > minTop) {
+ height -= range.y;
+ top += range.y;
+ }
+ } else {
+ height -= range.y;
+ top += range.y;
+ }
+ }
+
+ if (width < 0 && height < 0) {
+ action = ACTION_SOUTH_WEST;
+ height = -height;
+ width = -width;
+ top -= height;
+ left -= width;
+ } else if (width < 0) {
+ action = ACTION_NORTH_WEST;
+ width = -width;
+ left -= width;
+ } else if (height < 0) {
+ action = ACTION_SOUTH_EAST;
+ height = -height;
+ top -= height;
+ }
+
+ break;
+
+ case ACTION_NORTH_WEST:
+ if (aspectRatio) {
+ if (range.y <= 0 && (top <= minTop || left <= minLeft)) {
+ renderable = false;
+ break;
+ }
+
+ check(ACTION_NORTH);
+ height -= range.y;
+ top += range.y;
+ width = height * aspectRatio;
+ left += cropBoxData.width - width;
+ } else {
+ check(ACTION_NORTH);
+ check(ACTION_WEST);
+
+ if (range.x <= 0) {
+ if (left > minLeft) {
+ width -= range.x;
+ left += range.x;
+ } else if (range.y <= 0 && top <= minTop) {
+ renderable = false;
+ }
+ } else {
+ width -= range.x;
+ left += range.x;
+ }
+
+ if (range.y <= 0) {
+ if (top > minTop) {
+ height -= range.y;
+ top += range.y;
+ }
+ } else {
+ height -= range.y;
+ top += range.y;
+ }
+ }
+
+ if (width < 0 && height < 0) {
+ action = ACTION_SOUTH_EAST;
+ height = -height;
+ width = -width;
+ top -= height;
+ left -= width;
+ } else if (width < 0) {
+ action = ACTION_NORTH_EAST;
+ width = -width;
+ left -= width;
+ } else if (height < 0) {
+ action = ACTION_SOUTH_WEST;
+ height = -height;
+ top -= height;
+ }
+
+ break;
+
+ case ACTION_SOUTH_WEST:
+ if (aspectRatio) {
+ if (range.x <= 0 && (left <= minLeft || bottom >= maxHeight)) {
+ renderable = false;
+ break;
+ }
+
+ check(ACTION_WEST);
+ width -= range.x;
+ left += range.x;
+ height = width / aspectRatio;
+ } else {
+ check(ACTION_SOUTH);
+ check(ACTION_WEST);
+
+ if (range.x <= 0) {
+ if (left > minLeft) {
+ width -= range.x;
+ left += range.x;
+ } else if (range.y >= 0 && bottom >= maxHeight) {
+ renderable = false;
+ }
+ } else {
+ width -= range.x;
+ left += range.x;
+ }
+
+ if (range.y >= 0) {
+ if (bottom < maxHeight) {
+ height += range.y;
+ }
+ } else {
+ height += range.y;
+ }
+ }
+
+ if (width < 0 && height < 0) {
+ action = ACTION_NORTH_EAST;
+ height = -height;
+ width = -width;
+ top -= height;
+ left -= width;
+ } else if (width < 0) {
+ action = ACTION_SOUTH_EAST;
+ width = -width;
+ left -= width;
+ } else if (height < 0) {
+ action = ACTION_NORTH_WEST;
+ height = -height;
+ top -= height;
+ }
+
+ break;
+
+ case ACTION_SOUTH_EAST:
+ if (aspectRatio) {
+ if (range.x >= 0 && (right >= maxWidth || bottom >= maxHeight)) {
+ renderable = false;
+ break;
+ }
+
+ check(ACTION_EAST);
+ width += range.x;
+ height = width / aspectRatio;
+ } else {
+ check(ACTION_SOUTH);
+ check(ACTION_EAST);
+
+ if (range.x >= 0) {
+ if (right < maxWidth) {
+ width += range.x;
+ } else if (range.y >= 0 && bottom >= maxHeight) {
+ renderable = false;
+ }
+ } else {
+ width += range.x;
+ }
+
+ if (range.y >= 0) {
+ if (bottom < maxHeight) {
+ height += range.y;
+ }
+ } else {
+ height += range.y;
+ }
+ }
+
+ if (width < 0 && height < 0) {
+ action = ACTION_NORTH_WEST;
+ height = -height;
+ width = -width;
+ top -= height;
+ left -= width;
+ } else if (width < 0) {
+ action = ACTION_SOUTH_WEST;
+ width = -width;
+ left -= width;
+ } else if (height < 0) {
+ action = ACTION_NORTH_EAST;
+ height = -height;
+ top -= height;
+ }
+
+ break;
+ // Move canvas
+
+ case ACTION_MOVE:
+ this.move(range.x, range.y);
+ renderable = false;
+ break;
+ // Zoom canvas
+
+ case ACTION_ZOOM:
+ this.zoom(getMaxZoomRatio(pointers), event);
+ renderable = false;
+ break;
+ // Create crop box
+
+ case ACTION_CROP:
+ if (!range.x || !range.y) {
+ renderable = false;
+ break;
+ }
+
+ offset = getOffset(this.cropper);
+ left = pointer.startX - offset.left;
+ top = pointer.startY - offset.top;
+ width = cropBoxData.minWidth;
+ height = cropBoxData.minHeight;
+
+ if (range.x > 0) {
+ action = range.y > 0 ? ACTION_SOUTH_EAST : ACTION_NORTH_EAST;
+ } else if (range.x < 0) {
+ left -= width;
+ action = range.y > 0 ? ACTION_SOUTH_WEST : ACTION_NORTH_WEST;
+ }
+
+ if (range.y < 0) {
+ top -= height;
+ } // Show the crop box if is hidden
+
+
+ if (!this.cropped) {
+ removeClass(this.cropBox, CLASS_HIDDEN);
+ this.cropped = true;
+
+ if (this.limited) {
+ this.limitCropBox(true, true);
+ }
+ }
+
+ break;
+
+ default:
+ }
+
+ if (renderable) {
+ cropBoxData.width = width;
+ cropBoxData.height = height;
+ cropBoxData.left = left;
+ cropBoxData.top = top;
+ this.action = action;
+ this.renderCropBox();
+ } // Override
+
+
+ forEach(pointers, function (p) {
+ p.startX = p.endX;
+ p.startY = p.endY;
+ });
+ }
+ };
+
+ var methods = {
+ // Show the crop box manually
+ crop: function crop() {
+ if (this.ready && !this.cropped && !this.disabled) {
+ this.cropped = true;
+ this.limitCropBox(true, true);
+
+ if (this.options.modal) {
+ addClass(this.dragBox, CLASS_MODAL);
+ }
+
+ removeClass(this.cropBox, CLASS_HIDDEN);
+ this.setCropBoxData(this.initialCropBoxData);
+ }
+
+ return this;
+ },
+ // Reset the image and crop box to their initial states
+ reset: function reset() {
+ if (this.ready && !this.disabled) {
+ this.imageData = assign({}, this.initialImageData);
+ this.canvasData = assign({}, this.initialCanvasData);
+ this.cropBoxData = assign({}, this.initialCropBoxData);
+ this.renderCanvas();
+
+ if (this.cropped) {
+ this.renderCropBox();
+ }
+ }
+
+ return this;
+ },
+ // Clear the crop box
+ clear: function clear() {
+ if (this.cropped && !this.disabled) {
+ assign(this.cropBoxData, {
+ left: 0,
+ top: 0,
+ width: 0,
+ height: 0
+ });
+ this.cropped = false;
+ this.renderCropBox();
+ this.limitCanvas(true, true); // Render canvas after crop box rendered
+
+ this.renderCanvas();
+ removeClass(this.dragBox, CLASS_MODAL);
+ addClass(this.cropBox, CLASS_HIDDEN);
+ }
+
+ return this;
+ },
+
+ /**
+ * Replace the image's src and rebuild the cropper
+ * @param {string} url - The new URL.
+ * @param {boolean} [hasSameSize] - Indicate if the new image has the same size as the old one.
+ * @returns {Cropper} this
+ */
+ replace: function replace(url) {
+ var hasSameSize = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
+
+ if (!this.disabled && url) {
+ if (this.isImg) {
+ this.element.src = url;
+ }
+
+ if (hasSameSize) {
+ this.url = url;
+ this.image.src = url;
+
+ if (this.ready) {
+ this.viewBoxImage.src = url;
+ forEach(this.previews, function (element) {
+ element.getElementsByTagName('img')[0].src = url;
+ });
+ }
+ } else {
+ if (this.isImg) {
+ this.replaced = true;
+ }
+
+ this.options.data = null;
+ this.uncreate();
+ this.load(url);
+ }
+ }
+
+ return this;
+ },
+ // Enable (unfreeze) the cropper
+ enable: function enable() {
+ if (this.ready && this.disabled) {
+ this.disabled = false;
+ removeClass(this.cropper, CLASS_DISABLED);
+ }
+
+ return this;
+ },
+ // Disable (freeze) the cropper
+ disable: function disable() {
+ if (this.ready && !this.disabled) {
+ this.disabled = true;
+ addClass(this.cropper, CLASS_DISABLED);
+ }
+
+ return this;
+ },
+
+ /**
+ * Destroy the cropper and remove the instance from the image
+ * @returns {Cropper} this
+ */
+ destroy: function destroy() {
+ var element = this.element;
+
+ if (!element[NAMESPACE]) {
+ return this;
+ }
+
+ element[NAMESPACE] = undefined;
+
+ if (this.isImg && this.replaced) {
+ element.src = this.originalUrl;
+ }
+
+ this.uncreate();
+ return this;
+ },
+
+ /**
+ * Move the canvas with relative offsets
+ * @param {number} offsetX - The relative offset distance on the x-axis.
+ * @param {number} [offsetY=offsetX] - The relative offset distance on the y-axis.
+ * @returns {Cropper} this
+ */
+ move: function move(offsetX) {
+ var offsetY = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : offsetX;
+ var _this$canvasData = this.canvasData,
+ left = _this$canvasData.left,
+ top = _this$canvasData.top;
+ return this.moveTo(isUndefined(offsetX) ? offsetX : left + Number(offsetX), isUndefined(offsetY) ? offsetY : top + Number(offsetY));
+ },
+
+ /**
+ * Move the canvas to an absolute point
+ * @param {number} x - The x-axis coordinate.
+ * @param {number} [y=x] - The y-axis coordinate.
+ * @returns {Cropper} this
+ */
+ moveTo: function moveTo(x) {
+ var y = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : x;
+ var canvasData = this.canvasData;
+ var changed = false;
+ x = Number(x);
+ y = Number(y);
+
+ if (this.ready && !this.disabled && this.options.movable) {
+ if (isNumber(x)) {
+ canvasData.left = x;
+ changed = true;
+ }
+
+ if (isNumber(y)) {
+ canvasData.top = y;
+ changed = true;
+ }
+
+ if (changed) {
+ this.renderCanvas(true);
+ }
+ }
+
+ return this;
+ },
+
+ /**
+ * Zoom the canvas with a relative ratio
+ * @param {number} ratio - The target ratio.
+ * @param {Event} _originalEvent - The original event if any.
+ * @returns {Cropper} this
+ */
+ zoom: function zoom(ratio, _originalEvent) {
+ var canvasData = this.canvasData;
+ ratio = Number(ratio);
+
+ if (ratio < 0) {
+ ratio = 1 / (1 - ratio);
+ } else {
+ ratio = 1 + ratio;
+ }
+
+ return this.zoomTo(canvasData.width * ratio / canvasData.naturalWidth, null, _originalEvent);
+ },
+
+ /**
+ * Zoom the canvas to an absolute ratio
+ * @param {number} ratio - The target ratio.
+ * @param {Object} pivot - The zoom pivot point coordinate.
+ * @param {Event} _originalEvent - The original event if any.
+ * @returns {Cropper} this
+ */
+ zoomTo: function zoomTo(ratio, pivot, _originalEvent) {
+ var options = this.options,
+ canvasData = this.canvasData;
+ var width = canvasData.width,
+ height = canvasData.height,
+ naturalWidth = canvasData.naturalWidth,
+ naturalHeight = canvasData.naturalHeight;
+ ratio = Number(ratio);
+
+ if (ratio >= 0 && this.ready && !this.disabled && options.zoomable) {
+ var newWidth = naturalWidth * ratio;
+ var newHeight = naturalHeight * ratio;
+
+ if (dispatchEvent(this.element, EVENT_ZOOM, {
+ ratio: ratio,
+ oldRatio: width / naturalWidth,
+ originalEvent: _originalEvent
+ }) === false) {
+ return this;
+ }
+
+ if (_originalEvent) {
+ var pointers = this.pointers;
+ var offset = getOffset(this.cropper);
+ var center = pointers && Object.keys(pointers).length ? getPointersCenter(pointers) : {
+ pageX: _originalEvent.pageX,
+ pageY: _originalEvent.pageY
+ }; // Zoom from the triggering point of the event
+
+ canvasData.left -= (newWidth - width) * ((center.pageX - offset.left - canvasData.left) / width);
+ canvasData.top -= (newHeight - height) * ((center.pageY - offset.top - canvasData.top) / height);
+ } else if (isPlainObject(pivot) && isNumber(pivot.x) && isNumber(pivot.y)) {
+ canvasData.left -= (newWidth - width) * ((pivot.x - canvasData.left) / width);
+ canvasData.top -= (newHeight - height) * ((pivot.y - canvasData.top) / height);
+ } else {
+ // Zoom from the center of the canvas
+ canvasData.left -= (newWidth - width) / 2;
+ canvasData.top -= (newHeight - height) / 2;
+ }
+
+ canvasData.width = newWidth;
+ canvasData.height = newHeight;
+ this.renderCanvas(true);
+ }
+
+ return this;
+ },
+
+ /**
+ * Rotate the canvas with a relative degree
+ * @param {number} degree - The rotate degree.
+ * @returns {Cropper} this
+ */
+ rotate: function rotate(degree) {
+ return this.rotateTo((this.imageData.rotate || 0) + Number(degree));
+ },
+
+ /**
+ * Rotate the canvas to an absolute degree
+ * @param {number} degree - The rotate degree.
+ * @returns {Cropper} this
+ */
+ rotateTo: function rotateTo(degree) {
+ degree = Number(degree);
+
+ if (isNumber(degree) && this.ready && !this.disabled && this.options.rotatable) {
+ this.imageData.rotate = degree % 360;
+ this.renderCanvas(true, true);
+ }
+
+ return this;
+ },
+
+ /**
+ * Scale the image on the x-axis.
+ * @param {number} scaleX - The scale ratio on the x-axis.
+ * @returns {Cropper} this
+ */
+ scaleX: function scaleX(_scaleX) {
+ var scaleY = this.imageData.scaleY;
+ return this.scale(_scaleX, isNumber(scaleY) ? scaleY : 1);
+ },
+
+ /**
+ * Scale the image on the y-axis.
+ * @param {number} scaleY - The scale ratio on the y-axis.
+ * @returns {Cropper} this
+ */
+ scaleY: function scaleY(_scaleY) {
+ var scaleX = this.imageData.scaleX;
+ return this.scale(isNumber(scaleX) ? scaleX : 1, _scaleY);
+ },
+
+ /**
+ * Scale the image
+ * @param {number} scaleX - The scale ratio on the x-axis.
+ * @param {number} [scaleY=scaleX] - The scale ratio on the y-axis.
+ * @returns {Cropper} this
+ */
+ scale: function scale(scaleX) {
+ var scaleY = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : scaleX;
+ var imageData = this.imageData;
+ var transformed = false;
+ scaleX = Number(scaleX);
+ scaleY = Number(scaleY);
+
+ if (this.ready && !this.disabled && this.options.scalable) {
+ if (isNumber(scaleX)) {
+ imageData.scaleX = scaleX;
+ transformed = true;
+ }
+
+ if (isNumber(scaleY)) {
+ imageData.scaleY = scaleY;
+ transformed = true;
+ }
+
+ if (transformed) {
+ this.renderCanvas(true, true);
+ }
+ }
+
+ return this;
+ },
+
+ /**
+ * Get the cropped area position and size data (base on the original image)
+ * @param {boolean} [rounded=false] - Indicate if round the data values or not.
+ * @returns {Object} The result cropped data.
+ */
+ getData: function getData() {
+ var rounded = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
+ var options = this.options,
+ imageData = this.imageData,
+ canvasData = this.canvasData,
+ cropBoxData = this.cropBoxData;
+ var data;
+
+ if (this.ready && this.cropped) {
+ data = {
+ x: cropBoxData.left - canvasData.left,
+ y: cropBoxData.top - canvasData.top,
+ width: cropBoxData.width,
+ height: cropBoxData.height
+ };
+ var ratio = imageData.width / imageData.naturalWidth;
+ forEach(data, function (n, i) {
+ data[i] = n / ratio;
+ });
+
+ if (rounded) {
+ // In case rounding off leads to extra 1px in right or bottom border
+ // we should round the top-left corner and the dimension (#343).
+ var bottom = Math.round(data.y + data.height);
+ var right = Math.round(data.x + data.width);
+ data.x = Math.round(data.x);
+ data.y = Math.round(data.y);
+ data.width = right - data.x;
+ data.height = bottom - data.y;
+ }
+ } else {
+ data = {
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0
+ };
+ }
+
+ if (options.rotatable) {
+ data.rotate = imageData.rotate || 0;
+ }
+
+ if (options.scalable) {
+ data.scaleX = imageData.scaleX || 1;
+ data.scaleY = imageData.scaleY || 1;
+ }
+
+ return data;
+ },
+
+ /**
+ * Set the cropped area position and size with new data
+ * @param {Object} data - The new data.
+ * @returns {Cropper} this
+ */
+ setData: function setData(data) {
+ var options = this.options,
+ imageData = this.imageData,
+ canvasData = this.canvasData;
+ var cropBoxData = {};
+
+ if (this.ready && !this.disabled && isPlainObject(data)) {
+ var transformed = false;
+
+ if (options.rotatable) {
+ if (isNumber(data.rotate) && data.rotate !== imageData.rotate) {
+ imageData.rotate = data.rotate;
+ transformed = true;
+ }
+ }
+
+ if (options.scalable) {
+ if (isNumber(data.scaleX) && data.scaleX !== imageData.scaleX) {
+ imageData.scaleX = data.scaleX;
+ transformed = true;
+ }
+
+ if (isNumber(data.scaleY) && data.scaleY !== imageData.scaleY) {
+ imageData.scaleY = data.scaleY;
+ transformed = true;
+ }
+ }
+
+ if (transformed) {
+ this.renderCanvas(true, true);
+ }
+
+ var ratio = imageData.width / imageData.naturalWidth;
+
+ if (isNumber(data.x)) {
+ cropBoxData.left = data.x * ratio + canvasData.left;
+ }
+
+ if (isNumber(data.y)) {
+ cropBoxData.top = data.y * ratio + canvasData.top;
+ }
+
+ if (isNumber(data.width)) {
+ cropBoxData.width = data.width * ratio;
+ }
+
+ if (isNumber(data.height)) {
+ cropBoxData.height = data.height * ratio;
+ }
+
+ this.setCropBoxData(cropBoxData);
+ }
+
+ return this;
+ },
+
+ /**
+ * Get the container size data.
+ * @returns {Object} The result container data.
+ */
+ getContainerData: function getContainerData() {
+ return this.ready ? assign({}, this.containerData) : {};
+ },
+
+ /**
+ * Get the image position and size data.
+ * @returns {Object} The result image data.
+ */
+ getImageData: function getImageData() {
+ return this.sized ? assign({}, this.imageData) : {};
+ },
+
+ /**
+ * Get the canvas position and size data.
+ * @returns {Object} The result canvas data.
+ */
+ getCanvasData: function getCanvasData() {
+ var canvasData = this.canvasData;
+ var data = {};
+
+ if (this.ready) {
+ forEach(['left', 'top', 'width', 'height', 'naturalWidth', 'naturalHeight'], function (n) {
+ data[n] = canvasData[n];
+ });
+ }
+
+ return data;
+ },
+
+ /**
+ * Set the canvas position and size with new data.
+ * @param {Object} data - The new canvas data.
+ * @returns {Cropper} this
+ */
+ setCanvasData: function setCanvasData(data) {
+ var canvasData = this.canvasData;
+ var aspectRatio = canvasData.aspectRatio;
+
+ if (this.ready && !this.disabled && isPlainObject(data)) {
+ if (isNumber(data.left)) {
+ canvasData.left = data.left;
+ }
+
+ if (isNumber(data.top)) {
+ canvasData.top = data.top;
+ }
+
+ if (isNumber(data.width)) {
+ canvasData.width = data.width;
+ canvasData.height = data.width / aspectRatio;
+ } else if (isNumber(data.height)) {
+ canvasData.height = data.height;
+ canvasData.width = data.height * aspectRatio;
+ }
+
+ this.renderCanvas(true);
+ }
+
+ return this;
+ },
+
+ /**
+ * Get the crop box position and size data.
+ * @returns {Object} The result crop box data.
+ */
+ getCropBoxData: function getCropBoxData() {
+ var cropBoxData = this.cropBoxData;
+ var data;
+
+ if (this.ready && this.cropped) {
+ data = {
+ left: cropBoxData.left,
+ top: cropBoxData.top,
+ width: cropBoxData.width,
+ height: cropBoxData.height
+ };
+ }
+
+ return data || {};
+ },
+
+ /**
+ * Set the crop box position and size with new data.
+ * @param {Object} data - The new crop box data.
+ * @returns {Cropper} this
+ */
+ setCropBoxData: function setCropBoxData(data) {
+ var cropBoxData = this.cropBoxData;
+ var aspectRatio = this.options.aspectRatio;
+ var widthChanged;
+ var heightChanged;
+
+ if (this.ready && this.cropped && !this.disabled && isPlainObject(data)) {
+ if (isNumber(data.left)) {
+ cropBoxData.left = data.left;
+ }
+
+ if (isNumber(data.top)) {
+ cropBoxData.top = data.top;
+ }
+
+ if (isNumber(data.width) && data.width !== cropBoxData.width) {
+ widthChanged = true;
+ cropBoxData.width = data.width;
+ }
+
+ if (isNumber(data.height) && data.height !== cropBoxData.height) {
+ heightChanged = true;
+ cropBoxData.height = data.height;
+ }
+
+ if (aspectRatio) {
+ if (widthChanged) {
+ cropBoxData.height = cropBoxData.width / aspectRatio;
+ } else if (heightChanged) {
+ cropBoxData.width = cropBoxData.height * aspectRatio;
+ }
+ }
+
+ this.renderCropBox();
+ }
+
+ return this;
+ },
+
+ /**
+ * Get a canvas drawn the cropped image.
+ * @param {Object} [options={}] - The config options.
+ * @returns {HTMLCanvasElement} - The result canvas.
+ */
+ getCroppedCanvas: function getCroppedCanvas() {
+ var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
+
+ if (!this.ready || !window.HTMLCanvasElement) {
+ return null;
+ }
+
+ var canvasData = this.canvasData;
+ var source = getSourceCanvas(this.image, this.imageData, canvasData, options); // Returns the source canvas if it is not cropped.
+
+ if (!this.cropped) {
+ return source;
+ }
+
+ var _this$getData = this.getData(),
+ initialX = _this$getData.x,
+ initialY = _this$getData.y,
+ initialWidth = _this$getData.width,
+ initialHeight = _this$getData.height;
+
+ var ratio = source.width / Math.floor(canvasData.naturalWidth);
+
+ if (ratio !== 1) {
+ initialX *= ratio;
+ initialY *= ratio;
+ initialWidth *= ratio;
+ initialHeight *= ratio;
+ }
+
+ var aspectRatio = initialWidth / initialHeight;
+ var maxSizes = getAdjustedSizes({
+ aspectRatio: aspectRatio,
+ width: options.maxWidth || Infinity,
+ height: options.maxHeight || Infinity
+ });
+ var minSizes = getAdjustedSizes({
+ aspectRatio: aspectRatio,
+ width: options.minWidth || 0,
+ height: options.minHeight || 0
+ }, 'cover');
+
+ var _getAdjustedSizes = getAdjustedSizes({
+ aspectRatio: aspectRatio,
+ width: options.width || (ratio !== 1 ? source.width : initialWidth),
+ height: options.height || (ratio !== 1 ? source.height : initialHeight)
+ }),
+ width = _getAdjustedSizes.width,
+ height = _getAdjustedSizes.height;
+
+ width = Math.min(maxSizes.width, Math.max(minSizes.width, width));
+ height = Math.min(maxSizes.height, Math.max(minSizes.height, height));
+ var canvas = document.createElement('canvas');
+ var context = canvas.getContext('2d');
+ canvas.width = normalizeDecimalNumber(width);
+ canvas.height = normalizeDecimalNumber(height);
+ context.fillStyle = options.fillColor || 'transparent';
+ context.fillRect(0, 0, width, height);
+ var _options$imageSmoothi = options.imageSmoothingEnabled,
+ imageSmoothingEnabled = _options$imageSmoothi === void 0 ? true : _options$imageSmoothi,
+ imageSmoothingQuality = options.imageSmoothingQuality;
+ context.imageSmoothingEnabled = imageSmoothingEnabled;
+
+ if (imageSmoothingQuality) {
+ context.imageSmoothingQuality = imageSmoothingQuality;
+ } // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D.drawImage
+
+
+ var sourceWidth = source.width;
+ var sourceHeight = source.height; // Source canvas parameters
+
+ var srcX = initialX;
+ var srcY = initialY;
+ var srcWidth;
+ var srcHeight; // Destination canvas parameters
+
+ var dstX;
+ var dstY;
+ var dstWidth;
+ var dstHeight;
+
+ if (srcX <= -initialWidth || srcX > sourceWidth) {
+ srcX = 0;
+ srcWidth = 0;
+ dstX = 0;
+ dstWidth = 0;
+ } else if (srcX <= 0) {
+ dstX = -srcX;
+ srcX = 0;
+ srcWidth = Math.min(sourceWidth, initialWidth + srcX);
+ dstWidth = srcWidth;
+ } else if (srcX <= sourceWidth) {
+ dstX = 0;
+ srcWidth = Math.min(initialWidth, sourceWidth - srcX);
+ dstWidth = srcWidth;
+ }
+
+ if (srcWidth <= 0 || srcY <= -initialHeight || srcY > sourceHeight) {
+ srcY = 0;
+ srcHeight = 0;
+ dstY = 0;
+ dstHeight = 0;
+ } else if (srcY <= 0) {
+ dstY = -srcY;
+ srcY = 0;
+ srcHeight = Math.min(sourceHeight, initialHeight + srcY);
+ dstHeight = srcHeight;
+ } else if (srcY <= sourceHeight) {
+ dstY = 0;
+ srcHeight = Math.min(initialHeight, sourceHeight - srcY);
+ dstHeight = srcHeight;
+ }
+
+ var params = [srcX, srcY, srcWidth, srcHeight]; // Avoid "IndexSizeError"
+
+ if (dstWidth > 0 && dstHeight > 0) {
+ var scale = width / initialWidth;
+ params.push(dstX * scale, dstY * scale, dstWidth * scale, dstHeight * scale);
+ } // All the numerical parameters should be integer for `drawImage`
+ // https://github.com/fengyuanchen/cropper/issues/476
+
+
+ context.drawImage.apply(context, [source].concat(_toConsumableArray(params.map(function (param) {
+ return Math.floor(normalizeDecimalNumber(param));
+ }))));
+ return canvas;
+ },
+
+ /**
+ * Change the aspect ratio of the crop box.
+ * @param {number} aspectRatio - The new aspect ratio.
+ * @returns {Cropper} this
+ */
+ setAspectRatio: function setAspectRatio(aspectRatio) {
+ var options = this.options;
+
+ if (!this.disabled && !isUndefined(aspectRatio)) {
+ // 0 -> NaN
+ options.aspectRatio = Math.max(0, aspectRatio) || NaN;
+
+ if (this.ready) {
+ this.initCropBox();
+
+ if (this.cropped) {
+ this.renderCropBox();
+ }
+ }
+ }
+
+ return this;
+ },
+
+ /**
+ * Change the drag mode.
+ * @param {string} mode - The new drag mode.
+ * @returns {Cropper} this
+ */
+ setDragMode: function setDragMode(mode) {
+ var options = this.options,
+ dragBox = this.dragBox,
+ face = this.face;
+
+ if (this.ready && !this.disabled) {
+ var croppable = mode === DRAG_MODE_CROP;
+ var movable = options.movable && mode === DRAG_MODE_MOVE;
+ mode = croppable || movable ? mode : DRAG_MODE_NONE;
+ options.dragMode = mode;
+ setData(dragBox, DATA_ACTION, mode);
+ toggleClass(dragBox, CLASS_CROP, croppable);
+ toggleClass(dragBox, CLASS_MOVE, movable);
+
+ if (!options.cropBoxMovable) {
+ // Sync drag mode to crop box when it is not movable
+ setData(face, DATA_ACTION, mode);
+ toggleClass(face, CLASS_CROP, croppable);
+ toggleClass(face, CLASS_MOVE, movable);
+ }
+ }
+
+ return this;
+ }
+ };
+
+ var AnotherCropper = WINDOW.Cropper;
+
+ var Cropper =
+ /*#__PURE__*/
+ function () {
+ /**
+ * Create a new Cropper.
+ * @param {Element} element - The target element for cropping.
+ * @param {Object} [options={}] - The configuration options.
+ */
+ function Cropper(element) {
+ var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
+
+ _classCallCheck(this, Cropper);
+
+ if (!element || !REGEXP_TAG_NAME.test(element.tagName)) {
+ throw new Error('The first argument is required and must be an <img> or <canvas> element.');
+ }
+
+ this.element = element;
+ this.options = assign({}, DEFAULTS, isPlainObject(options) && options);
+ this.cropped = false;
+ this.disabled = false;
+ this.pointers = {};
+ this.ready = false;
+ this.reloading = false;
+ this.replaced = false;
+ this.sized = false;
+ this.sizing = false;
+ this.init();
+ }
+
+ _createClass(Cropper, [{
+ key: "init",
+ value: function init() {
+ var element = this.element;
+ var tagName = element.tagName.toLowerCase();
+ var url;
+
+ if (element[NAMESPACE]) {
+ return;
+ }
+
+ element[NAMESPACE] = this;
+
+ if (tagName === 'img') {
+ this.isImg = true; // e.g.: "img/picture.jpg"
+
+ url = element.getAttribute('src') || '';
+ this.originalUrl = url; // Stop when it's a blank image
+
+ if (!url) {
+ return;
+ } // e.g.: "http://example.com/img/picture.jpg"
+
+
+ url = element.src;
+ } else if (tagName === 'canvas' && window.HTMLCanvasElement) {
+ url = element.toDataURL();
+ }
+
+ this.load(url);
+ }
+ }, {
+ key: "load",
+ value: function load(url) {
+ var _this = this;
+
+ if (!url) {
+ return;
+ }
+
+ this.url = url;
+ this.imageData = {};
+ var element = this.element,
+ options = this.options;
+
+ if (!options.rotatable && !options.scalable) {
+ options.checkOrientation = false;
+ } // Only IE10+ supports Typed Arrays
+
+
+ if (!options.checkOrientation || !window.ArrayBuffer) {
+ this.clone();
+ return;
+ } // Detect the mime type of the image directly if it is a Data URL
+
+
+ if (REGEXP_DATA_URL.test(url)) {
+ // Read ArrayBuffer from Data URL of JPEG images directly for better performance
+ if (REGEXP_DATA_URL_JPEG.test(url)) {
+ this.read(dataURLToArrayBuffer(url));
+ } else {
+ // Only a JPEG image may contains Exif Orientation information,
+ // the rest types of Data URLs are not necessary to check orientation at all.
+ this.clone();
+ }
+
+ return;
+ } // 1. Detect the mime type of the image by a XMLHttpRequest.
+ // 2. Load the image as ArrayBuffer for reading orientation if its a JPEG image.
+
+
+ var xhr = new XMLHttpRequest();
+ var clone = this.clone.bind(this);
+ this.reloading = true;
+ this.xhr = xhr; // 1. Cross origin requests are only supported for protocol schemes:
+ // http, https, data, chrome, chrome-extension.
+ // 2. Access to XMLHttpRequest from a Data URL will be blocked by CORS policy
+ // in some browsers as IE11 and Safari.
+
+ xhr.onabort = clone;
+ xhr.onerror = clone;
+ xhr.ontimeout = clone;
+
+ xhr.onprogress = function () {
+ // Abort the request directly if it not a JPEG image for better performance
+ if (xhr.getResponseHeader('content-type') !== MIME_TYPE_JPEG) {
+ xhr.abort();
+ }
+ };
+
+ xhr.onload = function () {
+ _this.read(xhr.response);
+ };
+
+ xhr.onloadend = function () {
+ _this.reloading = false;
+ _this.xhr = null;
+ }; // Bust cache when there is a "crossOrigin" property to avoid browser cache error
+
+
+ if (options.checkCrossOrigin && isCrossOriginURL(url) && element.crossOrigin) {
+ url = addTimestamp(url);
+ }
+
+ xhr.open('GET', url);
+ xhr.responseType = 'arraybuffer';
+ xhr.withCredentials = element.crossOrigin === 'use-credentials';
+ xhr.send();
+ }
+ }, {
+ key: "read",
+ value: function read(arrayBuffer) {
+ var options = this.options,
+ imageData = this.imageData; // Reset the orientation value to its default value 1
+ // as some iOS browsers will render image with its orientation
+
+ var orientation = resetAndGetOrientation(arrayBuffer);
+ var rotate = 0;
+ var scaleX = 1;
+ var scaleY = 1;
+
+ if (orientation > 1) {
+ // Generate a new URL which has the default orientation value
+ this.url = arrayBufferToDataURL(arrayBuffer, MIME_TYPE_JPEG);
+
+ var _parseOrientation = parseOrientation(orientation);
+
+ rotate = _parseOrientation.rotate;
+ scaleX = _parseOrientation.scaleX;
+ scaleY = _parseOrientation.scaleY;
+ }
+
+ if (options.rotatable) {
+ imageData.rotate = rotate;
+ }
+
+ if (options.scalable) {
+ imageData.scaleX = scaleX;
+ imageData.scaleY = scaleY;
+ }
+
+ this.clone();
+ }
+ }, {
+ key: "clone",
+ value: function clone() {
+ var element = this.element,
+ url = this.url;
+ var crossOrigin = element.crossOrigin;
+ var crossOriginUrl = url;
+
+ if (this.options.checkCrossOrigin && isCrossOriginURL(url)) {
+ if (!crossOrigin) {
+ crossOrigin = 'anonymous';
+ } // Bust cache when there is not a "crossOrigin" property (#519)
+
+
+ crossOriginUrl = addTimestamp(url);
+ }
+
+ this.crossOrigin = crossOrigin;
+ this.crossOriginUrl = crossOriginUrl;
+ var image = document.createElement('img');
+
+ if (crossOrigin) {
+ image.crossOrigin = crossOrigin;
+ }
+
+ image.src = crossOriginUrl || url;
+ image.alt = element.alt || 'The image to crop';
+ this.image = image;
+ image.onload = this.start.bind(this);
+ image.onerror = this.stop.bind(this);
+ addClass(image, CLASS_HIDE);
+ element.parentNode.insertBefore(image, element.nextSibling);
+ }
+ }, {
+ key: "start",
+ value: function start() {
+ var _this2 = this;
+
+ var image = this.image;
+ image.onload = null;
+ image.onerror = null;
+ this.sizing = true; // Match all browsers that use WebKit as the layout engine in iOS devices,
+ // such as Safari for iOS, Chrome for iOS, and in-app browsers.
+
+ var isIOSWebKit = WINDOW.navigator && /(?:iPad|iPhone|iPod).*?AppleWebKit/i.test(WINDOW.navigator.userAgent);
+
+ var done = function done(naturalWidth, naturalHeight) {
+ assign(_this2.imageData, {
+ naturalWidth: naturalWidth,
+ naturalHeight: naturalHeight,
+ aspectRatio: naturalWidth / naturalHeight
+ });
+ _this2.sizing = false;
+ _this2.sized = true;
+
+ _this2.build();
+ }; // Most modern browsers (excepts iOS WebKit)
+
+
+ if (image.naturalWidth && !isIOSWebKit) {
+ done(image.naturalWidth, image.naturalHeight);
+ return;
+ }
+
+ var sizingImage = document.createElement('img');
+ var body = document.body || document.documentElement;
+ this.sizingImage = sizingImage;
+
+ sizingImage.onload = function () {
+ done(sizingImage.width, sizingImage.height);
+
+ if (!isIOSWebKit) {
+ body.removeChild(sizingImage);
+ }
+ };
+
+ sizingImage.src = image.src; // iOS WebKit will convert the image automatically
+ // with its orientation once append it into DOM (#279)
+
+ if (!isIOSWebKit) {
+ sizingImage.style.cssText = 'left:0;' + 'max-height:none!important;' + 'max-width:none!important;' + 'min-height:0!important;' + 'min-width:0!important;' + 'opacity:0;' + 'position:absolute;' + 'top:0;' + 'z-index:-1;';
+ body.appendChild(sizingImage);
+ }
+ }
+ }, {
+ key: "stop",
+ value: function stop() {
+ var image = this.image;
+ image.onload = null;
+ image.onerror = null;
+ image.parentNode.removeChild(image);
+ this.image = null;
+ }
+ }, {
+ key: "build",
+ value: function build() {
+ if (!this.sized || this.ready) {
+ return;
+ }
+
+ var element = this.element,
+ options = this.options,
+ image = this.image; // Create cropper elements
+
+ var container = element.parentNode;
+ var template = document.createElement('div');
+ template.innerHTML = TEMPLATE;
+ var cropper = template.querySelector(".".concat(NAMESPACE, "-container"));
+ var canvas = cropper.querySelector(".".concat(NAMESPACE, "-canvas"));
+ var dragBox = cropper.querySelector(".".concat(NAMESPACE, "-drag-box"));
+ var cropBox = cropper.querySelector(".".concat(NAMESPACE, "-crop-box"));
+ var face = cropBox.querySelector(".".concat(NAMESPACE, "-face"));
+ this.container = container;
+ this.cropper = cropper;
+ this.canvas = canvas;
+ this.dragBox = dragBox;
+ this.cropBox = cropBox;
+ this.viewBox = cropper.querySelector(".".concat(NAMESPACE, "-view-box"));
+ this.face = face;
+ canvas.appendChild(image); // Hide the original image
+
+ addClass(element, CLASS_HIDDEN); // Inserts the cropper after to the current image
+
+ container.insertBefore(cropper, element.nextSibling); // Show the image if is hidden
+
+ if (!this.isImg) {
+ removeClass(image, CLASS_HIDE);
+ }
+
+ this.initPreview();
+ this.bind();
+ options.initialAspectRatio = Math.max(0, options.initialAspectRatio) || NaN;
+ options.aspectRatio = Math.max(0, options.aspectRatio) || NaN;
+ options.viewMode = Math.max(0, Math.min(3, Math.round(options.viewMode))) || 0;
+ addClass(cropBox, CLASS_HIDDEN);
+
+ if (!options.guides) {
+ addClass(cropBox.getElementsByClassName("".concat(NAMESPACE, "-dashed")), CLASS_HIDDEN);
+ }
+
+ if (!options.center) {
+ addClass(cropBox.getElementsByClassName("".concat(NAMESPACE, "-center")), CLASS_HIDDEN);
+ }
+
+ if (options.background) {
+ addClass(cropper, "".concat(NAMESPACE, "-bg"));
+ }
+
+ if (!options.highlight) {
+ addClass(face, CLASS_INVISIBLE);
+ }
+
+ if (options.cropBoxMovable) {
+ addClass(face, CLASS_MOVE);
+ setData(face, DATA_ACTION, ACTION_ALL);
+ }
+
+ if (!options.cropBoxResizable) {
+ addClass(cropBox.getElementsByClassName("".concat(NAMESPACE, "-line")), CLASS_HIDDEN);
+ addClass(cropBox.getElementsByClassName("".concat(NAMESPACE, "-point")), CLASS_HIDDEN);
+ }
+
+ this.render();
+ this.ready = true;
+ this.setDragMode(options.dragMode);
+
+ if (options.autoCrop) {
+ this.crop();
+ }
+
+ this.setData(options.data);
+
+ if (isFunction(options.ready)) {
+ addListener(element, EVENT_READY, options.ready, {
+ once: true
+ });
+ }
+
+ dispatchEvent(element, EVENT_READY);
+ }
+ }, {
+ key: "unbuild",
+ value: function unbuild() {
+ if (!this.ready) {
+ return;
+ }
+
+ this.ready = false;
+ this.unbind();
+ this.resetPreview();
+ this.cropper.parentNode.removeChild(this.cropper);
+ removeClass(this.element, CLASS_HIDDEN);
+ }
+ }, {
+ key: "uncreate",
+ value: function uncreate() {
+ if (this.ready) {
+ this.unbuild();
+ this.ready = false;
+ this.cropped = false;
+ } else if (this.sizing) {
+ this.sizingImage.onload = null;
+ this.sizing = false;
+ this.sized = false;
+ } else if (this.reloading) {
+ this.xhr.onabort = null;
+ this.xhr.abort();
+ } else if (this.image) {
+ this.stop();
+ }
+ }
+ /**
+ * Get the no conflict cropper class.
+ * @returns {Cropper} The cropper class.
+ */
+
+ }], [{
+ key: "noConflict",
+ value: function noConflict() {
+ window.Cropper = AnotherCropper;
+ return Cropper;
+ }
+ /**
+ * Change the default options.
+ * @param {Object} options - The new default options.
+ */
+
+ }, {
+ key: "setDefaults",
+ value: function setDefaults(options) {
+ assign(DEFAULTS, isPlainObject(options) && options);
+ }
+ }]);
+
+ return Cropper;
+ }();
+
+ assign(Cropper.prototype, render, preview, events, handlers, change, methods);
+
+ return Cropper;
+
+}));
diff --git a/addons/web_editor/static/lib/jQuery.transfo.js b/addons/web_editor/static/lib/jQuery.transfo.js
new file mode 100644
index 00000000..855d6f48
--- /dev/null
+++ b/addons/web_editor/static/lib/jQuery.transfo.js
@@ -0,0 +1,453 @@
+/*
+Copyright (c) 2014 Christophe Matthieu,
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+*/
+
+(function($){
+ 'use strict';
+ var rad = Math.PI/180;
+
+ // public methods
+ var methods = {
+ init : function(settings) {
+ return this.each(function() {
+ var $this = $(this), transfo = $this.data('transfo');
+ if (!transfo) {
+ _init($this, settings);
+ } else {
+ _overwriteOptions($this, transfo, settings);
+ _targetCss($this, transfo);
+ }
+ });
+ },
+
+ destroy : function() {
+ return this.each(function() {
+ var $this = $(this);
+ if ($this.data('transfo')) {
+ _destroy($this);
+ }
+ });
+ },
+
+ reset : function() {
+ return this.each(function() {
+ var $this = $(this);
+ if ($this.data('transfo')) {
+ _reset($this);
+ }
+ });
+ },
+
+ toggle : function() {
+ return this.each(function() {
+ var $this = $(this);
+ var transfo = $this.data('transfo');
+ if (transfo) {
+ transfo.settings.hide = !transfo.settings.hide;
+ _showHide($this, transfo);
+ }
+ });
+ },
+
+ hide : function() {
+ return this.each(function() {
+ var $this = $(this);
+ var transfo = $this.data('transfo');
+ if (transfo) {
+ transfo.settings.hide = true;
+ _showHide($this, transfo);
+ }
+ });
+ },
+
+ show : function() {
+ return this.each(function() {
+ var $this = $(this);
+ var transfo = $this.data('transfo');
+ if (transfo) {
+ transfo.settings.hide = false;
+ _showHide($this, transfo);
+ }
+ });
+ },
+
+ settings : function() {
+ if(this.length > 1) {
+ this.map(function () {
+ var $this = $(this);
+ return $this.data('transfo') && $this.data('transfo').settings;
+ });
+ }
+ return this.data('transfo') && $this.data('transfo').settings;
+ },
+ center : function() {
+ if(this.length > 1) {
+ this.map(function () {
+ var $this = $(this);
+ return $this.data('transfo') && $this.data('transfo').$center.offset();
+ });
+ }
+ return this.data('transfo') && this.data('transfo').$center.offset();
+ }
+ };
+
+ $.fn.transfo = function( method ) {
+ if ( methods[method] ) {
+ return methods[method].apply( this, Array.prototype.slice.call( arguments, 1 ));
+ } else if ( typeof method === 'object' || ! method ) {
+ return methods.init.apply( this, arguments );
+ } else {
+ $.error( 'Method ' + method + ' does not exist on jQuery.transfo' );
+ }
+ return false;
+ };
+
+ function _init ($this, settings) {
+ var transfo = {};
+ $this.data('transfo', transfo);
+ transfo.settings = settings;
+
+ // generate all the controls markup
+ var css = "box-sizing: border-box; position: absolute; background-color: #fff; border: 1px solid #ccc; width: 8px; height: 8px; margin-left: -4px; margin-top: -4px;";
+ transfo.$markup = $(''
+ + '<div class="transfo-container">'
+ + '<div class="transfo-controls">'
+ + '<div style="cursor: crosshair; position: absolute; margin: -30px; top: 0; right: 0; padding: 1px 0 0 1px;" class="transfo-rotator">'
+ + '<span class="fa-stack fa-lg">'
+ + '<i class="fa fa-circle fa-stack-2x"></i>'
+ + '<i class="fa fa-repeat fa-stack-1x fa-inverse"></i>'
+ + '</span>'
+ + '</div>'
+ + '<div style="' + css + 'top: 0%; left: 0%; cursor: nw-resize;" class="transfo-scaler-tl"></div>'
+ + '<div style="' + css + 'top: 0%; left: 100%; cursor: ne-resize;" class="transfo-scaler-tr"></div>'
+ + '<div style="' + css + 'top: 100%; left: 100%; cursor: se-resize;" class="transfo-scaler-br"></div>'
+ + '<div style="' + css + 'top: 100%; left: 0%; cursor: sw-resize;" class="transfo-scaler-bl"></div>'
+ + '<div style="' + css + 'top: 0%; left: 50%; cursor: n-resize;" class="transfo-scaler-tc"></div>'
+ + '<div style="' + css + 'top: 100%; left: 50%; cursor: s-resize;" class="transfo-scaler-bc"></div>'
+ + '<div style="' + css + 'top: 50%; left: 0%; cursor: w-resize;" class="transfo-scaler-ml"></div>'
+ + '<div style="' + css + 'top: 50%; left: 100%; cursor: e-resize;" class="transfo-scaler-mr"></div>'
+ + '<div style="' + css + 'border: 0; width: 0px; height: 0px; top: 50%; left: 50%;" class="transfo-scaler-mc"></div>'
+ + '</div>'
+ + '</div>');
+ transfo.$center = transfo.$markup.find(".transfo-scaler-mc");
+
+ // init setting and get css to set wrap
+ _setOptions($this, transfo);
+ _overwriteOptions ($this, transfo, settings);
+
+ // append controls to container
+ $("body").append(transfo.$markup);
+
+ // set transfo container and markup
+ setTimeout(function () {
+ _targetCss($this, transfo);
+ },0);
+
+ _bind($this, transfo);
+
+ _targetCss($this, transfo);
+ _stop_animation($this[0]);
+ }
+
+ function _overwriteOptions ($this, transfo, settings) {
+ transfo.settings = $.extend(transfo.settings, settings || {});
+ }
+
+ function _stop_animation (target) {
+ target.style.webkitAnimationPlayState = "paused";
+ target.style.animationPlayState = "paused";
+ target.style.webkitTransition = "none";
+ target.style.transition = "none";
+ }
+
+ function _setOptions ($this, transfo) {
+ var style = $this.attr("style") || "";
+ var transform = style.match(/transform\s*:([^;]+)/) ? style.match(/transform\s*:([^;]+)/)[1] : "";
+
+ transfo.settings = {};
+
+ transfo.settings.angle= transform.indexOf('rotate') != -1 ? parseFloat(transform.match(/rotate\(([^)]+)deg\)/)[1]) : 0;
+ transfo.settings.scalex= transform.indexOf('scaleX') != -1 ? parseFloat(transform.match(/scaleX\(([^)]+)\)/)[1]) : 1;
+ transfo.settings.scaley= transform.indexOf('scaleY') != -1 ? parseFloat(transform.match(/scaleY\(([^)]+)\)/)[1]) : 1;
+
+ transfo.settings.style = style.replace(/[^;]*transform[^;]+/g, '').replace(/;+/g, ';');
+
+ $this.attr("style", transfo.settings.style);
+ _stop_animation($this[0]);
+ transfo.settings.pos = $this.offset();
+
+ transfo.settings.height = $this.innerHeight();
+ transfo.settings.width = $this.innerWidth();
+
+ var translatex = transform.match(/translateX\(([0-9.-]+)(%|px)\)/);
+ var translatey = transform.match(/translateY\(([0-9.-]+)(%|px)\)/);
+ transfo.settings.translate = "%";
+
+ if (translatex && translatex[2] === "%") {
+ transfo.settings.translatexp = parseFloat(translatex[1]);
+ transfo.settings.translatex = transfo.settings.translatexp / 100 * transfo.settings.width;
+ } else {
+ transfo.settings.translatex = translatex ? parseFloat(translatex[1]) : 0;
+ }
+ if (translatey && translatey[2] === "%") {
+ transfo.settings.translateyp = parseFloat(translatey[1]);
+ transfo.settings.translatey = transfo.settings.translateyp / 100 * transfo.settings.height;
+ } else {
+ transfo.settings.translatey = translatey ? parseFloat(translatey[1]) : 0;
+ }
+
+ transfo.settings.css = window.getComputedStyle($this[0], null);
+
+ transfo.settings.rotationStep = 5;
+ transfo.settings.hide = false;
+ transfo.settings.callback = function () {};
+ }
+
+ function _bind ($this, transfo) {
+ function mousedown (event) {
+ _mouseDown($this, this, transfo, event);
+ $(document).on("mousemove", mousemove).on("mouseup", mouseup);
+ }
+ function mousemove (event) {
+ _mouseMove($this, this, transfo, event);
+ }
+ function mouseup (event) {
+ _mouseUp($this, this, transfo, event);
+ $(document).off("mousemove", mousemove).off("mouseup", mouseup);
+ }
+
+ transfo.$markup.off().on("mousedown", mousedown);
+ transfo.$markup.find(".transfo-controls >:not(.transfo-scaler-mc)").off().on("mousedown", mousedown);
+ }
+
+ function _mouseDown($this, div, transfo, event) {
+ event.preventDefault();
+ if (transfo.active || event.which !== 1) return;
+
+ var type = "position", $e = $(div);
+ if ($e.hasClass("transfo-rotator")) type = "rotator";
+ else if ($e.hasClass("transfo-scaler-tl")) type = "tl";
+ else if ($e.hasClass("transfo-scaler-tr")) type = "tr";
+ else if ($e.hasClass("transfo-scaler-br")) type = "br";
+ else if ($e.hasClass("transfo-scaler-bl")) type = "bl";
+ else if ($e.hasClass("transfo-scaler-tc")) type = "tc";
+ else if ($e.hasClass("transfo-scaler-bc")) type = "bc";
+ else if ($e.hasClass("transfo-scaler-ml")) type = "ml";
+ else if ($e.hasClass("transfo-scaler-mr")) type = "mr";
+
+ transfo.active = {
+ "type": type,
+ "scalex": transfo.settings.scalex,
+ "scaley": transfo.settings.scaley,
+ "pageX": event.pageX,
+ "pageY": event.pageY,
+ "center": transfo.$center.offset(),
+ };
+ }
+ function _mouseUp($this, div, transfo, event) {
+ transfo.active = null;
+ }
+
+ function _mouseMove($this, div, transfo, event) {
+ event.preventDefault();
+ if (!transfo.active) return;
+ var settings = transfo.settings;
+ var center = transfo.active.center;
+ var cdx = center.left - event.pageX;
+ var cdy = center.top - event.pageY;
+
+ if (transfo.active.type == "rotator") {
+ var ang, dang = Math.atan((settings.width * settings.scalex) / (settings.height * settings.scaley)) / rad;
+
+ if (cdy) ang = Math.atan(- cdx / cdy) / rad;
+ else ang = 0;
+ if (event.pageY >= center.top && event.pageX >= center.left) ang += 180;
+ else if (event.pageY >= center.top && event.pageX < center.left) ang += 180;
+ else if (event.pageY < center.top && event.pageX < center.left) ang += 360;
+
+ ang -= dang;
+ if (settings.scaley < 0 && settings.scalex < 0) ang += 180;
+
+ if (!event.ctrlKey) {
+ settings.angle = Math.round(ang / transfo.settings.rotationStep) * transfo.settings.rotationStep;
+ } else {
+ settings.angle = ang;
+ }
+
+ // reset position : don't move center
+ _targetCss($this, transfo);
+ var new_center = transfo.$center.offset();
+ var x = center.left - new_center.left;
+ var y = center.top - new_center.top;
+ var angle = ang * rad;
+ settings.translatex += x*Math.cos(angle) - y*Math.sin(-angle);
+ settings.translatey += - x*Math.sin(angle) + y*Math.cos(-angle);
+ }
+ else if (transfo.active.type == "position") {
+ var angle = settings.angle * rad;
+ var x = event.pageX - transfo.active.pageX;
+ var y = event.pageY - transfo.active.pageY;
+ transfo.active.pageX = event.pageX;
+ transfo.active.pageY = event.pageY;
+ var dx = x*Math.cos(angle) - y*Math.sin(-angle);
+ var dy = - x*Math.sin(angle) + y*Math.cos(-angle);
+
+ settings.translatex += dx;
+ settings.translatey += dy;
+ }
+ else if (transfo.active.type.length === 2) {
+ var angle = settings.angle * rad;
+ var dx = cdx*Math.cos(angle) - cdy*Math.sin(-angle);
+ var dy = - cdx*Math.sin(angle) + cdy*Math.cos(-angle);
+ if (transfo.active.type.indexOf("t") != -1) {
+ settings.scaley = dy / (settings.height/2);
+ }
+ if (transfo.active.type.indexOf("b") != -1) {
+ settings.scaley = - dy / (settings.height/2);
+ }
+ if (transfo.active.type.indexOf("l") != -1) {
+ settings.scalex = dx / (settings.width/2);
+ }
+ if (transfo.active.type.indexOf("r") != -1) {
+ settings.scalex = - dx / (settings.width/2);
+ }
+ if (settings.scaley > 0 && settings.scaley < 0.05) settings.scaley = 0.05;
+ if (settings.scalex > 0 && settings.scalex < 0.05) settings.scalex = 0.05;
+ if (settings.scaley < 0 && settings.scaley > -0.05) settings.scaley = -0.05;
+ if (settings.scalex < 0 && settings.scalex > -0.05) settings.scalex = -0.05;
+
+ if (event.shiftKey &&
+ (transfo.active.type === "tl" || transfo.active.type === "bl" ||
+ transfo.active.type === "tr" || transfo.active.type === "br")) {
+ settings.scaley = settings.scalex;
+ }
+ }
+
+ settings.angle = Math.round(settings.angle);
+ settings.translatex = Math.round(settings.translatex);
+ settings.translatey = Math.round(settings.translatey);
+ settings.scalex = Math.round(settings.scalex*100)/100;
+ settings.scaley = Math.round(settings.scaley*100)/100;
+
+ _targetCss($this, transfo);
+ _stop_animation($this[0]);
+ return false;
+ }
+
+ function _setCss($this, css, settings) {
+ var transform = "";
+ var trans = false;
+ if (settings.angle !== 0) {
+ trans = true;
+ transform += " rotate("+settings.angle+"deg) ";
+ }
+ if (settings.translatex) {
+ trans = true;
+ transform += " translateX("+(settings.translate === "%" ? settings.translatexp+"%" : settings.translatex+"px")+") ";
+ }
+ if (settings.translatey) {
+ trans = true;
+ transform += " translateY("+(settings.translate === "%" ? settings.translateyp+"%" : settings.translatey+"px")+") ";
+ }
+ if (settings.scalex != 1) {
+ trans = true;
+ transform += " scaleX("+settings.scalex+") ";
+ }
+ if (settings.scaley != 1){
+ trans = true;
+ transform += " scaleY("+settings.scaley+") ";
+ }
+
+ if (trans) {
+ css += ";"
+ /* Safari */
+ css += "-webkit-transform:" + transform + ";"
+ /* Firefox */
+ + "-moz-transform:" + transform + ";"
+ /* IE */
+ + "-ms-transform:" + transform + ";"
+ /* Opera */
+ + "-o-transform:" + transform + ";"
+ /* Other */
+ + "transform:" + transform + ";";
+ }
+
+ css = css.replace(/(\s*;)+/g, ';').replace(/^\s*;|;\s*$/g, '');
+
+ $this.attr("style", css);
+ }
+
+ function _targetCss ($this, transfo) {
+ var settings = transfo.settings;
+ var width = parseFloat(settings.css.width);
+ var height = parseFloat(settings.css.height);
+ settings.translatexp = Math.round(settings.translatex/width*1000)/10;
+ settings.translateyp = Math.round(settings.translatey/height*1000)/10;
+
+ _setCss($this, settings.style, settings);
+
+ transfo.$markup.css({
+ "position": "absolute",
+ "width": width + "px",
+ "height": height + "px",
+ "top": settings.pos.top + "px",
+ "left": settings.pos.left + "px"
+ });
+
+ var $controls = transfo.$markup.find('.transfo-controls');
+ _setCss($controls,
+ "width:" + width + "px;" +
+ "height:" + height + "px;" +
+ "cursor: move;",
+ settings);
+
+ $controls.children().css("transform", "scaleX("+(1/settings.scalex)+") scaleY("+(1/settings.scaley)+")");
+
+ _showHide($this, transfo);
+
+ transfo.settings.callback.call($this[0], $this);
+ }
+
+ function _showHide ($this, transfo) {
+ transfo.$markup.css("z-index", transfo.settings.hide ? -1 : 1000);
+ if (transfo.settings.hide) {
+ transfo.$markup.find(".transfo-controls > *").hide();
+ transfo.$markup.find(".transfo-scaler-mc").show();
+ } else {
+ transfo.$markup.find(".transfo-controls > *").show();
+ }
+ }
+
+ function _destroy ($this) {
+ $this.data('transfo').$markup.remove();
+ $this.removeData('transfo');
+ }
+
+ function _reset ($this) {
+ var transfo = $this.data('transfo');
+ _destroy($this);
+ $this.transfo(transfo.settings);
+ }
+
+})(jQuery);
diff --git a/addons/web_editor/static/lib/jabberwock/jabberwock.css b/addons/web_editor/static/lib/jabberwock/jabberwock.css
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/addons/web_editor/static/lib/jabberwock/jabberwock.css
diff --git a/addons/web_editor/static/lib/jabberwock/jabberwock.js b/addons/web_editor/static/lib/jabberwock/jabberwock.js
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/addons/web_editor/static/lib/jabberwock/jabberwock.js
diff --git a/addons/web_editor/static/lib/jquery-cropper/LICENSE b/addons/web_editor/static/lib/jquery-cropper/LICENSE
new file mode 100644
index 00000000..4b2a9da8
--- /dev/null
+++ b/addons/web_editor/static/lib/jquery-cropper/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2018 Chen Fengyuan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/addons/web_editor/static/lib/jquery-cropper/jquery-cropper.js b/addons/web_editor/static/lib/jquery-cropper/jquery-cropper.js
new file mode 100644
index 00000000..c1c71415
--- /dev/null
+++ b/addons/web_editor/static/lib/jquery-cropper/jquery-cropper.js
@@ -0,0 +1,75 @@
+/*!
+ * jQuery Cropper v1.0.0
+ * https://github.com/fengyuanchen/jquery-cropper
+ *
+ * Copyright (c) 2018 Chen Fengyuan
+ * Released under the MIT license
+ *
+ * Date: 2018-04-01T06:20:13.168Z
+ */
+
+(function (global, factory) {
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(require('jquery'), require('cropperjs')) :
+ typeof define === 'function' && define.amd ? define(['jquery', 'cropperjs'], factory) :
+ (factory(global.jQuery,global.Cropper));
+ }(this, (function ($,Cropper) { 'use strict';
+
+ $ = $ && $.hasOwnProperty('default') ? $['default'] : $;
+ Cropper = Cropper && Cropper.hasOwnProperty('default') ? Cropper['default'] : Cropper;
+
+ if ($.fn) {
+ var AnotherCropper = $.fn.cropper;
+ var NAMESPACE = 'cropper';
+
+ $.fn.cropper = function jQueryCropper(option) {
+ for (var _len = arguments.length, args = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
+ args[_key - 1] = arguments[_key];
+ }
+
+ var result = void 0;
+
+ this.each(function (i, element) {
+ var $element = $(element);
+ var isDestroy = option === 'destroy';
+ var cropper = $element.data(NAMESPACE);
+
+ if (!cropper) {
+ if (isDestroy) {
+ return;
+ }
+
+ var options = $.extend({}, $element.data(), $.isPlainObject(option) && option);
+
+ cropper = new Cropper(element, options);
+ $element.data(NAMESPACE, cropper);
+ }
+
+ if (typeof option === 'string') {
+ var fn = cropper[option];
+
+ if ($.isFunction(fn)) {
+ result = fn.apply(cropper, args);
+
+ if (result === cropper) {
+ result = undefined;
+ }
+
+ if (isDestroy) {
+ $element.removeData(NAMESPACE);
+ }
+ }
+ }
+ });
+
+ return result !== undefined ? result : this;
+ };
+
+ $.fn.cropper.Constructor = Cropper;
+ $.fn.cropper.setDefaults = Cropper.setDefaults;
+ $.fn.cropper.noConflict = function noConflict() {
+ $.fn.cropper = AnotherCropper;
+ return this;
+ };
+ }
+
+ })));
diff --git a/addons/web_editor/static/lib/summernote/src/css/summernote.css b/addons/web_editor/static/lib/summernote/src/css/summernote.css
new file mode 100644
index 00000000..7604c927
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/css/summernote.css
@@ -0,0 +1,447 @@
+/* Theme Variables
+ ------------------------------------------*/
+/* Frame Mode Layout
+ ------------------------------------------*/
+.note-editor {
+ border: 1px solid #a9a9a9;
+ position: relative;
+ /* overflow: hidden; ODOO: removed for embedded editor */
+ /* dropzone */
+ /* codeview mode */
+ /* fullscreen mode */
+ /* statusbar */
+}
+.note-editor .note-dropzone {
+ position: absolute;
+ display: none;
+ z-index: 100;
+ color: lightskyblue;
+ background-color: white;
+ opacity: 0.95;
+ pointer-event: none;
+}
+.note-editor .note-dropzone .note-dropzone-message {
+ display: table-cell;
+ vertical-align: middle;
+ text-align: center;
+ font-size: 28px;
+ font-weight: bold;
+}
+.note-editor .note-dropzone.hover {
+ color: #098ddf;
+}
+.note-editor.dragover .note-dropzone {
+ display: table;
+}
+.note-editor.codeview .note-editing-area .note-editable {
+ display: none;
+}
+.note-editor.codeview .note-editing-area .note-codable {
+ display: block;
+}
+.note-editor.fullscreen {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ z-index: 1050;
+ /* bs3 modal-backdrop: 1030, bs2: 1040 */
+}
+.note-editor.fullscreen .note-editable {
+ background-color: white;
+}
+.note-editor.fullscreen .note-resizebar {
+ display: none;
+}
+.note-editor .note-editing-area {
+ position: relative;
+ overflow: hidden;
+ /* editable */
+ /* codeable */
+}
+.note-editor .note-editing-area .note-editable {
+ background-color: #fff;
+ color: #000;
+ padding: 10px;
+ overflow: auto;
+ outline: none;
+}
+.note-editor .note-editing-area .note-editable[contenteditable=true]:empty:not(:focus):before {
+ content: attr(data-placeholder);
+}
+.note-editor .note-editing-area .note-editable[contenteditable="false"] {
+ background-color: #e5e5e5;
+}
+.note-editor .note-editing-area .note-codable {
+ display: none;
+ width: 100%;
+ padding: 10px;
+ border: none;
+ box-shadow: none;
+ font-family: Menlo, Monaco, monospace, sans-serif;
+ font-size: 14px;
+ color: #ccc;
+ background-color: #222;
+ resize: none;
+ /* override BS2 default style */
+ box-sizing: border-box;
+ border-radius: 0;
+ margin-bottom: 0;
+}
+.note-editor .note-statusbar {
+ background-color: #f5f5f5;
+}
+.note-editor .note-statusbar .note-resizebar {
+ padding-top: 1px;
+ height: 8px;
+ width: 100%;
+ cursor: ns-resize;
+}
+.note-editor .note-statusbar .note-resizebar .note-icon-bar {
+ width: 20px;
+ margin: 1px auto;
+ border-top: 1px solid #a9a9a9;
+}
+/* Air Mode Layout
+------------------------------------------*/
+.note-air-editor {
+ outline: none;
+}
+/* Popover
+------------------------------------------*/
+.note-popover .popover {
+ max-width: none;
+}
+.note-popover .popover .popover-body a {
+ display: inline-block;
+ max-width: 200px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ /* for FF */
+ vertical-align: middle;
+ /* for FF */
+}
+.note-popover .popover .arrow {
+ left: 20px;
+}
+/* Popover and Toolbar (Button container)
+------------------------------------------*/
+.note-popover .popover .popover-body,
+.panel-heading.note-toolbar {
+ margin: 0;
+ padding: 0 0 5px 5px;
+ /* dropdown-menu for toolbar and popover */
+ /* color palette for toolbar and popover */
+}
+.note-popover .popover .popover-body > .btn-group,
+.panel-heading.note-toolbar > .btn-group {
+ margin-top: 5px;
+ margin-left: 0;
+ margin-right: 5px;
+}
+.note-popover .popover .popover-body .btn-group .note-table,
+.panel-heading.note-toolbar .btn-group .note-table {
+ min-width: 0;
+ padding: 5px;
+}
+.note-popover .popover .popover-body .btn-group .note-table .note-dimension-picker,
+.panel-heading.note-toolbar .btn-group .note-table .note-dimension-picker {
+ font-size: 18px;
+}
+.note-popover .popover .popover-body .btn-group .note-table .note-dimension-picker .note-dimension-picker-mousecatcher,
+.panel-heading.note-toolbar .btn-group .note-table .note-dimension-picker .note-dimension-picker-mousecatcher {
+ position: absolute !important;
+ z-index: 3;
+ width: 10em;
+ height: 10em;
+ cursor: pointer;
+}
+.note-popover .popover .popover-body .btn-group .note-table .note-dimension-picker .note-dimension-picker-unhighlighted,
+.panel-heading.note-toolbar .btn-group .note-table .note-dimension-picker .note-dimension-picker-unhighlighted {
+ position: relative !important;
+ z-index: 1;
+ width: 5em;
+ height: 5em;
+ background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASAgMAAAAroGbEAAAACVBMVEUAAIj4+Pjp6ekKlAqjAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfYAR0BKhmnaJzPAAAAG0lEQVQI12NgAAOtVatWMTCohoaGUY+EmIkEAEruEzK2J7tvAAAAAElFTkSuQmCC') repeat;
+}
+.note-popover .popover .popover-body .btn-group .note-table .note-dimension-picker .note-dimension-picker-highlighted,
+.panel-heading.note-toolbar .btn-group .note-table .note-dimension-picker .note-dimension-picker-highlighted {
+ position: absolute !important;
+ z-index: 2;
+ width: 1em;
+ height: 1em;
+ background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASAgMAAAAroGbEAAAACVBMVEUAAIjd6vvD2f9LKLW+AAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfYAR0BKwNDEVT0AAAAG0lEQVQI12NgAAOtVatWMTCohoaGUY+EmIkEAEruEzK2J7tvAAAAAElFTkSuQmCC') repeat;
+}
+.note-popover .popover .popover-body .note-style h1,
+.panel-heading.note-toolbar .note-style h1,
+.note-popover .popover .popover-body .note-style h2,
+.panel-heading.note-toolbar .note-style h2,
+.note-popover .popover .popover-body .note-style h3,
+.panel-heading.note-toolbar .note-style h3,
+.note-popover .popover .popover-body .note-style h4,
+.panel-heading.note-toolbar .note-style h4,
+.note-popover .popover .popover-body .note-style h5,
+.panel-heading.note-toolbar .note-style h5,
+.note-popover .popover .popover-body .note-style h6,
+.panel-heading.note-toolbar .note-style h6,
+.note-popover .popover .popover-body .note-style blockquote,
+.panel-heading.note-toolbar .note-style blockquote {
+ margin: 0;
+}
+.note-popover .popover .popover-body .note-color .dropdown-toggle,
+.panel-heading.note-toolbar .note-color .dropdown-toggle {
+ width: 20px;
+ padding-left: 5px;
+}
+.note-popover .popover .popover-body .note-color .dropdown-menu,
+.panel-heading.note-toolbar .note-color .dropdown-menu {
+ min-width: 340px;
+}
+.note-popover .popover .popover-body .note-color .dropdown-menu .btn-group,
+.panel-heading.note-toolbar .note-color .dropdown-menu .btn-group {
+ margin: 0;
+}
+.note-popover .popover .popover-body .note-color .dropdown-menu .btn-group:first-child,
+.panel-heading.note-toolbar .note-color .dropdown-menu .btn-group:first-child {
+ margin: 0 5px;
+}
+.note-popover .popover .popover-body .note-color .dropdown-menu .btn-group .note-palette-title,
+.panel-heading.note-toolbar .note-color .dropdown-menu .btn-group .note-palette-title {
+ font-size: 12px;
+ margin: 2px 7px;
+ text-align: center;
+ border-bottom: 1px solid #eee;
+}
+.note-popover .popover .popover-body .note-color .dropdown-menu .btn-group .note-color-reset,
+.panel-heading.note-toolbar .note-color .dropdown-menu .btn-group .note-color-reset {
+ font-size: 11px;
+ margin: 3px;
+ padding: 0 3px;
+ cursor: pointer;
+ border-radius: 5px;
+}
+.note-popover .popover .popover-body .note-color .dropdown-menu .btn-group .note-color-row,
+.panel-heading.note-toolbar .note-color .dropdown-menu .btn-group .note-color-row {
+ height: 20px;
+}
+.note-popover .popover .popover-body .note-color .dropdown-menu .btn-group .note-color-reset:hover,
+.panel-heading.note-toolbar .note-color .dropdown-menu .btn-group .note-color-reset:hover {
+ background: #eee;
+}
+.note-popover .popover .popover-body .note-para .dropdown-menu,
+.panel-heading.note-toolbar .note-para .dropdown-menu {
+ min-width: 216px;
+ padding: 5px;
+}
+.note-popover .popover .popover-body .note-para .dropdown-menu > div:first-child,
+.panel-heading.note-toolbar .note-para .dropdown-menu > div:first-child {
+ margin-right: 5px;
+}
+.note-popover .popover .popover-body .dropdown-menu,
+.panel-heading.note-toolbar .dropdown-menu {
+ min-width: 90px;
+ /* dropdown-menu right position */
+ /* http://forrst.com/posts/Bootstrap_right_positioned_dropdown-2KB */
+ /* dropdown-menu for selectbox */
+}
+.note-popover .popover .popover-body .dropdown-menu.right,
+.panel-heading.note-toolbar .dropdown-menu.right {
+ right: 0;
+ left: auto;
+}
+.note-popover .popover .popover-body .dropdown-menu.right::before,
+.panel-heading.note-toolbar .dropdown-menu.right::before {
+ right: 9px;
+ left: auto !important;
+}
+.note-popover .popover .popover-body .dropdown-menu.right::after,
+.panel-heading.note-toolbar .dropdown-menu.right::after {
+ right: 10px;
+ left: auto !important;
+}
+.note-popover .popover .popover-body .dropdown-menu.note-check li a i,
+.panel-heading.note-toolbar .dropdown-menu.note-check li a i {
+ color: deepskyblue;
+ visibility: hidden;
+}
+.note-popover .popover .popover-body .dropdown-menu.note-check li a.checked i,
+.panel-heading.note-toolbar .dropdown-menu.note-check li a.checked i {
+ visibility: visible;
+}
+.note-popover .popover .popover-body .note-fontsize-10,
+.panel-heading.note-toolbar .note-fontsize-10 {
+ font-size: 10px;
+}
+.note-popover .popover .popover-body .note-color-palette,
+.panel-heading.note-toolbar .note-color-palette {
+ line-height: 1;
+}
+.note-popover .popover .popover-body .note-color-palette div .note-color-btn,
+.panel-heading.note-toolbar .note-color-palette div .note-color-btn {
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ margin: 0;
+ border: 1px solid #fff;
+}
+.note-popover .popover .popover-body .note-color-palette div .note-color-btn:hover,
+.panel-heading.note-toolbar .note-color-palette div .note-color-btn:hover {
+ border: 1px solid #000;
+}
+/* Dialog
+------------------------------------------*/
+.note-dialog > div {
+ display: none;
+ /* BS2's hide pacth. */
+}
+.note-dialog .form-group {
+ /* overwrite BS's form-horizontal minus margins */
+ margin-left: 0;
+ margin-right: 0;
+}
+.note-dialog .note-modal-form {
+ margin: 0;
+ /* overwrite BS2's form margin bottom */
+}
+.note-dialog .note-image-dialog .note-dropzone {
+ min-height: 100px;
+ font-size: 30px;
+ line-height: 4;
+ /* vertical-align */
+ color: lightgray;
+ text-align: center;
+ border: 4px dashed lightgray;
+ margin-bottom: 10px;
+}
+.note-dialog .note-help-dialog {
+ font-size: 12px;
+ color: #ccc;
+ background-color: #222 !important;
+ opacity: 0.9;
+ /* BS2's background pacth. */
+ background: transparent;
+ border: none;
+}
+.note-dialog .note-help-dialog .modal-content {
+ background: transparent;
+ border: 1px solid white;
+ box-shadow: none;
+ border-radius: 5px;
+}
+.note-dialog .note-help-dialog a {
+ font-size: 12px;
+ color: white;
+}
+.note-dialog .note-help-dialog .title {
+ color: white;
+ font-size: 14px;
+ font-weight: bold;
+ padding-bottom: 5px;
+ margin-bottom: 10px;
+ border-bottom: white 1px solid;
+}
+.note-dialog .note-help-dialog .modal-close {
+ font-size: 14px;
+ color: #dd0;
+ cursor: pointer;
+}
+.note-dialog .note-help-dialog .text-center {
+ margin: 10px 0 0;
+}
+.note-dialog .note-help-dialog .note-shortcut {
+ padding-top: 8px;
+ padding-bottom: 8px;
+}
+.note-dialog .note-help-dialog .note-shortcut-row {
+ margin-right: -5px;
+ margin-left: -5px;
+}
+.note-dialog .note-help-dialog .note-shortcut-col {
+ padding-right: 5px;
+ padding-left: 5px;
+}
+.note-dialog .note-help-dialog .note-shortcut-title {
+ font-size: 13px;
+ font-weight: bold;
+ color: #dd0;
+}
+.note-dialog .note-help-dialog .note-shortcut-key {
+ font-family: "Courier New";
+ color: #dd0;
+ text-align: right;
+}
+/* Handle
+------------------------------------------*/
+.note-handle {
+ /* control selection */
+}
+.note-handle .note-control-selection {
+ position: absolute;
+ display: none;
+ border: 1px solid black;
+}
+.note-handle .note-control-selection > div {
+ position: absolute;
+}
+.note-handle .note-control-selection .note-control-selection-bg {
+ width: 100%;
+ height: 100%;
+ background-color: black;
+ opacity: 0.3;
+}
+.note-handle .note-control-selection .note-control-handle {
+ width: 7px;
+ height: 7px;
+ border: 1px solid black;
+}
+.note-handle .note-control-selection .note-control-holder {
+ width: 7px;
+ height: 7px;
+ border: 1px solid black;
+}
+.note-handle .note-control-selection .note-control-sizing {
+ width: 7px;
+ height: 7px;
+ border: 1px solid black;
+ background-color: white;
+}
+.note-handle .note-control-selection .note-control-nw {
+ top: -5px;
+ left: -5px;
+ border-right: none;
+ border-bottom: none;
+}
+.note-handle .note-control-selection .note-control-ne {
+ top: -5px;
+ right: -5px;
+ border-bottom: none;
+ border-left: none;
+}
+.note-handle .note-control-selection .note-control-sw {
+ bottom: -5px;
+ left: -5px;
+ border-top: none;
+ border-right: none;
+}
+.note-handle .note-control-selection .note-control-se {
+ right: -5px;
+ bottom: -5px;
+ cursor: se-resize;
+}
+.note-handle .note-control-selection .note-control-se.note-control-holder {
+ cursor: default;
+ border-top: none;
+ border-left: none;
+}
+.note-handle .note-control-selection .note-control-selection-info {
+ right: 0;
+ bottom: 0;
+ padding: 5px;
+ margin: 5px;
+ color: white;
+ background-color: black;
+ font-size: 12px;
+ border-radius: 5px;
+ opacity: 0.7;
+}
diff --git a/addons/web_editor/static/lib/summernote/src/js/EventHandler.js b/addons/web_editor/static/lib/summernote/src/js/EventHandler.js
new file mode 100644
index 00000000..a1243c6f
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/EventHandler.js
@@ -0,0 +1,572 @@
+define([
+ 'summernote/core/agent',
+ 'summernote/core/func',
+ 'summernote/core/dom',
+ 'summernote/core/async',
+ 'summernote/core/key',
+ 'summernote/core/list',
+ 'summernote/editing/History',
+ 'summernote/module/Editor',
+ 'summernote/module/Toolbar',
+ 'summernote/module/Statusbar',
+ 'summernote/module/Popover',
+ 'summernote/module/Handle',
+ 'summernote/module/Fullscreen',
+ 'summernote/module/Codeview',
+ 'summernote/module/DragAndDrop',
+ 'summernote/module/Clipboard',
+ 'summernote/module/LinkDialog',
+ 'summernote/module/ImageDialog',
+ 'summernote/module/HelpDialog'
+], function (agent, func, dom, async, key, list, History,
+ Editor, Toolbar, Statusbar, Popover, Handle, Fullscreen, Codeview,
+ DragAndDrop, Clipboard, LinkDialog, ImageDialog, HelpDialog) {
+
+ /**
+ * @class EventHandler
+ *
+ * EventHandler
+ * - TODO: new instance per a editor
+ */
+ var EventHandler = function () {
+ var self = this;
+
+ /**
+ * Modules
+ */
+ var modules = this.modules = {
+ editor: new Editor(this),
+ toolbar: new Toolbar(this),
+ statusbar: new Statusbar(this),
+ popover: new Popover(this),
+ handle: new Handle(this),
+ fullscreen: new Fullscreen(this),
+ codeview: new Codeview(this),
+ dragAndDrop: new DragAndDrop(this),
+ clipboard: new Clipboard(this),
+ linkDialog: new LinkDialog(this),
+ imageDialog: new ImageDialog(this),
+ helpDialog: new HelpDialog(this)
+ };
+
+ /**
+ * invoke module's method
+ *
+ * @param {String} moduleAndMethod - ex) 'editor.redo'
+ * @param {...*} arguments - arguments of method
+ * @return {*}
+ */
+ this.invoke = function () {
+ var moduleAndMethod = list.head(list.from(arguments));
+ var args = list.tail(list.from(arguments));
+
+ var splits = moduleAndMethod.split('.');
+ var hasSeparator = splits.length > 1;
+ var moduleName = hasSeparator && list.head(splits);
+ var methodName = hasSeparator ? list.last(splits) : list.head(splits);
+
+ var module = this.getModule(moduleName);
+ var method = module[methodName];
+
+ return method && method.apply(module, args);
+ };
+
+ /**
+ * returns module
+ *
+ * @param {String} moduleName - name of module
+ * @return {Module} - defaults is editor
+ */
+ this.getModule = function (moduleName) {
+ return this.modules[moduleName] || this.modules.editor;
+ };
+
+ /**
+ * @param {jQuery} $holder
+ * @param {Object} callbacks
+ * @param {String} eventNamespace
+ * @returns {Function}
+ */
+ var bindCustomEvent = this.bindCustomEvent = function ($holder, callbacks, eventNamespace) {
+ return function () {
+ var callback = callbacks[func.namespaceToCamel(eventNamespace, 'on')];
+ if (callback) {
+ callback.apply($holder[0], arguments);
+ }
+ return $holder.trigger('summernote.' + eventNamespace, arguments);
+ };
+ };
+
+ /**
+ * insert Images from file array.
+ *
+ * @private
+ * @param {Object} layoutInfo
+ * @param {File[]} files
+ */
+ this.insertImages = function (layoutInfo, files) {
+ var $editor = layoutInfo.editor(),
+ $editable = layoutInfo.editable(),
+ $holder = layoutInfo.holder();
+
+ var callbacks = $editable.data('callbacks');
+ var options = $editor.data('options');
+
+ // If onImageUpload options setted
+ if (callbacks.onImageUpload) {
+ bindCustomEvent($holder, callbacks, 'image.upload')(files);
+ // else insert Image as dataURL
+ } else {
+ $.each(files, function (idx, file) {
+ var filename = file.name;
+ if (options.maximumImageFileSize && options.maximumImageFileSize < file.size) {
+ bindCustomEvent($holder, callbacks, 'image.upload.error')(options.langInfo.image.maximumFileSizeError);
+ } else {
+ async.readFileAsDataURL(file).then(function (sDataURL) {
+ modules.editor.insertImage($editable, sDataURL, filename);
+ }).fail(function () {
+ bindCustomEvent($holder, callbacks, 'image.upload.error')(options.langInfo.image.maximumFileSizeError);
+ });
+ }
+ });
+ }
+ };
+
+ var commands = {
+ /**
+ * @param {Object} layoutInfo
+ */
+ showLinkDialog: function (layoutInfo) {
+ modules.linkDialog.show(layoutInfo);
+ },
+
+ /**
+ * @param {Object} layoutInfo
+ */
+ showImageDialog: function (layoutInfo) {
+ modules.imageDialog.show(layoutInfo);
+ },
+
+ /**
+ * @param {Object} layoutInfo
+ */
+ showHelpDialog: function (layoutInfo) {
+ modules.helpDialog.show(layoutInfo);
+ },
+
+ /**
+ * @param {Object} layoutInfo
+ */
+ fullscreen: function (layoutInfo) {
+ modules.fullscreen.toggle(layoutInfo);
+ },
+
+ /**
+ * @param {Object} layoutInfo
+ */
+ codeview: function (layoutInfo) {
+ modules.codeview.toggle(layoutInfo);
+ }
+ };
+
+ var hMousedown = function (event) {
+ //preventDefault Selection for FF, IE8+
+ if (dom.isImg(event.target)) {
+ event.preventDefault();
+ }
+ };
+
+ var hKeyupAndMouseup = function (event) {
+ var layoutInfo = dom.makeLayoutInfo(event.currentTarget || event.target);
+ modules.editor.removeBogus(layoutInfo.editable());
+ hToolbarAndPopoverUpdate(event);
+ };
+
+ /**
+ * update sytle info
+ * @param {Object} styleInfo
+ * @param {Object} layoutInfo
+ */
+ this.updateStyleInfo = function (styleInfo, layoutInfo) {
+ if (!styleInfo) {
+ return;
+ }
+ var isAirMode = (layoutInfo.editor().data('options') || {}).airMode;
+ if (!isAirMode) {
+ modules.toolbar.update(layoutInfo.toolbar(), styleInfo);
+ }
+
+ modules.popover.update(layoutInfo.popover(), styleInfo, isAirMode);
+ modules.handle.update(layoutInfo.handle(), styleInfo, isAirMode);
+ };
+
+ var hToolbarAndPopoverUpdate = function (event) {
+ var target = event.target;
+ // delay for range after mouseup
+ setTimeout(function () {
+ var layoutInfo = dom.makeLayoutInfo(target);
+ /* ODOO: (start_modification */
+ if (!layoutInfo) {
+ return;
+ }
+ var $editable = layoutInfo.editable();
+ if (event.setStyleInfoFromEditable) {
+ var styleInfo = modules.editor.styleFromNode($editable);
+ } else {
+ if (!event.isDefaultPrevented()) {
+ modules.editor.saveRange($editable);
+ }
+ var styleInfo = modules.editor.currentStyle(target);
+ }
+ /* ODOO: end_modification) */
+ self.updateStyleInfo(styleInfo, layoutInfo);
+ }, 0);
+ };
+
+ var hScroll = function (event) {
+ var layoutInfo = dom.makeLayoutInfo(event.currentTarget || event.target);
+ //hide popover and handle when scrolled
+ modules.popover.hide(layoutInfo.popover());
+ modules.handle.hide(layoutInfo.handle());
+ };
+
+ var hToolbarAndPopoverMousedown = function (event) {
+ // prevent default event when insertTable (FF, Webkit)
+ var $btn = $(event.target).closest('[data-event]');
+ if ($btn.length) {
+ event.preventDefault();
+ }
+ };
+
+ var hToolbarAndPopoverClick = function (event) {
+ var $btn = $(event.target).closest('[data-event]');
+
+ if (!$btn.length) {
+ return;
+ }
+
+ var eventName = $btn.attr('data-event'),
+ value = $btn.attr('data-value'),
+ hide = $btn.attr('data-hide');
+
+ var layoutInfo = dom.makeLayoutInfo(event.target);
+
+ // before command: detect control selection element($target)
+ var $target;
+ if ($.inArray(eventName, ['resize', 'floatMe', 'removeMedia', 'imageShape']) !== -1) {
+ var $selection = layoutInfo.handle().find('.note-control-selection');
+ $target = $($selection.data('target'));
+ }
+
+ // If requested, hide the popover when the button is clicked.
+ // Useful for things like showHelpDialog.
+ if (hide) {
+ $btn.parents('.popover').hide();
+ }
+
+ if ($.isFunction($.summernote.pluginEvents[eventName])) {
+ $.summernote.pluginEvents[eventName](event, modules.editor, layoutInfo, value);
+ } else if (modules.editor[eventName]) { // on command
+ var $editable = layoutInfo.editable();
+ $editable.focus();
+ modules.editor[eventName]($editable, value, $target);
+ event.preventDefault();
+ } else if (commands[eventName]) {
+ commands[eventName].call(this, layoutInfo);
+ event.preventDefault();
+ }
+
+ // after command
+ if ($.inArray(eventName, ['backColor', 'foreColor']) !== -1) {
+ var options = layoutInfo.editor().data('options', options);
+ var module = options.airMode ? modules.popover : modules.toolbar;
+ module.updateRecentColor(list.head($btn), eventName, value);
+ }
+
+ hToolbarAndPopoverUpdate(event);
+ };
+
+ var PX_PER_EM = 18;
+ var hDimensionPickerMove = function (event, options) {
+ var $picker = $(event.target.parentNode); // target is mousecatcher
+ var $dimensionDisplay = $picker.next();
+ var $catcher = $picker.find('.note-dimension-picker-mousecatcher');
+ var $highlighted = $picker.find('.note-dimension-picker-highlighted');
+ var $unhighlighted = $picker.find('.note-dimension-picker-unhighlighted');
+
+ var posOffset;
+ // HTML5 with jQuery - e.offsetX is undefined in Firefox
+ if (event.offsetX === undefined) {
+ var posCatcher = $(event.target).offset();
+ posOffset = {
+ x: event.pageX - posCatcher.left,
+ y: event.pageY - posCatcher.top
+ };
+ } else {
+ posOffset = {
+ x: event.offsetX,
+ y: event.offsetY
+ };
+ }
+
+ var dim = {
+ c: Math.ceil(posOffset.x / PX_PER_EM) || 1,
+ r: Math.ceil(posOffset.y / PX_PER_EM) || 1
+ };
+
+ $highlighted.css({ width: dim.c + 'em', height: dim.r + 'em' });
+ $catcher.attr('data-value', dim.c + 'x' + dim.r);
+
+ if (3 < dim.c && dim.c < options.insertTableMaxSize.col) {
+ $unhighlighted.css({ width: dim.c + 1 + 'em'});
+ }
+
+ if (3 < dim.r && dim.r < options.insertTableMaxSize.row) {
+ $unhighlighted.css({ height: dim.r + 1 + 'em'});
+ }
+
+ $dimensionDisplay.html(dim.c + ' x ' + dim.r);
+ };
+
+ /**
+ * bind KeyMap on keydown
+ *
+ * @param {Object} layoutInfo
+ * @param {Object} keyMap
+ */
+ this.bindKeyMap = function (layoutInfo, keyMap) {
+ var $editor = layoutInfo.editor();
+ var $editable = layoutInfo.editable();
+
+ $editable.on('keydown', function (event) {
+ var keys = [];
+
+ // modifier
+ if (event.metaKey) { keys.push('CMD'); }
+ if (event.ctrlKey && !event.altKey) { keys.push('CTRL'); }
+ if (event.shiftKey) { keys.push('SHIFT'); }
+
+ // keycode
+ var keyName = key.nameFromCode[event.keyCode];
+ if (keyName) {
+ keys.push(keyName);
+ }
+
+ var pluginEvent;
+ var keyString = keys.join('+');
+ var eventName = keyMap[keyString];
+
+ // ODOO: (start_modification
+ // odoo change: add visible event to overwrite the browser comportment
+ var keycode = event.keyCode;
+ if (!eventName &&
+ !event.ctrlKey && !event.metaKey && ( // special code/command
+ (keycode > 47 && keycode < 58) || // number keys
+ keycode == 32 || keycode == 13 || // spacebar & return
+ (keycode > 64 && keycode < 91) || // letter keys
+ (keycode > 95 && keycode < 112) || // numpad keys
+ (keycode > 185 && keycode < 193) || // ;=,-./` (in order)
+ (keycode > 218 && keycode < 223))) { // [\]' (in order))
+ eventName = 'visible';
+ } else if (!keycode && event.key !== 'Dead') {
+ self.invoke('restoreRange', $editable);
+ }
+ // ODOO: end_modification)
+
+ if (eventName) {
+ // FIXME Summernote doesn't support event pipeline yet.
+ // - Plugin -> Base Code
+ pluginEvent = $.summernote.pluginEvents[keyString];
+ if ($.isFunction(pluginEvent)) {
+ if (pluginEvent(event, modules.editor, layoutInfo)) {
+ return false;
+ }
+ }
+
+ pluginEvent = $.summernote.pluginEvents[eventName];
+
+ if ($.isFunction(pluginEvent)) {
+ pluginEvent(event, modules.editor, layoutInfo);
+ } else if (modules.editor[eventName]) {
+ modules.editor[eventName]($editable, $editor.data('options'));
+ event.preventDefault();
+ } else if (commands[eventName]) {
+ commands[eventName].call(this, layoutInfo);
+ event.preventDefault();
+ }
+ } else if (key.isEdit(event.keyCode)) {
+ modules.editor.afterCommand($editable);
+ }
+ });
+ };
+
+ /**
+ * attach eventhandler
+ *
+ * @param {Object} layoutInfo - layout Informations
+ * @param {Object} options - user options include custom event handlers
+ */
+ this.attach = function (layoutInfo, options) {
+ // handlers for editable
+ if (options.shortcuts) {
+ this.bindKeyMap(layoutInfo, options.keyMap[agent.isMac ? 'mac' : 'pc']);
+ }
+ layoutInfo.editable().on('mousedown', hMousedown);
+ layoutInfo.editable().on('keyup mouseup', hKeyupAndMouseup);
+ layoutInfo.editable().on('scroll', hScroll);
+
+ // handler for clipboard
+ modules.clipboard.attach(layoutInfo, options);
+
+ // handler for handle and popover
+ modules.handle.attach(layoutInfo, options);
+ layoutInfo.popover().on('click', hToolbarAndPopoverClick);
+ layoutInfo.popover().on('mousedown', hToolbarAndPopoverMousedown);
+
+ // handler for drag and drop
+ modules.dragAndDrop.attach(layoutInfo, options);
+
+ // handlers for frame mode (toolbar, statusbar)
+ if (!options.airMode) {
+ // handler for toolbar
+ layoutInfo.toolbar().on('click', hToolbarAndPopoverClick);
+ layoutInfo.toolbar().on('mousedown', hToolbarAndPopoverMousedown);
+
+ // handler for statusbar
+ modules.statusbar.attach(layoutInfo, options);
+ }
+
+ // handler for table dimension
+ var $catcherContainer = options.airMode ? layoutInfo.popover() :
+ layoutInfo.toolbar();
+ var $catcher = $catcherContainer.find('.note-dimension-picker-mousecatcher');
+ $catcher.css({
+ width: options.insertTableMaxSize.col + 'em',
+ height: options.insertTableMaxSize.row + 'em'
+ }).on('mousemove', function (event) {
+ hDimensionPickerMove(event, options);
+ });
+
+ // save options on editor
+ layoutInfo.editor().data('options', options);
+
+ // ret styleWithCSS for backColor / foreColor clearing with 'inherit'.
+ if (!agent.isMSIE) {
+ // [workaround] for Firefox
+ // - protect FF Error: NS_ERROR_FAILURE: Failure
+ setTimeout(function () {
+ document.execCommand('styleWithCSS', 0, options.styleWithSpan);
+ }, 0);
+ }
+
+ // History
+ var history = new History(layoutInfo.editable());
+ layoutInfo.editable().data('NoteHistory', history);
+
+ // All editor status will be saved on editable with jquery's data
+ // for support multiple editor with singleton object.
+ layoutInfo.editable().data('callbacks', {
+ onInit: options.onInit,
+ onFocus: options.onFocus,
+ onBlur: options.onBlur,
+ onKeydown: options.onKeydown,
+ onKeyup: options.onKeyup,
+ onMousedown: options.onMousedown,
+ onEnter: options.onEnter,
+ onPaste: options.onPaste,
+ onBeforeCommand: options.onBeforeCommand,
+ onChange: options.onChange,
+ onImageUpload: options.onImageUpload,
+ onImageUploadError: options.onImageUploadError,
+ onMediaDelete: options.onMediaDelete,
+ onToolbarClick: options.onToolbarClick,
+ onUpload: options.onUpload,
+ });
+
+ var styleInfo = modules.editor.styleFromNode(layoutInfo.editable());
+ this.updateStyleInfo(styleInfo, layoutInfo);
+ };
+
+ /**
+ * attach jquery custom event
+ *
+ * @param {Object} layoutInfo - layout Informations
+ */
+ this.attachCustomEvent = function (layoutInfo, options) {
+ var $holder = layoutInfo.holder();
+ var $editable = layoutInfo.editable();
+ var callbacks = $editable.data('callbacks');
+
+ $editable.focus(bindCustomEvent($holder, callbacks, 'focus'));
+ $editable.blur(bindCustomEvent($holder, callbacks, 'blur'));
+
+ $editable.keydown(function (event) {
+ if (event.keyCode === key.code.ENTER) {
+ bindCustomEvent($holder, callbacks, 'enter').call(this, event);
+ }
+ bindCustomEvent($holder, callbacks, 'keydown').call(this, event);
+ });
+ $editable.keyup(bindCustomEvent($holder, callbacks, 'keyup'));
+
+ $editable.on('mousedown', bindCustomEvent($holder, callbacks, 'mousedown'));
+ $editable.on('mouseup', bindCustomEvent($holder, callbacks, 'mouseup'));
+ $editable.on('scroll', bindCustomEvent($holder, callbacks, 'scroll'));
+
+ $editable.on('paste', bindCustomEvent($holder, callbacks, 'paste'));
+
+ // [workaround] IE doesn't have input events for contentEditable
+ // - see: https://goo.gl/4bfIvA
+ var changeEventName = agent.isMSIE ? 'DOMCharacterDataModified DOMSubtreeModified DOMNodeInserted' : 'input';
+ $editable.on(changeEventName, function () {
+ bindCustomEvent($holder, callbacks, 'change')($editable.html(), $editable);
+ });
+
+ if (!options.airMode) {
+ layoutInfo.toolbar().click(bindCustomEvent($holder, callbacks, 'toolbar.click'));
+ layoutInfo.popover().click(bindCustomEvent($holder, callbacks, 'popover.click'));
+ }
+
+ // Textarea: auto filling the code before form submit.
+ if (dom.isTextarea(list.head($holder))) {
+ $holder.closest('form').submit(function (e) {
+ layoutInfo.holder().val(layoutInfo.holder().code());
+ bindCustomEvent($holder, callbacks, 'submit').call(this, e, $holder.code());
+ });
+ }
+
+ // textarea auto sync
+ if (dom.isTextarea(list.head($holder)) && options.textareaAutoSync) {
+ $holder.on('summernote.change', function () {
+ layoutInfo.holder().val(layoutInfo.holder().code());
+ });
+ }
+
+ // fire init event
+ bindCustomEvent($holder, callbacks, 'init')(layoutInfo);
+
+ // fire plugin init event
+ for (var i = 0, len = $.summernote.plugins.length; i < len; i++) {
+ if ($.isFunction($.summernote.plugins[i].init)) {
+ $.summernote.plugins[i].init(layoutInfo);
+ }
+ }
+ };
+
+ this.detach = function (layoutInfo, options) {
+ layoutInfo.holder().off();
+ layoutInfo.editable().off();
+
+ layoutInfo.popover().off();
+ layoutInfo.handle().off();
+ layoutInfo.dialog().off();
+
+ if (!options.airMode) {
+ layoutInfo.dropzone().off();
+ layoutInfo.toolbar().off();
+ layoutInfo.statusbar().off();
+ }
+ };
+ };
+
+ return EventHandler;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/Renderer.js b/addons/web_editor/static/lib/summernote/src/js/Renderer.js
new file mode 100644
index 00000000..a57dab67
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/Renderer.js
@@ -0,0 +1,1026 @@
+define([
+ 'summernote/core/agent',
+ 'summernote/core/dom',
+ 'summernote/core/func',
+ 'summernote/core/list'
+], function (agent, dom, func, list) {
+ /**
+ * @class Renderer
+ *
+ * renderer
+ *
+ * rendering toolbar and editable
+ */
+ var Renderer = function () {
+
+ /**
+ * bootstrap button template
+ * @private
+ * @param {String} label button name
+ * @param {Object} [options] button options
+ * @param {String} [options.event] data-event
+ * @param {String} [options.className] button's class name
+ * @param {String} [options.value] data-value
+ * @param {String} [options.title] button's title for popup
+ * @param {String} [options.dropdown] dropdown html
+ * @param {String} [options.hide] data-hide
+ */
+ var tplButton = function (label, options) {
+ var event = options.event;
+ var value = options.value;
+ var title = options.title;
+ var className = options.className;
+ var dropdown = options.dropdown;
+ var hide = options.hide;
+
+ return (dropdown ? '<div class="btn-group' +
+ (className ? ' ' + className : '') + '">' : '') +
+ '<button type="button"' +
+ ' class="btn btn-secondary btn-sm' +
+ ((!dropdown && className) ? ' ' + className : '') +
+ (dropdown ? ' dropdown-toggle' : '') +
+ '"' +
+ (dropdown ? ' data-toggle="dropdown"' : '') +
+ (title ? ' title="' + title + '"' : '') +
+ (event ? ' data-event="' + event + '"' : '') +
+ (value ? ' data-value=\'' + value + '\'' : '') +
+ (hide ? ' data-hide=\'' + hide + '\'' : '') +
+ ' tabindex="-1">' +
+ label +
+ (dropdown ? ' <span class="caret"></span>' : '') +
+ '</button>' +
+ (dropdown || '') +
+ (dropdown ? '</div>' : '');
+ };
+
+ /**
+ * bootstrap icon button template
+ * @private
+ * @param {String} iconClassName
+ * @param {Object} [options]
+ * @param {String} [options.event]
+ * @param {String} [options.value]
+ * @param {String} [options.title]
+ * @param {String} [options.dropdown]
+ */
+ var tplIconButton = function (iconClassName, options) {
+ var label = '<i class="' + iconClassName + '"></i>';
+ return tplButton(label, options);
+ };
+
+ /**
+ * bootstrap popover template
+ * @private
+ * @param {String} className
+ * @param {String} content
+ */
+ var tplPopover = function (className, content) {
+ var $popover = $('<div class="' + className + ' popover bottom in" style="display: none;">' +
+ '<div class="arrow"></div>' +
+ '<div class="popover-body">' +
+ '</div>' +
+ '</div>');
+
+ $popover.find('.popover-body').append(content);
+ return $popover;
+ };
+
+ /**
+ * bootstrap dialog template
+ *
+ * @param {String} className
+ * @param {String} [title='']
+ * @param {String} body
+ * @param {String} [footer='']
+ */
+ var tplDialog = function (className, title, body, footer) {
+ return '<div class="' + className + ' modal" role="dialog" aria-hidden="false">' +
+ '<div class="modal-dialog">' +
+ '<div class="modal-content">' +
+ (title ?
+ '<header class="modal-header">' +
+ '<h4 class="modal-title">' + title + '</h4>' +
+ '<button type="button" class="close" aria-hidden="true" tabindex="-1">&times;</button>' +
+ '</header>' : ''
+ ) +
+ '<main class="modal-body">' + body + '</main>' +
+ (footer ?
+ '<header class="modal-footer">' + footer + '</header>' : ''
+ ) +
+ '</div>' +
+ '</div>' +
+ '</div>';
+ };
+
+ /**
+ * bootstrap dropdown template
+ *
+ * @param {String|String[]} contents
+ * @param {String} [className='']
+ * @param {String} [nodeName='']
+ */
+ var tplDropdown = function (contents, className, nodeName) {
+ var classes = 'dropdown-menu' + (className ? ' ' + className : '');
+ nodeName = nodeName || 'ul';
+ if (contents instanceof Array) {
+ contents = contents.join('');
+ }
+
+ return '<' + nodeName + ' class="' + classes + '">' + contents + '</' + nodeName + '>';
+ };
+
+ var tplButtonInfo = {
+ picture: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.image.image, {
+ event: 'showImageDialog',
+ title: lang.image.image,
+ hide: true
+ });
+ },
+ link: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.link.link, {
+ event: 'showLinkDialog',
+ title: lang.link.link,
+ hide: true
+ });
+ },
+ table: function (lang, options) {
+ var dropdown = [
+ '<div class="note-dimension-picker">',
+ '<div class="note-dimension-picker-mousecatcher" data-event="insertTable" data-value="1x1"></div>',
+ '<div class="note-dimension-picker-highlighted"></div>',
+ '<div class="note-dimension-picker-unhighlighted"></div>',
+ '</div>',
+ '<div class="note-dimension-display"> 1 x 1 </div>'
+ ];
+
+ return tplIconButton(options.iconPrefix + options.icons.table.table, {
+ title: lang.table.table,
+ dropdown: tplDropdown(dropdown, 'note-table')
+ });
+ },
+ style: function (lang, options) {
+ var items = options.styleTags.reduce(function (memo, v) {
+ var label = lang.style[v === 'p' ? 'normal' : v];
+ return memo + '<li><a class="dropdown-item" data-event="formatBlock" href="#" data-value="' + v + '">' +
+ (
+ (v === 'p' || v === 'pre') ? label :
+ '<' + v + '>' + label + '</' + v + '>'
+ ) +
+ '</a></li>';
+ }, '');
+
+ return tplIconButton(options.iconPrefix + options.icons.style.style, {
+ title: lang.style.style,
+ dropdown: tplDropdown(items)
+ });
+ },
+ fontname: function (lang, options) {
+ var realFontList = [];
+ var items = options.fontNames.reduce(function (memo, v) {
+ if (!agent.isFontInstalled(v) && !list.contains(options.fontNamesIgnoreCheck, v)) {
+ return memo;
+ }
+ realFontList.push(v);
+ return memo + '<li><a data-event="fontName" href="#" data-value="' + v + '" style="font-family:\'' + v + '\'">' +
+ '<i class="' + options.iconPrefix + options.icons.misc.check + '"></i> ' + v +
+ '</a></li>';
+ }, '');
+
+ var hasDefaultFont = agent.isFontInstalled(options.defaultFontName);
+ var defaultFontName = (hasDefaultFont) ? options.defaultFontName : realFontList[0];
+
+ var label = '<span class="note-current-fontname">' +
+ defaultFontName +
+ '</span>';
+ return tplButton(label, {
+ title: lang.font.name,
+ className: 'note-fontname',
+ dropdown: tplDropdown(items, 'note-check')
+ });
+ },
+ fontsize: function (lang, options) {
+ var items = options.fontSizes.reduce(function (memo, v) {
+ return memo + '<li><a data-event="fontSize" href="#" data-value="' + v + '">' +
+ '<i class="' + options.iconPrefix + options.icons.misc.check + '"></i> ' + v +
+ '</a></li>';
+ }, '');
+
+ var label = '<span class="note-current-fontsize">11</span>';
+ return tplButton(label, {
+ title: lang.font.size,
+ className: 'note-fontsize',
+ dropdown: tplDropdown(items, 'note-check')
+ });
+ },
+ color: function (lang, options) {
+ var colorButtonLabel = '<i class="' +
+ options.iconPrefix + options.icons.color.recent +
+ '" id="colors_preview" style="color:white;background-color:#B35E9B"></i>';
+ var colorButton = tplButton(colorButtonLabel, {
+ className: 'note-recent-color',
+ title: lang.color.recent,
+ event: 'color',
+ value: '{"backColor":"#B35E9B"}'
+ });
+
+ var items = [
+ '<li class="flex"><div class="btn-group flex-column">',
+ '<div class="note-palette-title">' + lang.color.background + '</div>',
+ '<div class="note-color-reset" data-event="backColor"',
+ ' data-value="inherit" title="' + lang.color.transparent + '">' + lang.color.setTransparent + '</div>',
+ '<div class="note-color-palette" data-target-event="backColor"></div>',
+ '</div><div class="btn-group flex-column">',
+ '<div class="note-palette-title">' + lang.color.foreground + '</div>',
+ '<div class="note-color-reset" data-event="foreColor" data-value="inherit" title="' + lang.color.reset + '">',
+ lang.color.resetToDefault,
+ '</div>',
+ '<div class="note-color-palette" data-target-event="foreColor"></div>',
+ '</div></li>'
+ ];
+
+ var moreButton = tplButton('', {
+ title: lang.color.more,
+ dropdown: tplDropdown(items)
+ });
+
+ return colorButton + moreButton;
+ },
+ bold: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.font.bold, {
+ event: 'bold',
+ title: lang.font.bold
+ });
+ },
+ italic: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.font.italic, {
+ event: 'italic',
+ title: lang.font.italic
+ });
+ },
+ underline: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.font.underline, {
+ event: 'underline',
+ title: lang.font.underline
+ });
+ },
+ strikethrough: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.font.strikethrough, {
+ event: 'strikethrough',
+ title: lang.font.strikethrough
+ });
+ },
+ superscript: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.font.superscript, {
+ event: 'superscript',
+ title: lang.font.superscript
+ });
+ },
+ subscript: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.font.subscript, {
+ event: 'subscript',
+ title: lang.font.subscript
+ });
+ },
+ clear: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.font.clear, {
+ event: 'removeFormat',
+ title: lang.font.clear
+ });
+ },
+ ul: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.lists.unordered, {
+ event: 'insertUnorderedList',
+ title: lang.lists.unordered
+ });
+ },
+ ol: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.lists.ordered, {
+ event: 'insertOrderedList',
+ title: lang.lists.ordered
+ });
+ },
+ paragraph: function (lang, options) {
+ var leftButton = tplIconButton(options.iconPrefix + options.icons.paragraph.left, {
+ title: lang.paragraph.left,
+ event: 'justifyLeft'
+ });
+ var centerButton = tplIconButton(options.iconPrefix + options.icons.paragraph.center, {
+ title: lang.paragraph.center,
+ event: 'justifyCenter'
+ });
+ var rightButton = tplIconButton(options.iconPrefix + options.icons.paragraph.right, {
+ title: lang.paragraph.right,
+ event: 'justifyRight'
+ });
+ var justifyButton = tplIconButton(options.iconPrefix + options.icons.paragraph.justify, {
+ title: lang.paragraph.justify,
+ event: 'justifyFull'
+ });
+
+ var outdentButton = tplIconButton(options.iconPrefix + options.icons.paragraph.outdent, {
+ title: lang.paragraph.outdent,
+ event: 'outdent'
+ });
+ var indentButton = tplIconButton(options.iconPrefix + options.icons.paragraph.indent, {
+ title: lang.paragraph.indent,
+ event: 'indent'
+ });
+
+ var dropdown = [
+ '<div class="note-align btn-group">',
+ leftButton + centerButton + rightButton + justifyButton,
+ '</div><div class="note-list btn-group">',
+ indentButton + outdentButton,
+ '</div>'
+ ];
+
+ return tplIconButton(options.iconPrefix + options.icons.paragraph.paragraph, {
+ title: lang.paragraph.paragraph,
+ dropdown: tplDropdown(dropdown, '', 'div')
+ });
+ },
+ height: function (lang, options) {
+ var items = options.lineHeights.reduce(function (memo, v) {
+ return memo + '<li><a data-event="lineHeight" href="#" data-value="' + parseFloat(v) + '">' +
+ '<i class="' + options.iconPrefix + options.icons.misc.check + '"></i> ' + v +
+ '</a></li>';
+ }, '');
+
+ return tplIconButton(options.iconPrefix + options.icons.font.height, {
+ title: lang.font.height,
+ dropdown: tplDropdown(items, 'note-check')
+ });
+
+ },
+ help: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.options.help, {
+ event: 'showHelpDialog',
+ title: lang.options.help,
+ hide: true
+ });
+ },
+ fullscreen: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.options.fullscreen, {
+ event: 'fullscreen',
+ title: lang.options.fullscreen
+ });
+ },
+ codeview: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.options.codeview, {
+ event: 'codeview',
+ title: lang.options.codeview
+ });
+ },
+ undo: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.history.undo, {
+ event: 'undo',
+ title: lang.history.undo
+ });
+ },
+ redo: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.history.redo, {
+ event: 'redo',
+ title: lang.history.redo
+ });
+ },
+ hr: function (lang, options) {
+ return tplIconButton(options.iconPrefix + options.icons.hr.insert, {
+ event: 'insertHorizontalRule',
+ title: lang.hr.insert
+ });
+ }
+ };
+
+ var tplPopovers = function (lang, options) {
+ var tplLinkPopover = function () {
+ var linkButton = tplIconButton(options.iconPrefix + options.icons.link.edit, {
+ title: lang.link.edit,
+ event: 'showLinkDialog',
+ hide: true
+ });
+ var unlinkButton = tplIconButton(options.iconPrefix + options.icons.link.unlink, {
+ title: lang.link.unlink,
+ event: 'unlink'
+ });
+ var content = '<a href="http://www.google.com" target="_blank">www.google.com</a>&nbsp;&nbsp;' +
+ '<div class="note-insert btn-group">' +
+ linkButton + unlinkButton +
+ '</div>';
+ return tplPopover('note-link-popover', content);
+ };
+
+ var tplImagePopover = function () {
+ var autoButton = tplButton('<span class="note-fontsize-10">Auto</span>', {
+ event: 'resize',
+ value: 'auto'
+ });
+ var fullButton = tplButton('<span class="note-fontsize-10">100%</span>', {
+ title: lang.image.resizeFull,
+ event: 'resize',
+ value: '1'
+ });
+ var halfButton = tplButton('<span class="note-fontsize-10">50%</span>', {
+ title: lang.image.resizeHalf,
+ event: 'resize',
+ value: '0.5'
+ });
+ var quarterButton = tplButton('<span class="note-fontsize-10">25%</span>', {
+ title: lang.image.resizeQuarter,
+ event: 'resize',
+ value: '0.25'
+ });
+
+ var leftButton = tplIconButton(options.iconPrefix + options.icons.image.floatLeft, {
+ title: lang.image.floatLeft,
+ event: 'floatMe',
+ value: 'left'
+ });
+ var rightButton = tplIconButton(options.iconPrefix + options.icons.image.floatRight, {
+ title: lang.image.floatRight,
+ event: 'floatMe',
+ value: 'right'
+ });
+ var justifyButton = tplIconButton(options.iconPrefix + options.icons.image.floatNone, {
+ title: lang.image.floatNone,
+ event: 'floatMe',
+ value: 'none'
+ });
+
+ var roundedButton = tplIconButton(options.iconPrefix + options.icons.image.shapeRounded, {
+ title: lang.image.shapeRounded,
+ event: 'imageShape',
+ value: 'rounded'
+ });
+ var circleButton = tplIconButton(options.iconPrefix + options.icons.image.shapeCircle, {
+ title: lang.image.shapeCircle,
+ event: 'imageShape',
+ value: 'rounded-circle'
+ });
+ var thumbnailButton = tplIconButton(options.iconPrefix + options.icons.image.shapeThumbnail, {
+ title: lang.image.shapeThumbnail,
+ event: 'imageShape',
+ value: 'img-thumbnail'
+ });
+ var noneButton = tplIconButton(options.iconPrefix + options.icons.image.shapeNone, {
+ title: lang.image.shapeNone,
+ event: 'imageShape',
+ value: ''
+ });
+
+ var removeButton = tplIconButton(options.iconPrefix + options.icons.image.remove, {
+ title: lang.image.remove,
+ event: 'removeMedia',
+ value: 'none'
+ });
+
+ var content = (options.disableResizeImage ? '' : '<div class="btn-group">' + autoButton + fullButton + halfButton + quarterButton + '</div>') +
+ '<div class="btn-group">' + leftButton + rightButton + justifyButton + '</div>' +
+ '<div class="btn-group">' + roundedButton + circleButton + thumbnailButton + noneButton + '</div>' +
+ '<div class="btn-group">' + removeButton + '</div>';
+ return tplPopover('note-image-popover', content);
+ };
+
+ var tplAirPopover = function () {
+ var $content = $('<div />');
+ for (var idx = 0, len = options.airPopover.length; idx < len; idx ++) {
+ var group = options.airPopover[idx];
+
+ var $group = $('<div class="note-' + group[0] + ' btn-group">');
+ for (var i = 0, lenGroup = group[1].length; i < lenGroup; i++) {
+ var $button = $(tplButtonInfo[group[1][i]](lang, options));
+
+ $button.attr('data-name', group[1][i]);
+
+ $group.append($button);
+ }
+ $content.append($group);
+ }
+
+ return tplPopover('note-air-popover', $content.children());
+ };
+
+ var $notePopover = $('<div class="note-popover" />');
+
+ $notePopover.append(tplLinkPopover());
+ $notePopover.append(tplImagePopover());
+
+ if (options.airMode) {
+ $notePopover.append(tplAirPopover());
+ }
+
+ return $notePopover;
+ };
+
+ this.tplButtonInfo = tplButtonInfo; // ODOO: allow access for override
+ this.tplPopovers = tplPopovers; // ODOO: allow access for override
+
+ var tplHandles = function (options) {
+ return '<div class="note-handle">' +
+ '<div class="note-control-selection">' +
+ '<div class="note-control-selection-bg"></div>' +
+ '<div class="note-control-holder note-control-nw"></div>' +
+ '<div class="note-control-holder note-control-ne"></div>' +
+ '<div class="note-control-holder note-control-sw"></div>' +
+ '<div class="' +
+ (options.disableResizeImage ? 'note-control-holder' : 'note-control-sizing') +
+ ' note-control-se"></div>' +
+ (options.disableResizeImage ? '' : '<div class="note-control-selection-info"></div>') +
+ '</div>' +
+ '</div>';
+ };
+
+ /**
+ * shortcut table template
+ * @param {String} title
+ * @param {String} body
+ */
+ var tplShortcut = function (title, keys) {
+ var keyClass = 'note-shortcut-col col-6 note-shortcut-';
+ var body = [];
+
+ for (var i in keys) {
+ if (keys.hasOwnProperty(i)) {
+ body.push(
+ '<div class="' + keyClass + 'key">' + keys[i].kbd + '</div>' +
+ '<div class="' + keyClass + 'name">' + keys[i].text + '</div>'
+ );
+ }
+ }
+
+ return '<div class="note-shortcut-row row"><div class="' + keyClass + 'title offset-6">' + title + '</div></div>' +
+ '<div class="note-shortcut-row row">' + body.join('</div><div class="note-shortcut-row row">') + '</div>';
+ };
+
+ var tplShortcutText = function (lang) {
+ var keys = [
+ { kbd: '⌘ + B', text: lang.font.bold },
+ { kbd: '⌘ + I', text: lang.font.italic },
+ { kbd: '⌘ + U', text: lang.font.underline },
+ { kbd: '⌘ + \\', text: lang.font.clear }
+ ];
+
+ return tplShortcut(lang.shortcut.textFormatting, keys);
+ };
+
+ var tplShortcutAction = function (lang) {
+ var keys = [
+ { kbd: '⌘ + Z', text: lang.history.undo },
+ { kbd: '⌘ + ⇧ + Z', text: lang.history.redo },
+ { kbd: '⌘ + ]', text: lang.paragraph.indent },
+ { kbd: '⌘ + [', text: lang.paragraph.outdent },
+ { kbd: '⌘ + ENTER', text: lang.hr.insert }
+ ];
+
+ return tplShortcut(lang.shortcut.action, keys);
+ };
+
+ var tplShortcutPara = function (lang) {
+ var keys = [
+ { kbd: '⌘ + ⇧ + L', text: lang.paragraph.left },
+ { kbd: '⌘ + ⇧ + E', text: lang.paragraph.center },
+ { kbd: '⌘ + ⇧ + R', text: lang.paragraph.right },
+ { kbd: '⌘ + ⇧ + J', text: lang.paragraph.justify },
+ { kbd: '⌘ + ⇧ + NUM7', text: lang.lists.ordered },
+ { kbd: '⌘ + ⇧ + NUM8', text: lang.lists.unordered }
+ ];
+
+ return tplShortcut(lang.shortcut.paragraphFormatting, keys);
+ };
+
+ var tplShortcutStyle = function (lang) {
+ var keys = [
+ { kbd: '⌘ + NUM0', text: lang.style.normal },
+ { kbd: '⌘ + NUM1', text: lang.style.h1 },
+ { kbd: '⌘ + NUM2', text: lang.style.h2 },
+ { kbd: '⌘ + NUM3', text: lang.style.h3 },
+ { kbd: '⌘ + NUM4', text: lang.style.h4 },
+ { kbd: '⌘ + NUM5', text: lang.style.h5 },
+ { kbd: '⌘ + NUM6', text: lang.style.h6 }
+ ];
+
+ return tplShortcut(lang.shortcut.documentStyle, keys);
+ };
+
+ var tplExtraShortcuts = function (lang, options) {
+ var extraKeys = options.extraKeys;
+ var keys = [];
+
+ for (var key in extraKeys) {
+ if (extraKeys.hasOwnProperty(key)) {
+ keys.push({ kbd: key, text: extraKeys[key] });
+ }
+ }
+
+ return tplShortcut(lang.shortcut.extraKeys, keys);
+ };
+
+ var tplShortcutTable = function (lang, options) {
+ var colClass = 'class="note-shortcut note-shortcut-col col-md-6 col-12"';
+ var template = [
+ '<div ' + colClass + '>' + tplShortcutAction(lang, options) + '</div>' +
+ '<div ' + colClass + '>' + tplShortcutText(lang, options) + '</div>',
+ '<div ' + colClass + '>' + tplShortcutStyle(lang, options) + '</div>' +
+ '<div ' + colClass + '>' + tplShortcutPara(lang, options) + '</div>'
+ ];
+
+ if (options.extraKeys) {
+ template.push('<div ' + colClass + '>' + tplExtraShortcuts(lang, options) + '</div>');
+ }
+
+ return '<div class="note-shortcut-row row">' +
+ template.join('</div><div class="note-shortcut-row row">') +
+ '</div>';
+ };
+
+ var replaceMacKeys = function (sHtml) {
+ return sHtml.replace(/⌘/g, 'Ctrl').replace(/⇧/g, 'Shift');
+ };
+
+ var tplDialogInfo = {
+ image: function (lang, options) {
+ var imageLimitation = '';
+ if (options.maximumImageFileSize) {
+ var unit = Math.floor(Math.log(options.maximumImageFileSize) / Math.log(1024));
+ var readableSize = (options.maximumImageFileSize / Math.pow(1024, unit)).toFixed(2) * 1 +
+ ' ' + ' KMGTP'[unit] + 'B';
+ imageLimitation = '<small>' + lang.image.maximumFileSize + ' : ' + readableSize + '</small>';
+ }
+
+ var body = '<div class="form-group row note-group-select-from-files">' +
+ '<label>' + lang.image.selectFromFiles + '</label>' +
+ '<input class="note-image-input form-control" type="file" name="files" accept="image/*" multiple="multiple" />' +
+ imageLimitation +
+ '</div>' +
+ '<div class="form-group row">' +
+ '<label>' + lang.image.url + '</label>' +
+ '<input class="note-image-url form-control col-md-12" type="text" />' +
+ '</div>';
+ var footer = '<button href="#" class="btn btn-primary note-image-btn disabled" disabled>' + lang.image.insert + '</button>';
+ return tplDialog('note-image-dialog', lang.image.insert, body, footer);
+ },
+
+ link: function (lang, options) {
+ var body = '<div class="form-group row">' +
+ '<label>' + lang.link.textToDisplay + '</label>' +
+ '<input class="note-link-text form-control col-md-12" type="text" />' +
+ '</div>' +
+ '<div class="form-group row">' +
+ '<label>' + lang.link.url + '</label>' +
+ '<input class="note-link-url form-control col-md-12" type="text" value="http://" />' +
+ '</div>' +
+ (!options.disableLinkTarget ?
+ '<div class="checkbox">' +
+ '<label>' + '<input type="checkbox" checked> ' +
+ lang.link.openInNewWindow +
+ '</label>' +
+ '</div>' : ''
+ );
+ var footer = '<button href="#" class="btn btn-primary note-link-btn disabled" disabled>' + lang.link.insert + '</button>';
+ return tplDialog('note-link-dialog', lang.link.insert, body, footer);
+ },
+
+ help: function (lang, options) {
+ var body = '<a class="modal-close float-right" aria-hidden="true" tabindex="-1">' + lang.shortcut.close + '</a>' +
+ '<div class="title">' + lang.shortcut.shortcuts + '</div>' +
+ (agent.isMac ? tplShortcutTable(lang, options) : replaceMacKeys(tplShortcutTable(lang, options))) +
+ '<p class="text-center">' +
+ '<a href="//summernote.org/" target="_blank">Summernote @VERSION</a> · ' +
+ '<a href="//github.com/summernote/summernote" target="_blank">Project</a> · ' +
+ '<a href="//github.com/summernote/summernote/issues" target="_blank">Issues</a>' +
+ '</p>';
+ return tplDialog('note-help-dialog', '', body, '');
+ }
+ };
+
+ var tplDialogs = function (lang, options) {
+ var dialogs = '';
+
+ $.each(tplDialogInfo, function (idx, tplDialog) {
+ dialogs += tplDialog(lang, options);
+ });
+
+ return '<div class="note-dialog">' + dialogs + '</div>';
+ };
+
+ var tplStatusbar = function () {
+ return '<div class="note-resizebar">' +
+ '<div class="note-icon-bar"></div>' +
+ '<div class="note-icon-bar"></div>' +
+ '<div class="note-icon-bar"></div>' +
+ '</div>';
+ };
+
+ var representShortcut = function (str) {
+ if (agent.isMac) {
+ str = str.replace('CMD', '⌘').replace('SHIFT', '⇧');
+ }
+
+ return str.replace('BACKSLASH', '\\')
+ .replace('SLASH', '/')
+ .replace('LEFTBRACKET', '[')
+ .replace('RIGHTBRACKET', ']');
+ };
+
+ /**
+ * createTooltip
+ *
+ * @param {jQuery} $container
+ * @param {Object} keyMap
+ * @param {String} [sPlacement]
+ */
+ var createTooltip = function ($container, keyMap, sPlacement) {
+ var invertedKeyMap = func.invertObject(keyMap);
+ var $buttons = $container.find('button');
+
+ $buttons.each(function (i, elBtn) {
+ var $btn = $(elBtn);
+ var sShortcut = invertedKeyMap[$btn.data('event')];
+ if (sShortcut) {
+ $btn.attr('title', function (i, v) {
+ return v + ' (' + representShortcut(sShortcut) + ')';
+ });
+ }
+ // bootstrap tooltip on btn-group bug
+ // https://github.com/twbs/bootstrap/issues/5687
+ }).tooltip({
+ container: 'body',
+ trigger: 'hover',
+ placement: sPlacement || 'top'
+ }).on('click', function () {
+ $(this).tooltip('hide');
+ });
+ };
+
+ // createPalette
+ var createPalette = function ($container, options) {
+ var colorInfo = options.colors;
+ $container.find('.note-color-palette').each(function () {
+ var $palette = $(this), eventName = $palette.attr('data-target-event');
+ var paletteContents = [];
+ for (var row = 0, lenRow = colorInfo.length; row < lenRow; row++) {
+ var colors = colorInfo[row];
+ var buttons = [];
+ for (var col = 0, lenCol = colors.length; col < lenCol; col++) {
+ var color = colors[col];
+ buttons.push(['<button type="button" class="note-color-btn" style="background-color:', color,
+ ';" data-event="', eventName,
+ '" data-value="', color,
+ '" title="', color,
+ '" data-toggle="button" tabindex="-1"></button>'].join(''));
+ }
+ paletteContents.push('<div class="note-color-row">' + buttons.join('') + '</div>');
+ }
+ $palette.html(paletteContents.join(''));
+ });
+ };
+
+ this.createPalette = createPalette; // ODOO: allow access for override
+
+ /**
+ * create summernote layout (air mode)
+ *
+ * @param {jQuery} $holder
+ * @param {Object} options
+ */
+ this.createLayoutByAirMode = function ($holder, options) {
+ var langInfo = options.langInfo;
+ var keyMap = options.keyMap[agent.isMac ? 'mac' : 'pc'];
+ var id = func.uniqueId();
+
+ $holder.addClass('note-air-editor note-editable'); // ODOO: removing panel-body class to remove unwanted style
+ $holder.attr({
+ 'data-note-id': id, // ODOO: we use [data-note-id="{id}"] instead of [id="{id}"]
+ // 'id': 'note-editor-' + id,
+ 'contentEditable': true
+ });
+
+ var body = document.body;
+ var $container = $('#web_editor-toolbars')
+
+ // create Popover
+ var $popover = $(this.tplPopovers(langInfo, options)); // ODOO: user (maybe) overrided method
+ $popover.addClass('note-air-layout');
+ $popover.attr('id', 'note-popover-' + id);
+ $popover.appendTo($container);
+ createTooltip($popover, keyMap);
+ this.createPalette($popover, options); // ODOO: use (maybe) overrided method
+
+ // create Handle
+ var $handle = $(tplHandles(options));
+ $handle.addClass('note-air-layout');
+ $handle.attr('id', 'note-handle-' + id);
+ $handle.appendTo($container);
+
+ // create Dialog
+ var $dialog = $(tplDialogs(langInfo, options));
+ $dialog.addClass('note-air-layout');
+ $dialog.attr('id', 'note-dialog-' + id);
+ $dialog.find('button.close, a.modal-close').click(function () {
+ $(this).closest('.modal').modal('hide');
+ });
+ $dialog.appendTo($container);
+ };
+
+ /**
+ * create summernote layout (normal mode)
+ *
+ * @param {jQuery} $holder
+ * @param {Object} options
+ */
+ this.createLayoutByFrame = function ($holder, options) {
+ var langInfo = options.langInfo;
+
+ //01. create Editor
+ var $editor = $('<div class="note-editor panel panel-default" />');
+ if (options.width) {
+ $editor.width(options.width);
+ }
+
+ //02. statusbar (resizebar)
+ if (options.height > 0) {
+ $('<div class="note-statusbar">' + (options.disableResizeEditor ? '' : tplStatusbar()) + '</div>').prependTo($editor);
+ }
+
+ //03 editing area
+ var $editingArea = $('<div class="note-editing-area" />');
+ //03. create editable
+ var isContentEditable = !$holder.is(':disabled');
+ var $editable = $('<div class="note-editable panel-body" contentEditable="' + isContentEditable + '"></div>').prependTo($editingArea);
+
+ if (options.height) {
+ $editable.height(options.height);
+ }
+ if (options.direction) {
+ $editable.attr('dir', options.direction);
+ }
+ var placeholder = $holder.attr('placeholder') || options.placeholder;
+ if (placeholder) {
+ $editable.attr('data-placeholder', placeholder);
+ }
+
+ $editable.html(dom.html($holder) || dom.emptyPara);
+
+ //031. create codable
+ $('<textarea class="note-codable"></textarea>').prependTo($editingArea);
+
+ //04. create Popover
+ var $popover = $(this.tplPopovers(langInfo, options)).prependTo($editingArea); // ODOO: use (maybe) overrided method
+ this.createPalette($popover, options); // ODOO: use (maybe) overrided method
+ createTooltip($popover, keyMap);
+
+ //05. handle(control selection, ...)
+ $(tplHandles(options)).prependTo($editingArea);
+
+ $editingArea.prependTo($editor);
+
+ //06. create Toolbar
+ var $toolbar = $('<div class="note-toolbar panel-heading" />');
+ for (var idx = 0, len = options.toolbar.length; idx < len; idx ++) {
+ var groupName = options.toolbar[idx][0];
+ var groupButtons = options.toolbar[idx][1];
+
+ var $group = $('<div class="note-' + groupName + ' btn-group" />');
+ for (var i = 0, btnLength = groupButtons.length; i < btnLength; i++) {
+ var buttonInfo = tplButtonInfo[groupButtons[i]];
+ // continue creating toolbar even if a button doesn't exist
+ if (!$.isFunction(buttonInfo)) { continue; }
+
+ var $button = $(buttonInfo(langInfo, options));
+ $button.attr('data-name', groupButtons[i]); // set button's alias, becuase to get button element from $toolbar
+ $group.append($button);
+ }
+ $toolbar.append($group);
+ }
+
+ var keyMap = options.keyMap[agent.isMac ? 'mac' : 'pc'];
+ this.createPalette($toolbar, options); // ODOO: use (maybe) overrided method
+ createTooltip($toolbar, keyMap, 'bottom');
+ $toolbar.prependTo($editor);
+
+ //07. create Dropzone
+ $('<div class="note-dropzone"><div class="note-dropzone-message"></div></div>').prependTo($editor);
+
+ //08. create Dialog
+ var $dialogContainer = options.dialogsInBody ? $(document.body) : $editor;
+ var $dialog = $(tplDialogs(langInfo, options)).prependTo($dialogContainer);
+ $dialog.find('button.close, a.modal-close').click(function () {
+ $(this).closest('.modal').modal('hide');
+ });
+
+ //09. Editor/Holder switch
+ $editor.insertAfter($holder);
+ $holder.hide();
+ };
+
+ this.hasNoteEditor = function ($holder) {
+ return this.noteEditorFromHolder($holder).length > 0;
+ };
+
+ this.noteEditorFromHolder = function ($holder) {
+ if ($holder.hasClass('note-air-editor')) {
+ return $holder;
+ } else if ($holder.next().hasClass('note-editor')) {
+ return $holder.next();
+ } else {
+ return $();
+ }
+ };
+
+ /**
+ * create summernote layout
+ *
+ * @param {jQuery} $holder
+ * @param {Object} options
+ */
+ this.createLayout = function ($holder, options) {
+ if (options.airMode) {
+ this.createLayoutByAirMode($holder, options);
+ } else {
+ this.createLayoutByFrame($holder, options);
+ }
+ };
+
+ /**
+ * returns layoutInfo from holder
+ *
+ * @param {jQuery} $holder - placeholder
+ * @return {Object}
+ */
+ this.layoutInfoFromHolder = function ($holder) {
+ var $editor = this.noteEditorFromHolder($holder);
+ if (!$editor.length) {
+ return;
+ }
+
+ // connect $holder to $editor
+ $editor.data('holder', $holder);
+
+ return dom.buildLayoutInfo($editor);
+ };
+
+ /**
+ * removeLayout
+ *
+ * @param {jQuery} $holder - placeholder
+ * @param {Object} layoutInfo
+ * @param {Object} options
+ *
+ */
+ this.removeLayout = function ($holder, layoutInfo, options) {
+ if (options.airMode) {
+ $holder.removeClass('note-air-editor note-editable')
+ .removeAttr('contentEditable'); // ODOO: removed id 'id contentEditable'
+
+ layoutInfo.popover().remove();
+ layoutInfo.handle().remove();
+ layoutInfo.dialog().remove();
+ } else {
+ $holder.html(layoutInfo.editable().html());
+
+ if (options.dialogsInBody) {
+ layoutInfo.dialog().remove();
+ }
+ layoutInfo.editor().remove();
+ $holder.show();
+ }
+ };
+
+ /**
+ *
+ * @return {Object}
+ * @return {function(label, options=):string} return.button {@link #tplButton function to make text button}
+ * @return {function(iconClass, options=):string} return.iconButton {@link #tplIconButton function to make icon button}
+ * @return {function(className, title=, body=, footer=):string} return.dialog {@link #tplDialog function to make dialog}
+ */
+ this.getTemplate = function () {
+ return {
+ button: tplButton,
+ iconButton: tplIconButton,
+ dialog: tplDialog,
+ dropdown: tplDropdown // ODOO: suggest upstream
+ };
+ };
+
+ /**
+ * add button information
+ *
+ * @param {String} name button name
+ * @param {Function} buttonInfo function to make button, reference to {@link #tplButton},{@link #tplIconButton}
+ */
+ this.addButtonInfo = function (name, buttonInfo) {
+ tplButtonInfo[name] = buttonInfo;
+ };
+
+ /**
+ *
+ * @param {String} name
+ * @param {Function} dialogInfo function to make dialog, reference to {@link #tplDialog}
+ */
+ this.addDialogInfo = function (name, dialogInfo) {
+ tplDialogInfo[name] = dialogInfo;
+ };
+ };
+
+ return Renderer;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/app.js b/addons/web_editor/static/lib/summernote/src/js/app.js
new file mode 100644
index 00000000..fba17afd
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/app.js
@@ -0,0 +1,42 @@
+require.config({
+ baseUrl: 'src/js',
+ paths: {
+ jquery: '//code.jquery.com/jquery-1.11.3',
+ bootstrap: '//netdna.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min',
+ summernotevideo: '/../../plugin/summernote-ext-video',
+ CodeMirror: '//cdnjs.cloudflare.com/ajax/libs/codemirror/3.20.0/codemirror',
+ CodeMirrorXml: '//cdnjs.cloudflare.com/ajax/libs/codemirror/3.20.0/mode/xml/xml.min',
+ CodeMirrorFormatting: '//cdnjs.cloudflare.com/ajax/libs/codemirror/2.36.0/formatting.min'
+ },
+ shim: {
+ bootstrap: ['jquery'],
+ CodeMirror: { exports: 'CodeMirror' },
+ CodeMirrorXml: ['CodeMirror'],
+ CodeMirrorFormatting: ['CodeMirror', 'CodeMirrorXml'],
+ summernotevideo: ['summernote']
+ },
+ packages: [{
+ name: 'summernote',
+ location: './',
+ main: 'summernote'
+ }]
+});
+
+require([
+ 'jquery', 'bootstrap', 'CodeMirrorFormatting',
+ 'summernote', 'summernotevideo'
+], function ($) {
+ // summernote
+ $('.summernote').summernote({
+ height: 300, // set editable area's height
+ focus: true, // set focus editable area after summernote loaded
+ tabsize: 2, // size of tab
+ placeholder: 'Type your message here...', // set editable area's placeholder text
+ codemirror: { // code mirror options
+ mode: 'text/html',
+ htmlMode: true,
+ lineNumbers: true,
+ theme: 'monokai'
+ }
+ });
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/core/agent.js b/addons/web_editor/static/lib/summernote/src/js/core/agent.js
new file mode 100644
index 00000000..21555a11
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/core/agent.js
@@ -0,0 +1,170 @@
+define(['jquery'], function ($) {
+ if (!Array.prototype.reduce) {
+ /**
+ * Array.prototype.reduce polyfill
+ *
+ * @param {Function} callback
+ * @param {Value} [initialValue]
+ * @return {Value}
+ *
+ * @see http://goo.gl/WNriQD
+ */
+ Array.prototype.reduce = function (callback) {
+ var t = Object(this), len = t.length >>> 0, k = 0, value;
+ if (arguments.length === 2) {
+ value = arguments[1];
+ } else {
+ while (k < len && !(k in t)) {
+ k++;
+ }
+ if (k >= len) {
+ throw new TypeError('Reduce of empty array with no initial value');
+ }
+ value = t[k++];
+ }
+ for (; k < len; k++) {
+ if (k in t) {
+ value = callback(value, t[k], k, t);
+ }
+ }
+ return value;
+ };
+ }
+
+ if ('function' !== typeof Array.prototype.filter) {
+ /**
+ * Array.prototype.filter polyfill
+ *
+ * @param {Function} func
+ * @return {Array}
+ *
+ * @see http://goo.gl/T1KFnq
+ */
+ Array.prototype.filter = function (func) {
+ var t = Object(this), len = t.length >>> 0;
+
+ var res = [];
+ var thisArg = arguments.length >= 2 ? arguments[1] : void 0;
+ for (var i = 0; i < len; i++) {
+ if (i in t) {
+ var val = t[i];
+ if (func.call(thisArg, val, i, t)) {
+ res.push(val);
+ }
+ }
+ }
+
+ return res;
+ };
+ }
+
+ if (!Array.prototype.map) {
+ /**
+ * Array.prototype.map polyfill
+ *
+ * @param {Function} callback
+ * @return {Array}
+ *
+ * @see https://goo.gl/SMWaMK
+ */
+ Array.prototype.map = function (callback, thisArg) {
+ var T, A, k;
+ if (this === null) {
+ throw new TypeError(' this is null or not defined');
+ }
+
+ var O = Object(this);
+ var len = O.length >>> 0;
+ if (typeof callback !== 'function') {
+ throw new TypeError(callback + ' is not a function');
+ }
+
+ if (arguments.length > 1) {
+ T = thisArg;
+ }
+
+ A = new Array(len);
+ k = 0;
+
+ while (k < len) {
+ var kValue, mappedValue;
+ if (k in O) {
+ kValue = O[k];
+ mappedValue = callback.call(T, kValue, k, O);
+ A[k] = mappedValue;
+ }
+ k++;
+ }
+ return A;
+ };
+ }
+
+ var isSupportAmd = typeof define === 'function' && define.amd;
+
+ /**
+ * returns whether font is installed or not.
+ *
+ * @param {String} fontName
+ * @return {Boolean}
+ */
+ var isFontInstalled = function (fontName) {
+ var testFontName = fontName === 'Comic Sans MS' ? 'Courier New' : 'Comic Sans MS';
+ var $tester = $('<div>').css({
+ position: 'absolute',
+ left: '-9999px',
+ top: '-9999px',
+ fontSize: '200px'
+ }).text('mmmmmmmmmwwwwwww').appendTo(document.body);
+
+ var originalWidth = $tester.css('fontFamily', testFontName).width();
+ var width = $tester.css('fontFamily', fontName + ',' + testFontName).width();
+
+ $tester.remove();
+
+ return originalWidth !== width;
+ };
+
+ var userAgent = navigator.userAgent;
+ var isMSIE = /MSIE|Trident/i.test(userAgent);
+ var browserVersion;
+ if (isMSIE) {
+ var matches = /MSIE (\d+[.]\d+)/.exec(userAgent);
+ if (matches) {
+ browserVersion = parseFloat(matches[1]);
+ }
+ matches = /Trident\/.*rv:([0-9]{1,}[\.0-9]{0,})/.exec(userAgent);
+ if (matches) {
+ browserVersion = parseFloat(matches[1]);
+ }
+ }
+
+ /**
+ * @class core.agent
+ *
+ * Object which check platform and agent
+ *
+ * @singleton
+ * @alternateClassName agent
+ */
+ var agent = {
+ /** @property {Boolean} [isMac=false] true if this agent is Mac */
+ isMac: navigator.appVersion.indexOf('Mac') > -1,
+ /** @property {Boolean} [isMSIE=false] true if this agent is a Internet Explorer */
+ isMSIE: isMSIE,
+ /** @property {Boolean} [isFF=false] true if this agent is a Firefox */
+ isFF: /firefox/i.test(userAgent),
+ isWebkit: /webkit/i.test(userAgent),
+ /** @property {Boolean} [isSafari=false] true if this agent is a Safari */
+ isSafari: /safari/i.test(userAgent),
+ /** @property {Float} browserVersion current browser version */
+ browserVersion: browserVersion,
+ /** @property {String} jqueryVersion current jQuery version string */
+ jqueryVersion: parseFloat($.fn.jquery),
+ isSupportAmd: isSupportAmd,
+ hasCodeMirror: isSupportAmd ? require.specified('CodeMirror') : !!window.CodeMirror,
+ isFontInstalled: isFontInstalled,
+ isW3CRangeSupport: !!document.createRange
+ };
+
+ return agent;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/core/async.js b/addons/web_editor/static/lib/summernote/src/js/core/async.js
new file mode 100644
index 00000000..7bc23586
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/core/async.js
@@ -0,0 +1,68 @@
+define('summernote/core/async', function () {
+ /**
+ * @class core.async
+ *
+ * Async functions which returns `Promise`
+ *
+ * @singleton
+ * @alternateClassName async
+ */
+ var async = (function () {
+ /**
+ * @method readFileAsDataURL
+ *
+ * read contents of file as representing URL
+ *
+ * @param {File} file
+ * @return {Promise} - then: sDataUrl
+ */
+ var readFileAsDataURL = function (file) {
+ return $.Deferred(function (deferred) {
+ $.extend(new FileReader(), {
+ onload: function (e) {
+ var sDataURL = e.target.result;
+ deferred.resolve(sDataURL);
+ },
+ onerror: function () {
+ deferred.reject(this);
+ }
+ }).readAsDataURL(file);
+ }).promise();
+ };
+
+ /**
+ * @method createImage
+ *
+ * create `<image>` from url string
+ *
+ * @param {String} sUrl
+ * @param {String} filename
+ * @return {Promise} - then: $image
+ */
+ var createImage = function (sUrl, filename) {
+ return $.Deferred(function (deferred) {
+ var $img = $('<img>');
+
+ $img.one('load', function () {
+ $img.off('error abort');
+ deferred.resolve($img);
+ }).one('error abort', function () {
+ $img.off('load').detach();
+ deferred.reject($img);
+ }).css({
+ display: 'none'
+ }).appendTo(document.body).attr({
+ 'src': sUrl,
+ 'data-filename': filename
+ });
+ }).promise();
+ };
+
+ return {
+ readFileAsDataURL: readFileAsDataURL,
+ createImage: createImage
+ };
+ })();
+
+ return async;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/core/dom.js b/addons/web_editor/static/lib/summernote/src/js/core/dom.js
new file mode 100644
index 00000000..f6a1960a
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/core/dom.js
@@ -0,0 +1,1120 @@
+define([
+ 'summernote/core/func',
+ 'summernote/core/list',
+ 'summernote/core/agent'
+], function (func, list, agent) {
+
+ var NBSP_CHAR = String.fromCharCode(160);
+ var ZERO_WIDTH_NBSP_CHAR = '\ufeff';
+
+ /**
+ * @class core.dom
+ *
+ * Dom functions
+ *
+ * @singleton
+ * @alternateClassName dom
+ */
+ var dom = (function () {
+ /**
+ * @method isEditable
+ *
+ * returns whether node is `note-editable` or not.
+ *
+ * @param {Node} node
+ * @return {Boolean}
+ */
+ var isEditable = function (node) {
+ return node && $(node).hasClass('note-editable');
+ };
+
+ /**
+ * @method isControlSizing
+ *
+ * returns whether node is `note-control-sizing` or not.
+ *
+ * @param {Node} node
+ * @return {Boolean}
+ */
+ var isControlSizing = function (node) {
+ return node && $(node).hasClass('note-control-sizing');
+ };
+
+ /**
+ * @method buildLayoutInfo
+ *
+ * build layoutInfo from $editor(.note-editor)
+ *
+ * @param {jQuery} $editor
+ * @return {Object}
+ * @return {Function} return.editor
+ * @return {Node} return.dropzone
+ * @return {Node} return.toolbar
+ * @return {Node} return.editable
+ * @return {Node} return.codable
+ * @return {Node} return.popover
+ * @return {Node} return.handle
+ * @return {Node} return.dialog
+ */
+ var buildLayoutInfo = function ($editor) {
+ var makeFinder;
+
+ // air mode
+ if ($editor.hasClass('note-air-editor')) {
+ // ODOO: editor on [data-note-id] attribute
+ // var id = list.last($editor.attr('id').split('-'));
+ var id = list.last($editor.attr('data-note-id').split('-'));
+ makeFinder = function (sIdPrefix) {
+ return function () { return $(sIdPrefix + id); };
+ };
+
+ return {
+ editor: function () { return $editor; },
+ holder : function () { return $editor.data('holder'); },
+ editable: function () { return $editor; },
+ popover: makeFinder('#note-popover-'),
+ handle: makeFinder('#note-handle-'),
+ dialog: makeFinder('#note-dialog-')
+ };
+
+ // frame mode
+ } else {
+ makeFinder = function (className, $base) {
+ $base = $base || $editor;
+ return function () { return $base.find(className); };
+ };
+
+ var options = $editor.data('options');
+ var $dialogHolder = (options && options.dialogsInBody) ? $(document.body) : null;
+
+ return {
+ editor: function () { return $editor; },
+ holder : function () { return $editor.data('holder'); },
+ dropzone: makeFinder('.note-dropzone'),
+ toolbar: makeFinder('.note-toolbar'),
+ editable: makeFinder('.note-editable'),
+ codable: makeFinder('.note-codable'),
+ statusbar: makeFinder('.note-statusbar'),
+ popover: makeFinder('.note-popover'),
+ handle: makeFinder('.note-handle'),
+ dialog: makeFinder('.note-dialog', $dialogHolder)
+ };
+ }
+ };
+
+ /**
+ * returns makeLayoutInfo from editor's descendant node.
+ *
+ * @private
+ * @param {Node} descendant
+ * @return {Object}
+ */
+ var makeLayoutInfo = function (descendant) {
+ var $target = $(descendant).closest('.note-editor, .note-air-editor, .note-air-layout');
+
+ if (!$target.length) {
+ return null;
+ }
+
+ var $editor;
+ if ($target.is('.note-editor, .note-air-editor')) {
+ $editor = $target;
+ } else {
+ // ODOO: editor on [data-note-id] attribute
+ // $editor = $('#note-editor-' + list.last($target.attr('id').split('-')));
+ $editor = $('[data-note-id="' + list.last($target.attr('id').split('-')) + '"]');
+ }
+
+ return buildLayoutInfo($editor);
+ };
+
+ /**
+ * @method makePredByNodeName
+ *
+ * returns predicate which judge whether nodeName is same
+ *
+ * @param {String} nodeName
+ * @return {Function}
+ */
+ var makePredByNodeName = function (nodeName) {
+ nodeName = nodeName.toUpperCase();
+ return function (node) {
+ return node && node.nodeName.toUpperCase() === nodeName;
+ };
+ };
+
+ /**
+ * @method isText
+ *
+ *
+ *
+ * @param {Node} node
+ * @return {Boolean} true if node's type is text(3)
+ */
+ var isText = function (node) {
+ return node && node.nodeType === 3;
+ };
+
+ /**
+ * ex) br, col, embed, hr, img, input, ...
+ * @see http://www.w3.org/html/wg/drafts/html/master/syntax.html#void-elements
+ */
+ var isVoid = function (node) {
+ return node && /^BR|^IMG|^HR|^IFRAME|^BUTTON/.test(node.nodeName.toUpperCase());
+ };
+
+ var isPara = function (node) {
+ if (isEditable(node)) {
+ return false;
+ }
+
+ // Chrome(v31.0), FF(v25.0.1) use DIV for paragraph
+ return node && /^DIV|^P|^LI|^H[1-7]/.test(node.nodeName.toUpperCase());
+ };
+
+ var isLi = makePredByNodeName('LI');
+
+ var isPurePara = function (node) {
+ return isPara(node) && !isLi(node);
+ };
+
+ var isTable = makePredByNodeName('TABLE');
+
+ var isInline = function (node) {
+ return !isBodyContainer(node) &&
+ !isList(node) &&
+ !isHr(node) &&
+ !isPara(node) &&
+ !isTable(node) &&
+ !isBlockquote(node);
+ };
+
+ var isList = function (node) {
+ return node && /^UL|^OL/.test(node.nodeName.toUpperCase());
+ };
+
+ var isHr = makePredByNodeName('HR');
+
+ var isCell = function (node) {
+ return node && /^TD|^TH/.test(node.nodeName.toUpperCase());
+ };
+
+ var isBlockquote = makePredByNodeName('BLOCKQUOTE');
+
+ var isBodyContainer = function (node) {
+ return isCell(node) || isBlockquote(node) || isEditable(node);
+ };
+
+ var isAnchor = makePredByNodeName('A');
+
+ var isParaInline = function (node) {
+ return isInline(node) && !!ancestor(node, isPara);
+ };
+
+ var isBodyInline = function (node) {
+ return isInline(node) && !ancestor(node, isPara);
+ };
+
+ var isBody = makePredByNodeName('BODY');
+
+ /**
+ * returns whether nodeB is closest sibling of nodeA
+ *
+ * @param {Node} nodeA
+ * @param {Node} nodeB
+ * @return {Boolean}
+ */
+ var isClosestSibling = function (nodeA, nodeB) {
+ return nodeA.nextSibling === nodeB ||
+ nodeA.previousSibling === nodeB;
+ };
+
+ /**
+ * returns array of closest siblings with node
+ *
+ * @param {Node} node
+ * @param {function} [pred] - predicate function
+ * @return {Node[]}
+ */
+ var withClosestSiblings = function (node, pred) {
+ pred = pred || func.ok;
+
+ var siblings = [];
+ if (node.previousSibling && pred(node.previousSibling)) {
+ siblings.push(node.previousSibling);
+ }
+ siblings.push(node);
+ if (node.nextSibling && pred(node.nextSibling)) {
+ siblings.push(node.nextSibling);
+ }
+ return siblings;
+ };
+
+ /**
+ * blank HTML for cursor position
+ * - [workaround] old IE only works with &nbsp;
+ * - [workaround] IE11 and other browser works with bogus br
+ */
+ var blankHTML = agent.isMSIE && agent.browserVersion < 11 ? '&nbsp;' : '<br>';
+
+ /**
+ * @method nodeLength
+ *
+ * returns #text's text size or element's childNodes size
+ *
+ * @param {Node} node
+ */
+ var nodeLength = function (node) {
+ if (isText(node)) {
+ return node.nodeValue.length;
+ }
+
+ return node.childNodes.length;
+ };
+
+ /**
+ * returns whether node is empty or not.
+ *
+ * @param {Node} node
+ * @return {Boolean}
+ */
+ var isEmpty = function (node) {
+ var len = nodeLength(node);
+
+ if (len === 0) {
+ return true;
+ } else if (!isText(node) && len === 1 && node.innerHTML === blankHTML) {
+ // ex) <p><br></p>, <span><br></span>
+ return true;
+ } else if (list.all(node.childNodes, isText) && node.innerHTML === '') {
+ // ex) <p></p>, <span></span>
+ return true;
+ }
+
+ return false;
+ };
+
+ /**
+ * padding blankHTML if node is empty (for cursor position)
+ */
+ var paddingBlankHTML = function (node) {
+ if (!isVoid(node) && !nodeLength(node)) {
+ node.innerHTML = blankHTML;
+ }
+ };
+
+ /**
+ * find nearest ancestor predicate hit
+ *
+ * @param {Node} node
+ * @param {Function} pred - predicate function
+ */
+ var ancestor = function (node, pred) {
+ while (node) {
+ if (pred(node)) { return node; }
+ if (isEditable(node)) { break; }
+
+ node = node.parentNode;
+ }
+ return null;
+ };
+
+ /**
+ * find nearest ancestor only single child blood line and predicate hit
+ *
+ * @param {Node} node
+ * @param {Function} pred - predicate function
+ */
+ var singleChildAncestor = function (node, pred) {
+ node = node.parentNode;
+
+ while (node) {
+ if (nodeLength(node) !== 1) { break; }
+ if (pred(node)) { return node; }
+ if (isEditable(node)) { break; }
+
+ node = node.parentNode;
+ }
+ return null;
+ };
+
+ /**
+ * returns new array of ancestor nodes (until predicate hit).
+ *
+ * @param {Node} node
+ * @param {Function} [optional] pred - predicate function
+ */
+ var listAncestor = function (node, pred) {
+ pred = pred || func.fail;
+
+ var ancestors = [];
+ ancestor(node, function (el) {
+ if (!isEditable(el)) {
+ ancestors.push(el);
+ }
+
+ return pred(el);
+ });
+ return ancestors;
+ };
+
+ /**
+ * find farthest ancestor predicate hit
+ */
+ var lastAncestor = function (node, pred) {
+ var ancestors = listAncestor(node);
+ return list.last(ancestors.filter(pred));
+ };
+
+ /**
+ * returns common ancestor node between two nodes.
+ *
+ * @param {Node} nodeA
+ * @param {Node} nodeB
+ */
+ var commonAncestor = function (nodeA, nodeB) {
+ var ancestors = listAncestor(nodeA);
+ for (var n = nodeB; n; n = n.parentNode) {
+ if ($.inArray(n, ancestors) > -1) { return n; }
+ }
+ return null; // difference document area
+ };
+
+ /**
+ * listing all previous siblings (until predicate hit).
+ *
+ * @param {Node} node
+ * @param {Function} [optional] pred - predicate function
+ */
+ var listPrev = function (node, pred) {
+ pred = pred || func.fail;
+
+ var nodes = [];
+ while (node) {
+ if (pred(node)) { break; }
+ nodes.push(node);
+ node = node.previousSibling;
+ }
+ return nodes;
+ };
+
+ /**
+ * listing next siblings (until predicate hit).
+ *
+ * @param {Node} node
+ * @param {Function} [pred] - predicate function
+ */
+ var listNext = function (node, pred) {
+ pred = pred || func.fail;
+
+ var nodes = [];
+ while (node) {
+ if (pred(node)) { break; }
+ nodes.push(node);
+ node = node.nextSibling;
+ }
+ return nodes;
+ };
+
+ /**
+ * listing descendant nodes
+ *
+ * @param {Node} node
+ * @param {Function} [pred] - predicate function
+ */
+ var listDescendant = function (node, pred) {
+ var descendents = [];
+ pred = pred || func.ok;
+
+ // start DFS(depth first search) with node
+ (function fnWalk(current) {
+ if (node !== current && pred(current)) {
+ descendents.push(current);
+ }
+ for (var idx = 0, len = current.childNodes.length; idx < len; idx++) {
+ fnWalk(current.childNodes[idx]);
+ }
+ })(node);
+
+ return descendents;
+ };
+
+ /**
+ * wrap node with new tag.
+ *
+ * @param {Node} node
+ * @param {Node} tagName of wrapper
+ * @return {Node} - wrapper
+ */
+ var wrap = function (node, wrapperName) {
+ var parent = node.parentNode;
+ var wrapper = $('<' + wrapperName + '>')[0];
+
+ parent.insertBefore(wrapper, node);
+ wrapper.appendChild(node);
+
+ return wrapper;
+ };
+
+ /**
+ * insert node after preceding
+ *
+ * @param {Node} node
+ * @param {Node} preceding - predicate function
+ */
+ var insertAfter = function (node, preceding) {
+ var next = preceding.nextSibling, parent = preceding.parentNode;
+ if (next) {
+ parent.insertBefore(node, next);
+ } else {
+ parent.appendChild(node);
+ }
+ return node;
+ };
+
+ /**
+ * append elements.
+ *
+ * @param {Node} node
+ * @param {Collection} aChild
+ */
+ var appendChildNodes = function (node, aChild) {
+ $.each(aChild, function (idx, child) {
+ node.appendChild(child);
+ });
+ return node;
+ };
+
+ /**
+ * returns whether boundaryPoint is left edge or not.
+ *
+ * @param {BoundaryPoint} point
+ * @return {Boolean}
+ */
+ var isLeftEdgePoint = function (point) {
+ return point.offset === 0;
+ };
+
+ /**
+ * returns whether boundaryPoint is right edge or not.
+ *
+ * @param {BoundaryPoint} point
+ * @return {Boolean}
+ */
+ var isRightEdgePoint = function (point) {
+ return point.offset === nodeLength(point.node);
+ };
+
+ /**
+ * returns whether boundaryPoint is edge or not.
+ *
+ * @param {BoundaryPoint} point
+ * @return {Boolean}
+ */
+ var isEdgePoint = function (point) {
+ return isLeftEdgePoint(point) || isRightEdgePoint(point);
+ };
+
+ /**
+ * returns wheter node is left edge of ancestor or not.
+ *
+ * @param {Node} node
+ * @param {Node} ancestor
+ * @return {Boolean}
+ */
+ var isLeftEdgeOf = function (node, ancestor) {
+ while (node && node !== ancestor) {
+ if (position(node) !== 0) {
+ return false;
+ }
+ node = node.parentNode;
+ }
+
+ return true;
+ };
+
+ /**
+ * returns whether node is right edge of ancestor or not.
+ *
+ * @param {Node} node
+ * @param {Node} ancestor
+ * @return {Boolean}
+ */
+ var isRightEdgeOf = function (node, ancestor) {
+ while (node && node !== ancestor) {
+ if (position(node) !== nodeLength(node.parentNode) - 1) {
+ return false;
+ }
+ node = node.parentNode;
+ }
+
+ return true;
+ };
+
+ /**
+ * returns whether point is left edge of ancestor or not.
+ * @param {BoundaryPoint} point
+ * @param {Node} ancestor
+ * @return {Boolean}
+ */
+ var isLeftEdgePointOf = function (point, ancestor) {
+ return isLeftEdgePoint(point) && isLeftEdgeOf(point.node, ancestor);
+ };
+
+ /**
+ * returns whether point is right edge of ancestor or not.
+ * @param {BoundaryPoint} point
+ * @param {Node} ancestor
+ * @return {Boolean}
+ */
+ var isRightEdgePointOf = function (point, ancestor) {
+ return isRightEdgePoint(point) && isRightEdgeOf(point.node, ancestor);
+ };
+
+ /**
+ * returns offset from parent.
+ *
+ * @param {Node} node
+ */
+ var position = function (node) {
+ var offset = 0;
+ while ((node = node.previousSibling)) {
+ offset += 1;
+ }
+ return offset;
+ };
+
+ var hasChildren = function (node) {
+ return !!(node && node.childNodes && node.childNodes.length);
+ };
+
+ /**
+ * returns previous boundaryPoint
+ *
+ * @param {BoundaryPoint} point
+ * @param {Boolean} isSkipInnerOffset
+ * @return {BoundaryPoint}
+ */
+ var prevPoint = function (point, isSkipInnerOffset) {
+ var node, offset;
+
+ if (point.offset === 0) {
+ if (isEditable(point.node)) {
+ return null;
+ }
+
+ node = point.node.parentNode;
+ offset = position(point.node);
+ } else if (hasChildren(point.node)) {
+ node = point.node.childNodes[point.offset - 1];
+ offset = nodeLength(node);
+ } else {
+ node = point.node;
+ offset = isSkipInnerOffset ? 0 : point.offset - 1;
+ }
+
+ return {
+ node: node,
+ offset: offset
+ };
+ };
+
+ /**
+ * returns next boundaryPoint
+ *
+ * @param {BoundaryPoint} point
+ * @param {Boolean} isSkipInnerOffset
+ * @return {BoundaryPoint}
+ */
+ var nextPoint = function (point, isSkipInnerOffset) {
+ var node, offset;
+
+ if (nodeLength(point.node) === point.offset) {
+ if (isEditable(point.node)) {
+ return null;
+ }
+
+ node = point.node.parentNode;
+ offset = position(point.node) + 1;
+ } else if (hasChildren(point.node)) {
+ node = point.node.childNodes[point.offset];
+ offset = 0;
+ } else {
+ node = point.node;
+ offset = isSkipInnerOffset ? nodeLength(point.node) : point.offset + 1;
+ }
+
+ return {
+ node: node,
+ offset: offset
+ };
+ };
+
+ /**
+ * returns whether pointA and pointB is same or not.
+ *
+ * @param {BoundaryPoint} pointA
+ * @param {BoundaryPoint} pointB
+ * @return {Boolean}
+ */
+ var isSamePoint = function (pointA, pointB) {
+ return pointA.node === pointB.node && pointA.offset === pointB.offset;
+ };
+
+ /**
+ * returns whether point is visible (can set cursor) or not.
+ *
+ * @param {BoundaryPoint} point
+ * @return {Boolean}
+ */
+ var isVisiblePoint = function (point) {
+ if (isText(point.node) || !hasChildren(point.node) || isEmpty(point.node)) {
+ return true;
+ }
+
+ var leftNode = point.node.childNodes[point.offset - 1];
+ var rightNode = point.node.childNodes[point.offset];
+ if ((!leftNode || isVoid(leftNode)) && (!rightNode || isVoid(rightNode))) {
+ return true;
+ }
+
+ return false;
+ };
+
+ /**
+ * @method prevPointUtil
+ *
+ * @param {BoundaryPoint} point
+ * @param {Function} pred
+ * @return {BoundaryPoint}
+ */
+ var prevPointUntil = function (point, pred) {
+ while (point) {
+ if (pred(point)) {
+ return point;
+ }
+
+ point = prevPoint(point);
+ }
+
+ return null;
+ };
+
+ /**
+ * @method nextPointUntil
+ *
+ * @param {BoundaryPoint} point
+ * @param {Function} pred
+ * @return {BoundaryPoint}
+ */
+ var nextPointUntil = function (point, pred) {
+ while (point) {
+ if (pred(point)) {
+ return point;
+ }
+
+ point = nextPoint(point);
+ }
+
+ return null;
+ };
+
+ /**
+ * returns whether point has character or not.
+ *
+ * @param {Point} point
+ * @return {Boolean}
+ */
+ var isCharPoint = function (point) {
+ if (!isText(point.node)) {
+ return false;
+ }
+
+ var ch = point.node.nodeValue.charAt(point.offset - 1);
+ return ch && (ch !== ' ' && ch !== NBSP_CHAR);
+ };
+
+ /**
+ * @method walkPoint
+ *
+ * @param {BoundaryPoint} startPoint
+ * @param {BoundaryPoint} endPoint
+ * @param {Function} handler
+ * @param {Boolean} isSkipInnerOffset
+ */
+ var walkPoint = function (startPoint, endPoint, handler, isSkipInnerOffset) {
+ var point = startPoint;
+
+ while (point) {
+ handler(point);
+
+ if (isSamePoint(point, endPoint)) {
+ break;
+ }
+
+ var isSkipOffset = isSkipInnerOffset &&
+ startPoint.node !== point.node &&
+ endPoint.node !== point.node;
+ point = nextPoint(point, isSkipOffset);
+ }
+ };
+
+ /**
+ * @method makeOffsetPath
+ *
+ * return offsetPath(array of offset) from ancestor
+ *
+ * @param {Node} ancestor - ancestor node
+ * @param {Node} node
+ */
+ var makeOffsetPath = function (ancestor, node) {
+ var ancestors = listAncestor(node, func.eq(ancestor));
+ return ancestors.map(position).reverse();
+ };
+
+ /**
+ * @method fromOffsetPath
+ *
+ * return element from offsetPath(array of offset)
+ *
+ * @param {Node} ancestor - ancestor node
+ * @param {array} offsets - offsetPath
+ */
+ var fromOffsetPath = function (ancestor, offsets) {
+ var current = ancestor;
+ for (var i = 0, len = offsets.length; i < len; i++) {
+ if (current.childNodes.length <= offsets[i]) {
+ current = current.childNodes[current.childNodes.length - 1];
+ } else {
+ current = current.childNodes[offsets[i]];
+ }
+ }
+ return current;
+ };
+
+ /**
+ * @method splitNode
+ *
+ * split element or #text
+ *
+ * @param {BoundaryPoint} point
+ * @param {Object} [options]
+ * @param {Boolean} [options.isSkipPaddingBlankHTML] - default: false
+ * @param {Boolean} [options.isNotSplitEdgePoint] - default: false
+ * @return {Node} right node of boundaryPoint
+ */
+ var splitNode = function (point, options) {
+ var isSkipPaddingBlankHTML = options && options.isSkipPaddingBlankHTML;
+ var isNotSplitEdgePoint = options && options.isNotSplitEdgePoint;
+
+ // edge case
+ if (isEdgePoint(point) && (isText(point.node) || isNotSplitEdgePoint)) {
+ if (isLeftEdgePoint(point)) {
+ return point.node;
+ } else if (isRightEdgePoint(point)) {
+ return point.node.nextSibling;
+ }
+ }
+
+ // split #text
+ if (isText(point.node)) {
+ return point.node.splitText(point.offset);
+ } else {
+ var childNode = point.node.childNodes[point.offset];
+ var clone = insertAfter(point.node.cloneNode(false), point.node);
+ appendChildNodes(clone, listNext(childNode));
+
+ if (!isSkipPaddingBlankHTML) {
+ paddingBlankHTML(point.node);
+ paddingBlankHTML(clone);
+ }
+
+ return clone;
+ }
+ };
+
+ /**
+ * @method splitTree
+ *
+ * split tree by point
+ *
+ * @param {Node} root - split root
+ * @param {BoundaryPoint} point
+ * @param {Object} [options]
+ * @param {Boolean} [options.isSkipPaddingBlankHTML] - default: false
+ * @param {Boolean} [options.isNotSplitEdgePoint] - default: false
+ * @return {Node} right node of boundaryPoint
+ */
+ var splitTree = function (root, point, options) {
+ // ex) [#text, <span>, <p>]
+ var ancestors = listAncestor(point.node, func.eq(root));
+
+ if (!ancestors.length) {
+ return null;
+ } else if (ancestors.length === 1) {
+ return splitNode(point, options);
+ }
+
+ return ancestors.reduce(function (node, parent) {
+ if (node === point.node) {
+ node = splitNode(point, options);
+ }
+
+ return splitNode({
+ node: parent,
+ offset: node ? dom.position(node) : nodeLength(parent)
+ }, options);
+ });
+ };
+
+ /**
+ * split point
+ *
+ * @param {Point} point
+ * @param {Boolean} isInline
+ * @return {Object}
+ */
+ var splitPoint = function (point, isInline) {
+ // find splitRoot, container
+ // - inline: splitRoot is a child of paragraph
+ // - block: splitRoot is a child of bodyContainer
+ var pred = isInline ? isPara : isBodyContainer;
+ var ancestors = listAncestor(point.node, pred);
+ var topAncestor = list.last(ancestors) || point.node;
+
+ var splitRoot, container;
+ if (pred(topAncestor)) {
+ splitRoot = ancestors[ancestors.length - 2];
+ container = topAncestor;
+ } else {
+ splitRoot = topAncestor;
+ container = splitRoot.parentNode;
+ }
+
+ // if splitRoot is exists, split with splitTree
+ var pivot = splitRoot && splitTree(splitRoot, point, {
+ isSkipPaddingBlankHTML: isInline,
+ isNotSplitEdgePoint: isInline
+ });
+
+ // if container is point.node, find pivot with point.offset
+ if (!pivot && container === point.node) {
+ pivot = point.node.childNodes[point.offset];
+ }
+
+ return {
+ rightNode: pivot,
+ container: container
+ };
+ };
+
+ var create = function (nodeName) {
+ return document.createElement(nodeName);
+ };
+
+ var createText = function (text) {
+ return document.createTextNode(text);
+ };
+
+ /**
+ * @method remove
+ *
+ * remove node, (isRemoveChild: remove child or not)
+ *
+ * @param {Node} node
+ * @param {Boolean} isRemoveChild
+ */
+ var remove = function (node, isRemoveChild) {
+ if (!node || !node.parentNode) { return; }
+ if (node.removeNode) { return node.removeNode(isRemoveChild); }
+
+ var parent = node.parentNode;
+ if (!isRemoveChild) {
+ var nodes = [];
+ var i, len;
+ for (i = 0, len = node.childNodes.length; i < len; i++) {
+ nodes.push(node.childNodes[i]);
+ }
+
+ for (i = 0, len = nodes.length; i < len; i++) {
+ parent.insertBefore(nodes[i], node);
+ }
+ }
+
+ parent.removeChild(node);
+ };
+
+ /**
+ * @method removeWhile
+ *
+ * @param {Node} node
+ * @param {Function} pred
+ */
+ var removeWhile = function (node, pred) {
+ while (node) {
+ if (isEditable(node) || !pred(node)) {
+ break;
+ }
+
+ var parent = node.parentNode;
+ remove(node);
+ node = parent;
+ }
+ };
+
+ /**
+ * @method replace
+ *
+ * replace node with provided nodeName
+ *
+ * @param {Node} node
+ * @param {String} nodeName
+ * @return {Node} - new node
+ */
+ var replace = function (node, nodeName) {
+ if (node.nodeName.toUpperCase() === nodeName.toUpperCase()) {
+ return node;
+ }
+
+ var newNode = create(nodeName);
+
+ if (node.style.cssText) {
+ newNode.style.cssText = node.style.cssText;
+ }
+
+ appendChildNodes(newNode, list.from(node.childNodes));
+ insertAfter(newNode, node);
+ remove(node);
+
+ return newNode;
+ };
+
+ var isTextarea = makePredByNodeName('TEXTAREA');
+
+ /**
+ * @param {jQuery} $node
+ * @param {Boolean} [stripLinebreaks] - default: false
+ */
+ var value = function ($node, stripLinebreaks) {
+ var val = isTextarea($node[0]) ? $node.val() : $node.html();
+ if (stripLinebreaks) {
+ return val.replace(/[\n\r]/g, '');
+ }
+ return val;
+ };
+
+ /**
+ * @method html
+ *
+ * get the HTML contents of node
+ *
+ * @param {jQuery} $node
+ * @param {Boolean} [isNewlineOnBlock]
+ */
+ var html = function ($node, isNewlineOnBlock) {
+ var markup = value($node);
+
+ if (isNewlineOnBlock) {
+ var regexTag = /<(\/?)(\b(?!!)[^>\s]*)(.*?)(\s*\/?>)/g;
+ markup = markup.replace(regexTag, function (match, endSlash, name) {
+ name = name.toUpperCase();
+ var isEndOfInlineContainer = /^DIV|^TD|^TH|^P|^LI|^H[1-7]/.test(name) &&
+ !!endSlash;
+ var isBlockNode = /^BLOCKQUOTE|^TABLE|^TBODY|^TR|^HR|^UL|^OL/.test(name);
+
+ return match + ((isEndOfInlineContainer || isBlockNode) ? '\n' : '');
+ });
+ markup = $.trim(markup);
+ }
+
+ return markup;
+ };
+
+ return {
+ /** @property {String} NBSP_CHAR */
+ NBSP_CHAR: NBSP_CHAR,
+ /** @property {String} ZERO_WIDTH_NBSP_CHAR */
+ ZERO_WIDTH_NBSP_CHAR: ZERO_WIDTH_NBSP_CHAR,
+ /** @property {String} blank */
+ blank: blankHTML,
+ /** @property {String} emptyPara */
+ emptyPara: '<p>' + blankHTML + '</p>',
+ makePredByNodeName: makePredByNodeName,
+ isEditable: isEditable,
+ isControlSizing: isControlSizing,
+ buildLayoutInfo: buildLayoutInfo,
+ makeLayoutInfo: makeLayoutInfo,
+ isText: isText,
+ isVoid: isVoid,
+ isPara: isPara,
+ isPurePara: isPurePara,
+ isInline: isInline,
+ isBlock: func.not(isInline),
+ isBodyInline: isBodyInline,
+ isBody: isBody,
+ isParaInline: isParaInline,
+ isList: isList,
+ isTable: isTable,
+ isCell: isCell,
+ isBlockquote: isBlockquote,
+ isBodyContainer: isBodyContainer,
+ isAnchor: isAnchor,
+ isDiv: makePredByNodeName('DIV'),
+ isLi: isLi,
+ isBR: makePredByNodeName('BR'),
+ isSpan: makePredByNodeName('SPAN'),
+ isB: makePredByNodeName('B'),
+ isU: makePredByNodeName('U'),
+ isS: makePredByNodeName('S'),
+ isI: makePredByNodeName('I'),
+ isImg: makePredByNodeName('IMG'),
+ isTextarea: isTextarea,
+ isEmpty: isEmpty,
+ isEmptyAnchor: func.and(isAnchor, isEmpty),
+ isClosestSibling: isClosestSibling,
+ withClosestSiblings: withClosestSiblings,
+ nodeLength: nodeLength,
+ isLeftEdgePoint: isLeftEdgePoint,
+ isRightEdgePoint: isRightEdgePoint,
+ isEdgePoint: isEdgePoint,
+ isLeftEdgeOf: isLeftEdgeOf,
+ isRightEdgeOf: isRightEdgeOf,
+ isLeftEdgePointOf: isLeftEdgePointOf,
+ isRightEdgePointOf: isRightEdgePointOf,
+ prevPoint: prevPoint,
+ nextPoint: nextPoint,
+ isSamePoint: isSamePoint,
+ isVisiblePoint: isVisiblePoint,
+ prevPointUntil: prevPointUntil,
+ nextPointUntil: nextPointUntil,
+ isCharPoint: isCharPoint,
+ walkPoint: walkPoint,
+ ancestor: ancestor,
+ singleChildAncestor: singleChildAncestor,
+ listAncestor: listAncestor,
+ lastAncestor: lastAncestor,
+ listNext: listNext,
+ listPrev: listPrev,
+ listDescendant: listDescendant,
+ commonAncestor: commonAncestor,
+ wrap: wrap,
+ insertAfter: insertAfter,
+ appendChildNodes: appendChildNodes,
+ position: position,
+ hasChildren: hasChildren,
+ makeOffsetPath: makeOffsetPath,
+ fromOffsetPath: fromOffsetPath,
+ splitTree: splitTree,
+ splitPoint: splitPoint,
+ create: create,
+ createText: createText,
+ remove: remove,
+ removeWhile: removeWhile,
+ replace: replace,
+ html: html,
+ value: value
+ };
+ })();
+
+ return dom;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/core/func.js b/addons/web_editor/static/lib/summernote/src/js/core/func.js
new file mode 100644
index 00000000..11bfdf54
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/core/func.js
@@ -0,0 +1,130 @@
+define('summernote/core/func', function () {
+ /**
+ * @class core.func
+ *
+ * func utils (for high-order func's arg)
+ *
+ * @singleton
+ * @alternateClassName func
+ */
+ var func = (function () {
+ var eq = function (itemA) {
+ return function (itemB) {
+ return itemA === itemB;
+ };
+ };
+
+ var eq2 = function (itemA, itemB) {
+ return itemA === itemB;
+ };
+
+ var peq2 = function (propName) {
+ return function (itemA, itemB) {
+ return itemA[propName] === itemB[propName];
+ };
+ };
+
+ var ok = function () {
+ return true;
+ };
+
+ var fail = function () {
+ return false;
+ };
+
+ var not = function (f) {
+ return function () {
+ return !f.apply(f, arguments);
+ };
+ };
+
+ var and = function (fA, fB) {
+ return function (item) {
+ return fA(item) && fB(item);
+ };
+ };
+
+ var self = function (a) {
+ return a;
+ };
+
+ var idCounter = 0;
+
+ /**
+ * generate a globally-unique id
+ *
+ * @param {String} [prefix]
+ */
+ var uniqueId = function (prefix) {
+ var id = ++idCounter + '';
+ return prefix ? prefix + id : id;
+ };
+
+ /**
+ * returns bnd (bounds) from rect
+ *
+ * - IE Compatability Issue: http://goo.gl/sRLOAo
+ * - Scroll Issue: http://goo.gl/sNjUc
+ *
+ * @param {Rect} rect
+ * @return {Object} bounds
+ * @return {Number} bounds.top
+ * @return {Number} bounds.left
+ * @return {Number} bounds.width
+ * @return {Number} bounds.height
+ */
+ var rect2bnd = function (rect) {
+ var $document = $(document);
+ return {
+ top: rect.top + $document.scrollTop(),
+ left: rect.left + $document.scrollLeft(),
+ width: rect.right - rect.left,
+ height: rect.bottom - rect.top
+ };
+ };
+
+ /**
+ * returns a copy of the object where the keys have become the values and the values the keys.
+ * @param {Object} obj
+ * @return {Object}
+ */
+ var invertObject = function (obj) {
+ var inverted = {};
+ for (var key in obj) {
+ if (obj.hasOwnProperty(key)) {
+ inverted[obj[key]] = key;
+ }
+ }
+ return inverted;
+ };
+
+ /**
+ * @param {String} namespace
+ * @param {String} [prefix]
+ * @return {String}
+ */
+ var namespaceToCamel = function (namespace, prefix) {
+ prefix = prefix || '';
+ return prefix + namespace.split('.').map(function (name) {
+ return name.substring(0, 1).toUpperCase() + name.substring(1);
+ }).join('');
+ };
+
+ return {
+ eq: eq,
+ eq2: eq2,
+ peq2: peq2,
+ ok: ok,
+ fail: fail,
+ self: self,
+ not: not,
+ and: and,
+ uniqueId: uniqueId,
+ rect2bnd: rect2bnd,
+ invertObject: invertObject,
+ namespaceToCamel: namespaceToCamel
+ };
+ })();
+
+ return func;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/core/key.js b/addons/web_editor/static/lib/summernote/src/js/core/key.js
new file mode 100644
index 00000000..a17b2756
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/core/key.js
@@ -0,0 +1,96 @@
+define([
+ 'summernote/core/list',
+ 'summernote/core/func'
+], function (list, func) {
+ /**
+ * @class core.key
+ *
+ * Object for keycodes.
+ *
+ * @singleton
+ * @alternateClassName key
+ */
+ var key = (function () {
+ var keyMap = {
+ 'BACKSPACE': 8,
+ 'TAB': 9,
+ 'ENTER': 13,
+ 'SPACE': 32,
+
+ // Arrow
+ 'LEFT': 37,
+ 'UP': 38,
+ 'RIGHT': 39,
+ 'DOWN': 40,
+
+ // Number: 0-9
+ 'NUM0': 48,
+ 'NUM1': 49,
+ 'NUM2': 50,
+ 'NUM3': 51,
+ 'NUM4': 52,
+ 'NUM5': 53,
+ 'NUM6': 54,
+ 'NUM7': 55,
+ 'NUM8': 56,
+
+ // Alphabet: a-z
+ 'B': 66,
+ 'E': 69,
+ 'I': 73,
+ 'J': 74,
+ 'K': 75,
+ 'L': 76,
+ 'R': 82,
+ 'S': 83,
+ 'U': 85,
+ 'V': 86,
+ 'Y': 89,
+ 'Z': 90,
+
+ 'SLASH': 191,
+ 'LEFTBRACKET': 219,
+ 'BACKSLASH': 220,
+ 'RIGHTBRACKET': 221
+ };
+
+ return {
+ /**
+ * @method isEdit
+ *
+ * @param {Number} keyCode
+ * @return {Boolean}
+ */
+ isEdit: function (keyCode) {
+ return list.contains([
+ keyMap.BACKSPACE,
+ keyMap.TAB,
+ keyMap.ENTER,
+ keyMap.SPACe
+ ], keyCode);
+ },
+ /**
+ * @method isMove
+ *
+ * @param {Number} keyCode
+ * @return {Boolean}
+ */
+ isMove: function (keyCode) {
+ return list.contains([
+ keyMap.LEFT,
+ keyMap.UP,
+ keyMap.RIGHT,
+ keyMap.DOWN
+ ], keyCode);
+ },
+ /**
+ * @property {Object} nameFromCode
+ * @property {String} nameFromCode.8 "BACKSPACE"
+ */
+ nameFromCode: func.invertObject(keyMap),
+ code: keyMap
+ };
+ })();
+
+ return key;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/core/list.js b/addons/web_editor/static/lib/summernote/src/js/core/list.js
new file mode 100644
index 00000000..2d670883
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/core/list.js
@@ -0,0 +1,191 @@
+define(['summernote/core/func'], function (func) {
+ /**
+ * @class core.list
+ *
+ * list utils
+ *
+ * @singleton
+ * @alternateClassName list
+ */
+ var list = (function () {
+ /**
+ * returns the first item of an array.
+ *
+ * @param {Array} array
+ */
+ var head = function (array) {
+ return array[0];
+ };
+
+ /**
+ * returns the last item of an array.
+ *
+ * @param {Array} array
+ */
+ var last = function (array) {
+ return array[array.length - 1];
+ };
+
+ /**
+ * returns everything but the last entry of the array.
+ *
+ * @param {Array} array
+ */
+ var initial = function (array) {
+ return array.slice(0, array.length - 1);
+ };
+
+ /**
+ * returns the rest of the items in an array.
+ *
+ * @param {Array} array
+ */
+ var tail = function (array) {
+ return array.slice(1);
+ };
+
+ /**
+ * returns item of array
+ */
+ var find = function (array, pred) {
+ for (var idx = 0, len = array.length; idx < len; idx ++) {
+ var item = array[idx];
+ if (pred(item)) {
+ return item;
+ }
+ }
+ };
+
+ /**
+ * returns true if all of the values in the array pass the predicate truth test.
+ */
+ var all = function (array, pred) {
+ for (var idx = 0, len = array.length; idx < len; idx ++) {
+ if (!pred(array[idx])) {
+ return false;
+ }
+ }
+ return true;
+ };
+
+ /**
+ * returns index of item
+ */
+ var indexOf = function (array, item) {
+ return $.inArray(item, array);
+ };
+
+ /**
+ * returns true if the value is present in the list.
+ */
+ var contains = function (array, item) {
+ return indexOf(array, item) !== -1;
+ };
+
+ /**
+ * get sum from a list
+ *
+ * @param {Array} array - array
+ * @param {Function} fn - iterator
+ */
+ var sum = function (array, fn) {
+ fn = fn || func.self;
+ return array.reduce(function (memo, v) {
+ return memo + fn(v);
+ }, 0);
+ };
+
+ /**
+ * returns a copy of the collection with array type.
+ * @param {Collection} collection - collection eg) node.childNodes, ...
+ */
+ var from = function (collection) {
+ var result = [], idx = -1, length = collection.length;
+ while (++idx < length) {
+ result[idx] = collection[idx];
+ }
+ return result;
+ };
+
+ /**
+ * cluster elements by predicate function.
+ *
+ * @param {Array} array - array
+ * @param {Function} fn - predicate function for cluster rule
+ * @param {Array[]}
+ */
+ var clusterBy = function (array, fn) {
+ if (!array.length) { return []; }
+ var aTail = tail(array);
+ return aTail.reduce(function (memo, v) {
+ var aLast = last(memo);
+ if (fn(last(aLast), v)) {
+ aLast[aLast.length] = v;
+ } else {
+ memo[memo.length] = [v];
+ }
+ return memo;
+ }, [[head(array)]]);
+ };
+
+ /**
+ * returns a copy of the array with all falsy values removed
+ *
+ * @param {Array} array - array
+ * @param {Function} fn - predicate function for cluster rule
+ */
+ var compact = function (array) {
+ var aResult = [];
+ for (var idx = 0, len = array.length; idx < len; idx ++) {
+ if (array[idx]) { aResult.push(array[idx]); }
+ }
+ return aResult;
+ };
+
+ /**
+ * produces a duplicate-free version of the array
+ *
+ * @param {Array} array
+ */
+ var unique = function (array) {
+ var results = [];
+
+ for (var idx = 0, len = array.length; idx < len; idx ++) {
+ if (!contains(results, array[idx])) {
+ results.push(array[idx]);
+ }
+ }
+
+ return results;
+ };
+
+ /**
+ * returns next item.
+ * @param {Array} array
+ */
+ var next = function (array, item) {
+ var idx = indexOf(array, item);
+ if (idx === -1) { return null; }
+
+ return array[idx + 1];
+ };
+
+ /**
+ * returns prev item.
+ * @param {Array} array
+ */
+ var prev = function (array, item) {
+ var idx = indexOf(array, item);
+ if (idx === -1) { return null; }
+
+ return array[idx - 1];
+ };
+
+ return { head: head, last: last, initial: initial, tail: tail,
+ prev: prev, next: next, find: find, contains: contains,
+ all: all, sum: sum, from: from,
+ clusterBy: clusterBy, compact: compact, unique: unique };
+ })();
+
+ return list;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/core/range.js b/addons/web_editor/static/lib/summernote/src/js/core/range.js
new file mode 100644
index 00000000..20556b1d
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/core/range.js
@@ -0,0 +1,796 @@
+define([
+ 'summernote/core/agent',
+ 'summernote/core/func',
+ 'summernote/core/list',
+ 'summernote/core/dom'
+], function (agent, func, list, dom) {
+
+ var range = (function () {
+
+ /**
+ * return boundaryPoint from TextRange, inspired by Andy Na's HuskyRange.js
+ *
+ * @param {TextRange} textRange
+ * @param {Boolean} isStart
+ * @return {BoundaryPoint}
+ *
+ * @see http://msdn.microsoft.com/en-us/library/ie/ms535872(v=vs.85).aspx
+ */
+ var textRangeToPoint = function (textRange, isStart) {
+ var container = textRange.parentElement(), offset;
+
+ var tester = document.body.createTextRange(), prevContainer;
+ var childNodes = list.from(container.childNodes);
+ for (offset = 0; offset < childNodes.length; offset++) {
+ if (dom.isText(childNodes[offset])) {
+ continue;
+ }
+ tester.moveToElementText(childNodes[offset]);
+ if (tester.compareEndPoints('StartToStart', textRange) >= 0) {
+ break;
+ }
+ prevContainer = childNodes[offset];
+ }
+
+ if (offset !== 0 && dom.isText(childNodes[offset - 1])) {
+ var textRangeStart = document.body.createTextRange(), curTextNode = null;
+ textRangeStart.moveToElementText(prevContainer || container);
+ textRangeStart.collapse(!prevContainer);
+ curTextNode = prevContainer ? prevContainer.nextSibling : container.firstChild;
+
+ var pointTester = textRange.duplicate();
+ pointTester.setEndPoint('StartToStart', textRangeStart);
+ var textCount = pointTester.text.replace(/[\r\n]/g, '').length;
+
+ while (textCount > curTextNode.nodeValue.length && curTextNode.nextSibling) {
+ textCount -= curTextNode.nodeValue.length;
+ curTextNode = curTextNode.nextSibling;
+ }
+
+ /* jshint ignore:start */
+ var dummy = curTextNode.nodeValue; // enforce IE to re-reference curTextNode, hack
+ /* jshint ignore:end */
+
+ if (isStart && curTextNode.nextSibling && dom.isText(curTextNode.nextSibling) &&
+ textCount === curTextNode.nodeValue.length) {
+ textCount -= curTextNode.nodeValue.length;
+ curTextNode = curTextNode.nextSibling;
+ }
+
+ container = curTextNode;
+ offset = textCount;
+ }
+
+ return {
+ cont: container,
+ offset: offset
+ };
+ };
+
+ /**
+ * return TextRange from boundary point (inspired by google closure-library)
+ * @param {BoundaryPoint} point
+ * @return {TextRange}
+ */
+ var pointToTextRange = function (point) {
+ var textRangeInfo = function (container, offset) {
+ var node, isCollapseToStart;
+
+ if (dom.isText(container)) {
+ var prevTextNodes = dom.listPrev(container, func.not(dom.isText));
+ var prevContainer = list.last(prevTextNodes).previousSibling;
+ node = prevContainer || container.parentNode;
+ offset += list.sum(list.tail(prevTextNodes), dom.nodeLength);
+ isCollapseToStart = !prevContainer;
+ } else {
+ node = container.childNodes[offset] || container;
+ if (dom.isText(node)) {
+ return textRangeInfo(node, 0);
+ }
+
+ offset = 0;
+ isCollapseToStart = false;
+ }
+
+ return {
+ node: node,
+ collapseToStart: isCollapseToStart,
+ offset: offset
+ };
+ };
+
+ var textRange = document.body.createTextRange();
+ var info = textRangeInfo(point.node, point.offset);
+
+ textRange.moveToElementText(info.node);
+ textRange.collapse(info.collapseToStart);
+ textRange.moveStart('character', info.offset);
+ return textRange;
+ };
+
+ /**
+ * Wrapped Range
+ *
+ * @constructor
+ * @param {Node} sc - start container
+ * @param {Number} so - start offset
+ * @param {Node} ec - end container
+ * @param {Number} eo - end offset
+ */
+ var WrappedRange = function (sc, so, ec, eo) {
+ this.sc = sc;
+ this.so = so;
+ this.ec = ec;
+ this.eo = eo;
+
+ // nativeRange: get nativeRange from sc, so, ec, eo
+ var nativeRange = function () {
+ if (agent.isW3CRangeSupport) {
+ var w3cRange = document.createRange();
+ w3cRange.setStart(sc, so);
+ w3cRange.setEnd(ec, eo);
+
+ return w3cRange;
+ } else {
+ var textRange = pointToTextRange({
+ node: sc,
+ offset: so
+ });
+
+ textRange.setEndPoint('EndToEnd', pointToTextRange({
+ node: ec,
+ offset: eo
+ }));
+
+ return textRange;
+ }
+ };
+
+ this.getPoints = function () {
+ return {
+ sc: sc,
+ so: so,
+ ec: ec,
+ eo: eo
+ };
+ };
+
+ this.getStartPoint = function () {
+ return {
+ node: sc,
+ offset: so
+ };
+ };
+
+ this.getEndPoint = function () {
+ return {
+ node: ec,
+ offset: eo
+ };
+ };
+
+ /**
+ * select update visible range
+ */
+ this.select = function () {
+ var nativeRng = nativeRange();
+ if (agent.isW3CRangeSupport) {
+ var selection = document.getSelection();
+ if (selection.rangeCount > 0) {
+ selection.removeAllRanges();
+ }
+ selection.addRange(nativeRng);
+ } else {
+ nativeRng.select();
+ }
+
+ return this;
+ };
+
+ /**
+ * @return {WrappedRange}
+ */
+ this.normalize = function () {
+
+ /**
+ * @param {BoundaryPoint} point
+ * @param {Boolean} isLeftToRight
+ * @return {BoundaryPoint}
+ */
+ var getVisiblePoint = function (point, isLeftToRight) {
+ if ((dom.isVisiblePoint(point) && !dom.isEdgePoint(point)) ||
+ (dom.isVisiblePoint(point) && dom.isRightEdgePoint(point) && !isLeftToRight) ||
+ (dom.isVisiblePoint(point) && dom.isLeftEdgePoint(point) && isLeftToRight) ||
+ (dom.isVisiblePoint(point) && dom.isBlock(point.node) && dom.isEmpty(point.node))) {
+ return point;
+ }
+
+ // point on block's edge
+ var block = dom.ancestor(point.node, dom.isBlock);
+ if (((dom.isLeftEdgePointOf(point, block) || dom.isVoid(dom.prevPoint(point).node)) && !isLeftToRight) ||
+ ((dom.isRightEdgePointOf(point, block) || dom.isVoid(dom.nextPoint(point).node)) && isLeftToRight)) {
+
+ // returns point already on visible point
+ if (dom.isVisiblePoint(point)) {
+ return point;
+ }
+ // reverse direction
+ isLeftToRight = !isLeftToRight;
+ }
+
+ var nextPoint = isLeftToRight ? dom.nextPointUntil(dom.nextPoint(point), dom.isVisiblePoint) :
+ dom.prevPointUntil(dom.prevPoint(point), dom.isVisiblePoint);
+ return nextPoint || point;
+ };
+
+ var endPoint = getVisiblePoint(this.getEndPoint(), false);
+ var startPoint = this.isCollapsed() ? endPoint : getVisiblePoint(this.getStartPoint(), true);
+
+ return new WrappedRange(
+ startPoint.node,
+ startPoint.offset,
+ endPoint.node,
+ endPoint.offset
+ );
+ };
+
+ /**
+ * returns matched nodes on range
+ *
+ * @param {Function} [pred] - predicate function
+ * @param {Object} [options]
+ * @param {Boolean} [options.includeAncestor]
+ * @param {Boolean} [options.fullyContains]
+ * @return {Node[]}
+ */
+ this.nodes = function (pred, options) {
+ pred = pred || func.ok;
+
+ var includeAncestor = options && options.includeAncestor;
+ var fullyContains = options && options.fullyContains;
+
+ // TODO compare points and sort
+ var startPoint = this.getStartPoint();
+ var endPoint = this.getEndPoint();
+
+ var nodes = [];
+ var leftEdgeNodes = [];
+
+ dom.walkPoint(startPoint, endPoint, function (point) {
+ if (dom.isEditable(point.node)) {
+ return;
+ }
+
+ var node;
+ if (fullyContains) {
+ if (dom.isLeftEdgePoint(point)) {
+ leftEdgeNodes.push(point.node);
+ }
+ if (dom.isRightEdgePoint(point) && list.contains(leftEdgeNodes, point.node)) {
+ node = point.node;
+ }
+ } else if (includeAncestor) {
+ node = dom.ancestor(point.node, pred);
+ } else {
+ node = point.node;
+ }
+
+ if (node && pred(node)) {
+ nodes.push(node);
+ }
+ }, true);
+
+ return list.unique(nodes);
+ };
+
+ /**
+ * returns commonAncestor of range
+ * @return {Element} - commonAncestor
+ */
+ this.commonAncestor = function () {
+ return dom.commonAncestor(sc, ec);
+ };
+
+ /**
+ * returns expanded range by pred
+ *
+ * @param {Function} pred - predicate function
+ * @return {WrappedRange}
+ */
+ this.expand = function (pred) {
+ var startAncestor = dom.ancestor(sc, pred);
+ var endAncestor = dom.ancestor(ec, pred);
+
+ if (!startAncestor && !endAncestor) {
+ return new WrappedRange(sc, so, ec, eo);
+ }
+
+ var boundaryPoints = this.getPoints();
+
+ if (startAncestor) {
+ boundaryPoints.sc = startAncestor;
+ boundaryPoints.so = 0;
+ }
+
+ if (endAncestor) {
+ boundaryPoints.ec = endAncestor;
+ boundaryPoints.eo = dom.nodeLength(endAncestor);
+ }
+
+ return new WrappedRange(
+ boundaryPoints.sc,
+ boundaryPoints.so,
+ boundaryPoints.ec,
+ boundaryPoints.eo
+ );
+ };
+
+ /**
+ * @param {Boolean} isCollapseToStart
+ * @return {WrappedRange}
+ */
+ this.collapse = function (isCollapseToStart) {
+ if (isCollapseToStart) {
+ return new WrappedRange(sc, so, sc, so);
+ } else {
+ return new WrappedRange(ec, eo, ec, eo);
+ }
+ };
+
+ /**
+ * splitText on range
+ */
+ this.splitText = function () {
+ var isSameContainer = sc === ec;
+ var boundaryPoints = this.getPoints();
+
+ if (dom.isText(ec) && !dom.isEdgePoint(this.getEndPoint())) {
+ ec.splitText(eo);
+ }
+
+ if (dom.isText(sc) && !dom.isEdgePoint(this.getStartPoint())) {
+ boundaryPoints.sc = sc.splitText(so);
+ boundaryPoints.so = 0;
+
+ if (isSameContainer) {
+ boundaryPoints.ec = boundaryPoints.sc;
+ boundaryPoints.eo = eo - so;
+ }
+ }
+
+ return new WrappedRange(
+ boundaryPoints.sc,
+ boundaryPoints.so,
+ boundaryPoints.ec,
+ boundaryPoints.eo
+ );
+ };
+
+ /**
+ * delete contents on range
+ * @return {WrappedRange}
+ */
+ if(_.isUndefined(this.deleteContents)) // ODOO: ability to override by prototype
+ this.deleteContents = function () {
+ if (this.isCollapsed()) {
+ return this;
+ }
+
+ var rng = this.splitText();
+ var nodes = rng.nodes(null, {
+ fullyContains: true
+ });
+
+ // find new cursor point
+ var point = dom.prevPointUntil(rng.getStartPoint(), function (point) {
+ return !list.contains(nodes, point.node);
+ });
+
+ var emptyParents = [];
+ $.each(nodes, function (idx, node) {
+ // find empty parents
+ var parent = node.parentNode;
+ if (point.node !== parent && dom.nodeLength(parent) === 1) {
+ emptyParents.push(parent);
+ }
+ dom.remove(node, false);
+ });
+
+ // remove empty parents
+ $.each(emptyParents, function (idx, node) {
+ dom.remove(node, false);
+ });
+
+ return new WrappedRange(
+ point.node,
+ point.offset,
+ point.node,
+ point.offset
+ ).normalize();
+ };
+
+ /**
+ * makeIsOn: return isOn(pred) function
+ */
+ var makeIsOn = function (pred) {
+ return function () {
+ var ancestor = dom.ancestor(sc, pred);
+ return !!ancestor && (ancestor === dom.ancestor(ec, pred));
+ };
+ };
+
+ // isOnEditable: judge whether range is on editable or not
+ this.isOnEditable = makeIsOn(dom.isEditable);
+ // isOnList: judge whether range is on list node or not
+ this.isOnList = makeIsOn(dom.isList);
+ // isOnAnchor: judge whether range is on anchor node or not
+ this.isOnAnchor = makeIsOn(dom.isAnchor);
+ // isOnAnchor: judge whether range is on cell node or not
+ this.isOnCell = makeIsOn(dom.isCell);
+
+ /**
+ * @param {Function} pred
+ * @return {Boolean}
+ */
+ this.isLeftEdgeOf = function (pred) {
+ if (!dom.isLeftEdgePoint(this.getStartPoint())) {
+ return false;
+ }
+
+ var node = dom.ancestor(this.sc, pred);
+ return node && dom.isLeftEdgeOf(this.sc, node);
+ };
+
+ /**
+ * returns whether range was collapsed or not
+ */
+ this.isCollapsed = function () {
+ return sc === ec && so === eo;
+ };
+
+ /**
+ * wrap inline nodes which children of body with paragraph
+ *
+ * @return {WrappedRange}
+ */
+ this.wrapBodyInlineWithPara = function () {
+ if (dom.isBodyContainer(sc) && dom.isEmpty(sc)) {
+ sc.innerHTML = dom.emptyPara;
+ return new WrappedRange(sc.firstChild, 0, sc.firstChild, 0);
+ }
+
+ /**
+ * [workaround] firefox often create range on not visible point. so normalize here.
+ * - firefox: |<p>text</p>|
+ * - chrome: <p>|text|</p>
+ */
+ var rng = this.normalize();
+ if (dom.isParaInline(sc) || dom.isPara(sc)) {
+ return rng;
+ }
+
+ // ODOO: insert a p tag when try to insert a br with insertNode method, if the editor is inside a p, li, h1... (start_modification
+ // if apply the editor to a P, LI... or inside a P, LI...
+ if (dom.isText(sc)) {
+ var node = sc;
+ while (node.parentNode !== document) {
+ node = node.parentNode;
+ if (/^(P|LI|H[1-7]|BUTTON|A|SPAN)/.test(node.nodeName.toUpperCase())) {
+ return this.normalize();
+ }
+ }
+ }
+ // ODOO: end_modification)
+
+ // find inline top ancestor
+ var topAncestor;
+ if (dom.isInline(rng.sc)) {
+ var ancestors = dom.listAncestor(rng.sc, func.not(dom.isInline));
+ topAncestor = list.last(ancestors);
+ if (!dom.isInline(topAncestor)) {
+ topAncestor = ancestors[ancestors.length - 2] || rng.sc.childNodes[rng.so];
+ }
+ } else {
+ topAncestor = rng.sc.childNodes[rng.so > 0 ? rng.so - 1 : 0];
+ }
+
+ // siblings not in paragraph
+ var inlineSiblings = dom.listPrev(topAncestor, dom.isParaInline).reverse();
+ inlineSiblings = inlineSiblings.concat(dom.listNext(topAncestor.nextSibling, dom.isParaInline));
+
+ // wrap with paragraph
+ if (inlineSiblings.length) {
+ var para = dom.wrap(list.head(inlineSiblings), 'p');
+ dom.appendChildNodes(para, list.tail(inlineSiblings));
+ }
+
+ return this.normalize();
+ };
+
+ /**
+ * insert node at current cursor
+ *
+ * @param {Node} node
+ * @return {Node}
+ */
+ this.insertNode = function (node) {
+ var rng = this.wrapBodyInlineWithPara().deleteContents();
+ // ODOO: override to not split world for inserting inline
+ // original: var info = dom.splitPoint(rng.getStartPoint(), dom.isInline(node));
+ var info = dom.splitPoint(rng.getStartPoint(), !dom.isBodyContainer(dom.ancestor(rng.sc, function(node) { return dom.isBodyContainer(node) || dom.isPara(node) })));
+
+ if (info.rightNode) {
+ info.rightNode.parentNode.insertBefore(node, info.rightNode);
+ } else {
+ info.container.appendChild(node);
+ }
+
+ return node;
+ };
+
+ /**
+ * insert html at current cursor
+ */
+ this.pasteHTML = function (markup) {
+ var contentsContainer = $('<div></div>').html(markup)[0];
+ var childNodes = list.from(contentsContainer.childNodes);
+
+ var rng = this.wrapBodyInlineWithPara().deleteContents();
+
+ return childNodes.reverse().map(function (childNode) {
+ return rng.insertNode(childNode);
+ }).reverse();
+ };
+
+ /**
+ * returns text in range
+ *
+ * @return {String}
+ */
+ this.toString = function () {
+ var nativeRng = nativeRange();
+ return agent.isW3CRangeSupport ? nativeRng.toString() : nativeRng.text;
+ };
+
+ /**
+ * returns range for word before cursor
+ *
+ * @param {Boolean} [findAfter] - find after cursor, default: false
+ * @return {WrappedRange}
+ */
+ this.getWordRange = function (findAfter) {
+ var endPoint = this.getEndPoint();
+
+ if (!dom.isCharPoint(endPoint)) {
+ return this;
+ }
+
+ var startPoint = dom.prevPointUntil(endPoint, function (point) {
+ return !dom.isCharPoint(point);
+ });
+
+ if (findAfter) {
+ endPoint = dom.nextPointUntil(endPoint, function (point) {
+ return !dom.isCharPoint(point);
+ });
+ }
+
+ return new WrappedRange(
+ startPoint.node,
+ startPoint.offset,
+ endPoint.node,
+ endPoint.offset
+ );
+ };
+
+ /**
+ * create offsetPath bookmark
+ *
+ * @param {Node} editable
+ */
+ this.bookmark = function (editable) {
+ return {
+ s: {
+ path: dom.makeOffsetPath(editable, sc),
+ offset: so
+ },
+ e: {
+ path: dom.makeOffsetPath(editable, ec),
+ offset: eo
+ }
+ };
+ };
+
+ /**
+ * create offsetPath bookmark base on paragraph
+ *
+ * @param {Node[]} paras
+ */
+ this.paraBookmark = function (paras) {
+ return {
+ s: {
+ path: list.tail(dom.makeOffsetPath(list.head(paras), sc)),
+ offset: so
+ },
+ e: {
+ path: list.tail(dom.makeOffsetPath(list.last(paras), ec)),
+ offset: eo
+ }
+ };
+ };
+
+ /**
+ * getClientRects
+ * @return {Rect[]}
+ */
+ this.getClientRects = function () {
+ var nativeRng = nativeRange();
+ return nativeRng.getClientRects();
+ };
+ };
+
+ /**
+ * @class core.range
+ *
+ * Data structure
+ * * BoundaryPoint: a point of dom tree
+ * * BoundaryPoints: two boundaryPoints corresponding to the start and the end of the Range
+ *
+ * See to http://www.w3.org/TR/DOM-Level-2-Traversal-Range/ranges.html#Level-2-Range-Position
+ *
+ * @singleton
+ * @alternateClassName range
+ */
+ return {
+ WrappedRange: WrappedRange, // ODOO: give access to WrappedRange
+ /**
+ * @method
+ *
+ * create Range Object From arguments or Browser Selection
+ *
+ * @param {Node} sc - start container
+ * @param {Number} so - start offset
+ * @param {Node} ec - end container
+ * @param {Number} eo - end offset
+ * @return {WrappedRange}
+ */
+ create : function (sc, so, ec, eo) {
+ if (!arguments.length) { // from Browser Selection
+ if (agent.isW3CRangeSupport) {
+ var selection = document.getSelection();
+ if (!selection || selection.rangeCount === 0) {
+ return null;
+ } else {
+ try {
+ if (dom.isBody(selection.anchorNode)) {
+ // Firefox: returns entire body as range on initialization. We won't never need it.
+ return null;
+ }
+ } catch (e) {
+ return null;
+ }
+ }
+
+ var nativeRng = selection.getRangeAt(0);
+ sc = nativeRng.startContainer;
+ so = nativeRng.startOffset;
+ ec = nativeRng.endContainer;
+ eo = nativeRng.endOffset;
+ } else { // IE8: TextRange
+ var textRange = document.selection.createRange();
+ var textRangeEnd = textRange.duplicate();
+ textRangeEnd.collapse(false);
+ var textRangeStart = textRange;
+ textRangeStart.collapse(true);
+
+ var startPoint = textRangeToPoint(textRangeStart, true),
+ endPoint = textRangeToPoint(textRangeEnd, false);
+
+ // same visible point case: range was collapsed.
+ if (dom.isText(startPoint.node) && dom.isLeftEdgePoint(startPoint) &&
+ dom.isTextNode(endPoint.node) && dom.isRightEdgePoint(endPoint) &&
+ endPoint.node.nextSibling === startPoint.node) {
+ startPoint = endPoint;
+ }
+
+ sc = startPoint.cont;
+ so = startPoint.offset;
+ ec = endPoint.cont;
+ eo = endPoint.offset;
+ }
+ } else if (arguments.length === 2) { //collapsed
+ ec = sc;
+ eo = so;
+ }
+ return new WrappedRange(sc, so, ec, eo);
+ },
+
+ /**
+ * @method
+ *
+ * create WrappedRange from node
+ *
+ * @param {Node} node
+ * @return {WrappedRange}
+ */
+ createFromNode: function (node) {
+ var sc = node;
+ var so = 0;
+ var ec = node;
+ var eo = dom.nodeLength(ec);
+
+ // browsers can't target a picture or void node
+ if (dom.isVoid(sc)) {
+ so = dom.listPrev(sc).length - 1;
+ sc = sc.parentNode;
+ }
+ if (dom.isBR(ec)) {
+ eo = dom.listPrev(ec).length - 1;
+ ec = ec.parentNode;
+ } else if (dom.isVoid(ec)) {
+ eo = dom.listPrev(ec).length;
+ ec = ec.parentNode;
+ }
+
+ return this.create(sc, so, ec, eo);
+ },
+
+ /**
+ * create WrappedRange from node after position
+ *
+ * @param {Node} node
+ * @return {WrappedRange}
+ */
+ createFromNodeBefore: function (node) {
+ return this.createFromNode(node).collapse(true);
+ },
+
+ /**
+ * create WrappedRange from node after position
+ *
+ * @param {Node} node
+ * @return {WrappedRange}
+ */
+ createFromNodeAfter: function (node) {
+ return this.createFromNode(node).collapse();
+ },
+
+ /**
+ * @method
+ *
+ * create WrappedRange from bookmark
+ *
+ * @param {Node} editable
+ * @param {Object} bookmark
+ * @return {WrappedRange}
+ */
+ createFromBookmark : function (editable, bookmark) {
+ var sc = dom.fromOffsetPath(editable, bookmark.s.path);
+ var so = bookmark.s.offset;
+ var ec = dom.fromOffsetPath(editable, bookmark.e.path);
+ var eo = bookmark.e.offset;
+ return new WrappedRange(sc, so, ec, eo);
+ },
+
+ /**
+ * @method
+ *
+ * create WrappedRange from paraBookmark
+ *
+ * @param {Object} bookmark
+ * @param {Node[]} paras
+ * @return {WrappedRange}
+ */
+ createFromParaBookmark: function (bookmark, paras) {
+ var so = bookmark.s.offset;
+ var eo = bookmark.e.offset;
+ var sc = dom.fromOffsetPath(list.head(paras), bookmark.s.path);
+ var ec = dom.fromOffsetPath(list.last(paras), bookmark.e.path);
+
+ return new WrappedRange(sc, so, ec, eo);
+ }
+ };
+ })();
+
+ return range;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/defaults.js b/addons/web_editor/static/lib/summernote/src/js/defaults.js
new file mode 100644
index 00000000..9d8e0bab
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/defaults.js
@@ -0,0 +1,416 @@
+define('summernote/defaults', function () {
+ /**
+ * @class defaults
+ *
+ * @singleton
+ */
+ var defaults = {
+ /** @property */
+ version: '@VERSION',
+
+ /**
+ *
+ * for event options, reference to EventHandler.attach
+ *
+ * @property {Object} options
+ * @property {String/Number} [options.width=null] set editor width
+ * @property {String/Number} [options.height=null] set editor height, ex) 300
+ * @property {String/Number} options.minHeight set minimum height of editor
+ * @property {String/Number} options.maxHeight
+ * @property {String/Number} options.focus
+ * @property {Number} options.tabsize
+ * @property {Boolean} options.styleWithSpan
+ * @property {Object} options.codemirror
+ * @property {Object} [options.codemirror.mode='text/html']
+ * @property {Object} [options.codemirror.htmlMode=true]
+ * @property {Object} [options.codemirror.lineNumbers=true]
+ * @property {String} [options.lang=en-US] language 'en-US', 'ko-KR', ...
+ * @property {String} [options.direction=null] text direction, ex) 'rtl'
+ * @property {Array} [options.toolbar]
+ * @property {Boolean} [options.airMode=false]
+ * @property {Array} [options.airPopover]
+ * @property {Fucntion} [options.onInit] initialize
+ * @property {Fucntion} [options.onsubmit]
+ */
+ options: {
+ width: null, // set editor width
+ height: null, // set editor height, ex) 300
+
+ minHeight: null, // set minimum height of editor
+ maxHeight: null, // set maximum height of editor
+
+ focus: false, // set focus to editable area after initializing summernote
+
+ tabsize: 4, // size of tab ex) 2 or 4
+ styleWithSpan: true, // style with span (Chrome and FF only)
+
+ disableLinkTarget: false, // hide link Target Checkbox
+ disableDragAndDrop: false, // disable drag and drop event
+ disableResizeEditor: false, // disable resizing editor
+ disableResizeImage: false, // disable resizing image
+
+ shortcuts: true, // enable keyboard shortcuts
+
+ textareaAutoSync: true, // enable textarea auto sync
+
+ placeholder: false, // enable placeholder text
+ prettifyHtml: true, // enable prettifying html while toggling codeview
+
+ iconPrefix: 'fa fa-', // prefix for css icon classes
+
+ icons: {
+ font: {
+ bold: 'bold',
+ italic: 'italic',
+ underline: 'underline',
+ clear: 'eraser',
+ height: 'text-height',
+ strikethrough: 'strikethrough',
+ superscript: 'superscript',
+ subscript: 'subscript'
+ },
+ image: {
+ image: 'picture-o',
+ floatLeft: 'align-left',
+ floatRight: 'align-right',
+ floatNone: 'align-justify',
+ shapeRounded: 'square',
+ shapeCircle: 'circle-o',
+ shapeThumbnail: 'picture-o',
+ shapeNone: 'times',
+ remove: 'trash-o'
+ },
+ link: {
+ link: 'link',
+ unlink: 'unlink',
+ edit: 'edit'
+ },
+ table: {
+ table: 'table'
+ },
+ hr: {
+ insert: 'minus'
+ },
+ style: {
+ style: 'magic'
+ },
+ lists: {
+ unordered: 'list-ul',
+ ordered: 'list-ol'
+ },
+ options: {
+ help: 'question',
+ fullscreen: 'arrows-alt',
+ codeview: 'code'
+ },
+ paragraph: {
+ paragraph: 'align-left',
+ outdent: 'outdent',
+ indent: 'indent',
+ left: 'align-left',
+ center: 'align-center',
+ right: 'align-right',
+ justify: 'align-justify'
+ },
+ color: {
+ recent: 'font'
+ },
+ history: {
+ undo: 'undo',
+ redo: 'repeat'
+ },
+ misc: {
+ check: 'check'
+ }
+ },
+
+ dialogsInBody: false, // false will add dialogs into editor
+
+ codemirror: { // codemirror options
+ mode: 'text/html',
+ htmlMode: true,
+ lineNumbers: true
+ },
+
+ // language
+ lang: 'en-US', // language 'en-US', 'ko-KR', ...
+ direction: null, // text direction, ex) 'rtl'
+
+ // toolbar
+ toolbar: [
+ ['style', ['style']],
+ ['font', ['bold', 'italic', 'underline', 'clear']],
+ // ['font', ['bold', 'italic', 'underline', 'strikethrough', 'superscript', 'subscript', 'clear']],
+ ['fontname', ['fontname']],
+ ['fontsize', ['fontsize']],
+ ['color', ['color']],
+ ['para', ['ul', 'ol', 'paragraph']],
+ ['height', ['height']],
+ ['table', ['table']],
+ ['insert', ['link', 'picture', 'hr']],
+ ['view', ['fullscreen', 'codeview']],
+ ['help', ['help']]
+ ],
+
+ plugin : { },
+
+ // air mode: inline editor
+ airMode: false,
+ // airPopover: [
+ // ['style', ['style']],
+ // ['font', ['bold', 'italic', 'underline', 'clear']],
+ // ['fontname', ['fontname']],
+ // ['color', ['color']],
+ // ['para', ['ul', 'ol', 'paragraph']],
+ // ['height', ['height']],
+ // ['table', ['table']],
+ // ['insert', ['link', 'picture']],
+ // ['help', ['help']]
+ // ],
+ airPopover: [
+ ['color', ['color']],
+ ['font', ['bold', 'underline', 'clear']],
+ ['para', ['ul', 'paragraph']],
+ ['table', ['table']],
+ ['insert', ['link', 'picture']]
+ ],
+
+ // style tag
+ styleTags: ['p', 'blockquote', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
+
+ // default fontName
+ defaultFontName: 'Helvetica Neue',
+
+ // fontName
+ fontNames: [
+ 'Arial', 'Arial Black', 'Comic Sans MS', 'Courier New',
+ 'Helvetica Neue', 'Helvetica', 'Impact', 'Lucida Grande',
+ 'Tahoma', 'Times New Roman', 'Verdana'
+ ],
+ fontNamesIgnoreCheck: [],
+
+ fontSizes: ['8', '9', '10', '11', '12', '14', '18', '24', '36'],
+
+ // pallete colors(n x n)
+ colors: [
+ ['#000000', '#424242', '#636363', '#9C9C94', '#CEC6CE', '#EFEFEF', '#F7F7F7', '#FFFFFF'],
+ ['#FF0000', '#FF9C00', '#FFFF00', '#00FF00', '#00FFFF', '#0000FF', '#9C00FF', '#FF00FF'],
+ ['#F7C6CE', '#FFE7CE', '#FFEFC6', '#D6EFD6', '#CEDEE7', '#CEE7F7', '#D6D6E7', '#E7D6DE'],
+ ['#E79C9C', '#FFC69C', '#FFE79C', '#B5D6A5', '#A5C6CE', '#9CC6EF', '#B5A5D6', '#D6A5BD'],
+ ['#E76363', '#F7AD6B', '#FFD663', '#94BD7B', '#73A5AD', '#6BADDE', '#8C7BC6', '#C67BA5'],
+ ['#CE0000', '#E79439', '#EFC631', '#6BA54A', '#4A7B8C', '#3984C6', '#634AA5', '#A54A7B'],
+ ['#9C0000', '#B56308', '#BD9400', '#397B21', '#104A5A', '#085294', '#311873', '#731842'],
+ ['#630000', '#7B3900', '#846300', '#295218', '#083139', '#003163', '#21104A', '#4A1031']
+ ],
+
+ // lineHeight
+ lineHeights: ['1.0', '1.2', '1.4', '1.5', '1.6', '1.8', '2.0', '3.0'],
+
+ // insertTable max size
+ insertTableMaxSize: {
+ col: 10,
+ row: 10
+ },
+
+ // image
+ maximumImageFileSize: null, // size in bytes, null = no limit
+
+ // callbacks
+ oninit: null, // initialize
+ onfocus: null, // editable has focus
+ onblur: null, // editable out of focus
+ onenter: null, // enter key pressed
+ onkeyup: null, // keyup
+ onkeydown: null, // keydown
+ onImageUpload: null, // imageUpload
+ onImageUploadError: null, // imageUploadError
+ onMediaDelete: null, // media delete
+ onToolbarClick: null,
+ onsubmit: null,
+
+ /**
+ * manipulate link address when user create link
+ * @param {String} sLinkUrl
+ * @return {String}
+ */
+ onCreateLink: function (sLinkUrl) {
+ if (sLinkUrl.indexOf('@') !== -1 && sLinkUrl.indexOf(':') === -1) {
+ sLinkUrl = 'mailto:' + sLinkUrl;
+ }
+
+ return sLinkUrl;
+ },
+
+ keyMap: {
+ pc: {
+ 'ENTER': 'insertParagraph',
+ 'CTRL+Z': 'undo',
+ 'CTRL+Y': 'redo',
+ 'TAB': 'tab',
+ 'SHIFT+TAB': 'untab',
+ 'CTRL+B': 'bold',
+ 'CTRL+I': 'italic',
+ 'CTRL+U': 'underline',
+ 'CTRL+SHIFT+S': 'strikethrough',
+ 'CTRL+BACKSLASH': 'removeFormat',
+ 'CTRL+SHIFT+L': 'justifyLeft',
+ 'CTRL+SHIFT+E': 'justifyCenter',
+ 'CTRL+SHIFT+R': 'justifyRight',
+ 'CTRL+SHIFT+J': 'justifyFull',
+ 'CTRL+SHIFT+NUM7': 'insertUnorderedList',
+ 'CTRL+SHIFT+NUM8': 'insertOrderedList',
+ 'CTRL+LEFTBRACKET': 'outdent',
+ 'CTRL+RIGHTBRACKET': 'indent',
+ 'CTRL+NUM0': 'formatPara',
+ 'CTRL+NUM1': 'formatH1',
+ 'CTRL+NUM2': 'formatH2',
+ 'CTRL+NUM3': 'formatH3',
+ 'CTRL+NUM4': 'formatH4',
+ 'CTRL+NUM5': 'formatH5',
+ 'CTRL+NUM6': 'formatH6',
+ 'CTRL+ENTER': 'insertHorizontalRule',
+ 'CTRL+K': 'showLinkDialog'
+ },
+
+ mac: {
+ 'ENTER': 'insertParagraph',
+ 'CMD+Z': 'undo',
+ 'CMD+SHIFT+Z': 'redo',
+ 'TAB': 'tab',
+ 'SHIFT+TAB': 'untab',
+ 'CMD+B': 'bold',
+ 'CMD+I': 'italic',
+ 'CMD+U': 'underline',
+ 'CMD+SHIFT+S': 'strikethrough',
+ 'CMD+BACKSLASH': 'removeFormat',
+ 'CMD+SHIFT+L': 'justifyLeft',
+ 'CMD+SHIFT+E': 'justifyCenter',
+ 'CMD+SHIFT+R': 'justifyRight',
+ 'CMD+SHIFT+J': 'justifyFull',
+ 'CMD+SHIFT+NUM7': 'insertUnorderedList',
+ 'CMD+SHIFT+NUM8': 'insertOrderedList',
+ 'CMD+LEFTBRACKET': 'outdent',
+ 'CMD+RIGHTBRACKET': 'indent',
+ 'CMD+NUM0': 'formatPara',
+ 'CMD+NUM1': 'formatH1',
+ 'CMD+NUM2': 'formatH2',
+ 'CMD+NUM3': 'formatH3',
+ 'CMD+NUM4': 'formatH4',
+ 'CMD+NUM5': 'formatH5',
+ 'CMD+NUM6': 'formatH6',
+ 'CMD+ENTER': 'insertHorizontalRule',
+ 'CMD+K': 'showLinkDialog'
+ }
+ }
+ },
+
+ // default language: en-US
+ lang: {
+ 'en-US': {
+ font: {
+ bold: 'Bold',
+ italic: 'Italic',
+ underline: 'Underline',
+ clear: 'Remove Font Style',
+ height: 'Line Height',
+ name: 'Font Family',
+ strikethrough: 'Strikethrough',
+ subscript: 'Subscript',
+ superscript: 'Superscript',
+ size: 'Font Size'
+ },
+ image: {
+ image: 'Picture',
+ insert: 'Insert Image',
+ resizeFull: 'Resize Full',
+ resizeHalf: 'Resize Half',
+ resizeQuarter: 'Resize Quarter',
+ floatLeft: 'Float Left',
+ floatRight: 'Float Right',
+ floatNone: 'Float None',
+ shapeRounded: 'Shape: Rounded',
+ shapeCircle: 'Shape: Circle',
+ shapeThumbnail: 'Shape: Thumbnail',
+ shapeNone: 'Shape: None',
+ dragImageHere: 'Drag image or text here',
+ dropImage: 'Drop image or Text',
+ selectFromFiles: 'Select from files',
+ maximumFileSize: 'Maximum file size',
+ maximumFileSizeError: 'Maximum file size exceeded.',
+ url: 'Image URL',
+ remove: 'Remove Image'
+ },
+ link: {
+ link: 'Link',
+ insert: 'Insert Link',
+ unlink: 'Unlink',
+ edit: 'Edit',
+ textToDisplay: 'Text to display',
+ url: 'To what URL should this link go?',
+ openInNewWindow: 'Open in new window'
+ },
+ table: {
+ table: 'Table'
+ },
+ hr: {
+ insert: 'Insert Horizontal Rule'
+ },
+ style: {
+ style: 'Style',
+ normal: 'Paragraph',
+ blockquote: 'Quote',
+ pre: 'Code',
+ h1: 'Header 1',
+ h2: 'Header 2',
+ h3: 'Header 3',
+ h4: 'Header 4',
+ h5: 'Header 5',
+ h6: 'Header 6'
+ },
+ lists: {
+ unordered: 'Unordered list',
+ ordered: 'Ordered list'
+ },
+ options: {
+ help: 'Help',
+ fullscreen: 'Full Screen',
+ codeview: 'Code View'
+ },
+ paragraph: {
+ paragraph: 'Paragraph',
+ outdent: 'Outdent',
+ indent: 'Indent',
+ left: 'Align left',
+ center: 'Align center',
+ right: 'Align right',
+ justify: 'Justify full'
+ },
+ color: {
+ recent: 'Recent Color',
+ more: 'More Color',
+ background: 'Background Color',
+ foreground: 'Foreground Color',
+ transparent: 'Transparent',
+ setTransparent: 'Set transparent',
+ reset: 'Reset',
+ resetToDefault: 'Reset to default'
+ },
+ shortcut: {
+ shortcuts: 'Keyboard shortcuts',
+ close: 'Close',
+ textFormatting: 'Text formatting',
+ action: 'Action',
+ paragraphFormatting: 'Paragraph formatting',
+ documentStyle: 'Document Style',
+ extraKeys: 'Extra keys'
+ },
+ history: {
+ undo: 'Undo',
+ redo: 'Redo'
+ }
+ }
+ }
+ };
+
+ return defaults;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/editing/Bullet.js b/addons/web_editor/static/lib/summernote/src/js/editing/Bullet.js
new file mode 100644
index 00000000..6a115eda
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/editing/Bullet.js
@@ -0,0 +1,235 @@
+define([
+ 'summernote/core/list',
+ 'summernote/core/func',
+ 'summernote/core/dom',
+ 'summernote/core/range'
+], function (list, func, dom, range) {
+
+ /**
+ * @class editing.Bullet
+ *
+ * @alternateClassName Bullet
+ */
+ var Bullet = function () {
+ /**
+ * @method insertOrderedList
+ *
+ * toggle ordered list
+ *
+ * @type command
+ */
+ this.insertOrderedList = function () {
+ this.toggleList('OL');
+ };
+
+ /**
+ * @method insertUnorderedList
+ *
+ * toggle unordered list
+ *
+ * @type command
+ */
+ this.insertUnorderedList = function () {
+ this.toggleList('UL');
+ };
+
+ /**
+ * @method indent
+ *
+ * indent
+ *
+ * @type command
+ */
+ this.indent = function () {
+ var self = this;
+ var rng = range.create().wrapBodyInlineWithPara();
+
+ var paras = rng.nodes(dom.isPara, { includeAncestor: true });
+ var clustereds = list.clusterBy(paras, func.peq2('parentNode'));
+
+ $.each(clustereds, function (idx, paras) {
+ var head = list.head(paras);
+ if (dom.isLi(head)) {
+ self.wrapList(paras, head.parentNode.nodeName);
+ } else {
+ $.each(paras, function (idx, para) {
+ $(para).css('marginLeft', function (idx, val) {
+ return (parseInt(val, 10) || 0) + 25;
+ });
+ });
+ }
+ });
+
+ rng.select();
+ };
+
+ /**
+ * @method outdent
+ *
+ * outdent
+ *
+ * @type command
+ */
+ this.outdent = function () {
+ var self = this;
+ var rng = range.create().wrapBodyInlineWithPara();
+
+ var paras = rng.nodes(dom.isPara, { includeAncestor: true });
+ var clustereds = list.clusterBy(paras, func.peq2('parentNode'));
+
+ $.each(clustereds, function (idx, paras) {
+ var head = list.head(paras);
+ if (dom.isLi(head)) {
+ self.releaseList([paras]);
+ } else {
+ $.each(paras, function (idx, para) {
+ $(para).css('marginLeft', function (idx, val) {
+ val = (parseInt(val, 10) || 0);
+ return val > 25 ? val - 25 : '';
+ });
+ });
+ }
+ });
+
+ rng.select();
+ };
+
+ /**
+ * @method toggleList
+ *
+ * toggle list
+ *
+ * @param {String} listName - OL or UL
+ */
+ this.toggleList = function (listName) {
+ var self = this;
+ var rng = range.create().wrapBodyInlineWithPara();
+
+ var paras = rng.nodes(dom.isPara, { includeAncestor: true });
+ var bookmark = rng.paraBookmark(paras);
+ var clustereds = list.clusterBy(paras, func.peq2('parentNode'));
+
+ // paragraph to list
+ if (list.find(paras, dom.isPurePara)) {
+ var wrappedParas = [];
+ $.each(clustereds, function (idx, paras) {
+ wrappedParas = wrappedParas.concat(self.wrapList(paras, listName));
+ });
+ paras = wrappedParas;
+ // list to paragraph or change list style
+ } else {
+ var diffLists = rng.nodes(dom.isList, {
+ includeAncestor: true
+ }).filter(function (listNode) {
+ return !$.nodeName(listNode, listName);
+ });
+
+ if (diffLists.length) {
+ $.each(diffLists, function (idx, listNode) {
+ dom.replace(listNode, listName);
+ });
+ } else {
+ paras = this.releaseList(clustereds, true);
+ }
+ }
+
+ range.createFromParaBookmark(bookmark, paras).select();
+ };
+
+ /**
+ * @method wrapList
+ *
+ * @param {Node[]} paras
+ * @param {String} listName
+ * @return {Node[]}
+ */
+ this.wrapList = function (paras, listName) {
+ var head = list.head(paras);
+ var last = list.last(paras);
+
+ var prevList = dom.isList(head.previousSibling) && head.previousSibling;
+ var nextList = dom.isList(last.nextSibling) && last.nextSibling;
+
+ var listNode = prevList || dom.insertAfter(dom.create(listName || 'UL'), last);
+
+ // P to LI
+ paras = paras.map(function (para) {
+ return dom.isPurePara(para) ? dom.replace(para, 'LI') : para;
+ });
+
+ // append to list(<ul>, <ol>)
+ dom.appendChildNodes(listNode, paras);
+
+ if (nextList) {
+ dom.appendChildNodes(listNode, list.from(nextList.childNodes));
+ dom.remove(nextList);
+ }
+
+ return paras;
+ };
+
+ /**
+ * @method releaseList
+ *
+ * @param {Array[]} clustereds
+ * @param {Boolean} isEscapseToBody
+ * @return {Node[]}
+ */
+ this.releaseList = function (clustereds, isEscapseToBody) {
+ var releasedParas = [];
+
+ $.each(clustereds, function (idx, paras) {
+ var head = list.head(paras);
+ var last = list.last(paras);
+
+ var headList = isEscapseToBody ? dom.lastAncestor(head, dom.isList) :
+ head.parentNode;
+ var lastList = headList.childNodes.length > 1 ? dom.splitTree(headList, {
+ node: last.parentNode,
+ offset: dom.position(last) + 1
+ }, {
+ isSkipPaddingBlankHTML: true
+ }) : null;
+
+ var middleList = dom.splitTree(headList, {
+ node: head.parentNode,
+ offset: dom.position(head)
+ }, {
+ isSkipPaddingBlankHTML: true
+ });
+
+ paras = isEscapseToBody ? dom.listDescendant(middleList, dom.isLi) :
+ list.from(middleList.childNodes).filter(dom.isLi);
+
+ // LI to P
+ if (isEscapseToBody || !dom.isList(headList.parentNode)) {
+ paras = paras.map(function (para) {
+ return dom.replace(para, 'P');
+ });
+ }
+
+ $.each(list.from(paras).reverse(), function (idx, para) {
+ dom.insertAfter(para, headList);
+ });
+
+ // remove empty lists
+ var rootLists = list.compact([headList, middleList, lastList]);
+ $.each(rootLists, function (idx, rootList) {
+ var listNodes = [rootList].concat(dom.listDescendant(rootList, dom.isList));
+ $.each(listNodes.reverse(), function (idx, listNode) {
+ if (!dom.nodeLength(listNode)) {
+ dom.remove(listNode, true);
+ }
+ });
+ });
+
+ releasedParas = releasedParas.concat(paras);
+ });
+
+ return releasedParas;
+ };
+ };
+
+ return Bullet;
+});
+
diff --git a/addons/web_editor/static/lib/summernote/src/js/editing/History.js b/addons/web_editor/static/lib/summernote/src/js/editing/History.js
new file mode 100644
index 00000000..2cc94648
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/editing/History.js
@@ -0,0 +1,118 @@
+define(['summernote/core/range', 'summernote/core/dom'], function (range, dom) { // ODOO: suggest upstream
+ /**
+ * @class editing.History
+ *
+ * Editor History
+ *
+ */
+ var History = function ($editable) {
+ var stack = [], stackOffset = -1;
+ var editable = $editable[0];
+
+ var makeSnapshot = function () {
+ var rng = range.create();
+ var emptyBookmark = {s: {path: [], offset: 0}, e: {path: [], offset: 0}};
+
+ return {
+ contents: $editable.html(),
+ bookmark: (rng && dom.ancestor(rng.sc, dom.isEditable) ? rng.bookmark(editable) : emptyBookmark)
+ // ODOO: suggest upstream added " && dom.ancestor(rng.sc, dom.isEditable) "
+ };
+ };
+
+ var applySnapshot = function (snapshot) {
+ if (snapshot.contents !== null) {
+ $editable.html(snapshot.contents);
+ }
+ if (snapshot.bookmark !== null) {
+ range.createFromBookmark(editable, snapshot.bookmark).select();
+ }
+ };
+
+ /**
+ * undo
+ */
+ this.undo = function () {
+ // Create snap shot if not yet recorded
+ if ($editable.html() !== stack[stackOffset].contents) {
+ this.recordUndo();
+ }
+
+ if (0 < stackOffset) {
+ stackOffset--;
+ applySnapshot(stack[stackOffset]);
+ }
+ };
+
+ /* ODOO: to suggest upstream */
+ this.hasUndo = function () {
+ return 0 < stackOffset;
+ };
+
+ /**
+ * redo
+ */
+ this.redo = function () {
+ if (stack.length - 1 > stackOffset) {
+ stackOffset++;
+ applySnapshot(stack[stackOffset]);
+ }
+ };
+
+ /* ODOO: to suggest upstream */
+ this.hasRedo = function () {
+ return stack.length - 1 > stackOffset;
+ };
+
+ var last; // ODOO: to suggest upstream (since we may have several editor)
+ /**
+ * recorded undo
+ */
+ this.recordUndo = function () {
+ // ODOO: method totally rewritten
+ // test event for firefox: remove stack of history because event doesn't exists
+ var key = typeof event !== 'undefined' ? event : false;
+ if (key && !event.metaKey && !event.ctrlKey && !event.altKey && event.type === "keydown") {
+ key = event.type + "-";
+ if (event.which === 8 || event.which === 46) key += 'delete';
+ else if (event.which === 13) key += 'enter';
+ else key += 'other';
+ if (key === last) return;
+ hasUndo = true;
+ }
+ last = key;
+
+ // Wash out stack after stackOffset
+ if (stack.length > stackOffset+1) {
+ stack = stack.slice(0, stackOffset+1);
+ }
+
+ if (stack[stackOffset] && stack[stackOffset].contents === $editable.html()) {
+ return;
+ }
+
+ stackOffset++;
+
+ // Create new snapshot and push it to the end
+ stack.push(makeSnapshot());
+ };
+
+ /* ODOO: to suggest upstream */
+ this.splitNext = function () {
+ last = false;
+ };
+
+ /* ODOO: to suggest upstream */
+ this.reset = function () {
+ last = false;
+ stack = [];
+ stackOffset = -1;
+ this.recordUndo();
+ };
+
+ // Create first undo stack
+ this.recordUndo();
+ };
+
+ return History;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/editing/Style.js b/addons/web_editor/static/lib/summernote/src/js/editing/Style.js
new file mode 100644
index 00000000..bf749cd8
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/editing/Style.js
@@ -0,0 +1,161 @@
+define([
+ 'summernote/core/agent',
+ 'summernote/core/func',
+ 'summernote/core/list',
+ 'summernote/core/dom'
+], function (agent, func, list, dom) {
+ /**
+ * @class editing.Style
+ *
+ * Style
+ *
+ */
+ var Style = function () {
+ /**
+ * @method jQueryCSS
+ *
+ * [workaround] for old jQuery
+ * passing an array of style properties to .css()
+ * will result in an object of property-value pairs.
+ * (compability with version < 1.9)
+ *
+ * @private
+ * @param {jQuery} $obj
+ * @param {Array} propertyNames - An array of one or more CSS properties.
+ * @return {Object}
+ */
+ var jQueryCSS = function ($obj, propertyNames) {
+ if (agent.jqueryVersion < 1.9) {
+ var result = {};
+ $.each(propertyNames, function (idx, propertyName) {
+ result[propertyName] = $obj.css(propertyName);
+ });
+ return result;
+ }
+ return $obj.css.call($obj, propertyNames);
+ };
+
+ /**
+ * returns style object from node
+ *
+ * @param {jQuery} $node
+ * @return {Object}
+ */
+ this.fromNode = function ($node) {
+ var properties = ['font-family', 'font-size', 'text-align', 'list-style-type', 'line-height'];
+ var styleInfo = jQueryCSS($node, properties) || {};
+ styleInfo['font-size'] = parseInt(styleInfo['font-size'], 10);
+ return styleInfo;
+ };
+
+ /**
+ * paragraph level style
+ *
+ * @param {WrappedRange} rng
+ * @param {Object} styleInfo
+ */
+ this.stylePara = function (rng, styleInfo) {
+ $.each(rng.nodes(dom.isPara, {
+ includeAncestor: true
+ }), function (idx, para) {
+ $(para).css(styleInfo);
+ });
+ };
+
+ /**
+ * insert and returns styleNodes on range.
+ *
+ * @param {WrappedRange} rng
+ * @param {Object} [options] - options for styleNodes
+ * @param {String} [options.nodeName] - default: `SPAN`
+ * @param {Boolean} [options.expandClosestSibling] - default: `false`
+ * @param {Boolean} [options.onlyPartialContains] - default: `false`
+ * @return {Node[]}
+ */
+ this.styleNodes = function (rng, options) {
+ rng = rng.splitText();
+
+ var nodeName = options && options.nodeName || 'SPAN';
+ var expandClosestSibling = !!(options && options.expandClosestSibling);
+ var onlyPartialContains = !!(options && options.onlyPartialContains);
+
+ if (rng.isCollapsed()) {
+ return [rng.insertNode(dom.create(nodeName))];
+ }
+
+ var pred = dom.makePredByNodeName(nodeName);
+ var nodes = rng.nodes(dom.isText, {
+ fullyContains: true
+ }).map(function (text) {
+ return dom.singleChildAncestor(text, pred) || dom.wrap(text, nodeName);
+ });
+
+ if (expandClosestSibling) {
+ if (onlyPartialContains) {
+ var nodesInRange = rng.nodes();
+ // compose with partial contains predication
+ pred = func.and(pred, function (node) {
+ return list.contains(nodesInRange, node);
+ });
+ }
+
+ return nodes.map(function (node) {
+ var siblings = dom.withClosestSiblings(node, pred);
+ var head = list.head(siblings);
+ var tails = list.tail(siblings);
+ $.each(tails, function (idx, elem) {
+ dom.appendChildNodes(head, elem.childNodes);
+ dom.remove(elem);
+ });
+ return list.head(siblings);
+ });
+ } else {
+ return nodes;
+ }
+ };
+
+ /**
+ * get current style on cursor
+ *
+ * @param {WrappedRange} rng
+ * @return {Object} - object contains style properties.
+ */
+ this.current = function (rng) {
+ var $cont = $(dom.isText(rng.sc) ? rng.sc.parentNode : rng.sc);
+ var styleInfo = this.fromNode($cont);
+
+ // document.queryCommandState for toggle state
+ styleInfo['font-bold'] = document.queryCommandState('bold') ? 'bold' : 'normal';
+ styleInfo['font-italic'] = document.queryCommandState('italic') ? 'italic' : 'normal';
+ styleInfo['font-underline'] = document.queryCommandState('underline') ? 'underline' : 'normal';
+ styleInfo['font-strikethrough'] = document.queryCommandState('strikeThrough') ? 'strikethrough' : 'normal';
+ styleInfo['font-superscript'] = document.queryCommandState('superscript') ? 'superscript' : 'normal';
+ styleInfo['font-subscript'] = document.queryCommandState('subscript') ? 'subscript' : 'normal';
+
+ // list-style-type to list-style(unordered, ordered)
+ if (!rng.isOnList()) {
+ styleInfo['list-style'] = 'none';
+ } else {
+ var aOrderedType = ['circle', 'disc', 'disc-leading-zero', 'square'];
+ var isUnordered = $.inArray(styleInfo['list-style-type'], aOrderedType) > -1;
+ styleInfo['list-style'] = isUnordered ? 'unordered' : 'ordered';
+ }
+
+ var para = dom.ancestor(rng.sc, dom.isPara);
+ if (para && para.style['line-height']) {
+ styleInfo['line-height'] = para.style.lineHeight;
+ } else {
+ var lineHeight = parseInt(styleInfo['line-height'], 10) / parseInt(styleInfo['font-size'], 10);
+ styleInfo['line-height'] = lineHeight.toFixed(1);
+ }
+
+ styleInfo.anchor = rng.isOnAnchor() && dom.ancestor(rng.sc, dom.isAnchor);
+ styleInfo.ancestors = dom.listAncestor(rng.sc, dom.isEditable);
+ styleInfo.range = rng;
+
+ return styleInfo;
+ };
+ };
+
+ return Style;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/editing/Table.js b/addons/web_editor/static/lib/summernote/src/js/editing/Table.js
new file mode 100644
index 00000000..634d5a79
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/editing/Table.js
@@ -0,0 +1,52 @@
+define([
+ 'summernote/core/dom', 'summernote/core/range', 'summernote/core/list'
+], function (dom, range, list) {
+ /**
+ * @class editing.Table
+ *
+ * Table
+ *
+ */
+ var Table = function () {
+ /**
+ * handle tab key
+ *
+ * @param {WrappedRange} rng
+ * @param {Boolean} isShift
+ */
+ this.tab = function (rng, isShift) {
+ var cell = dom.ancestor(rng.commonAncestor(), dom.isCell);
+ var table = dom.ancestor(cell, dom.isTable);
+ var cells = dom.listDescendant(table, dom.isCell);
+
+ var nextCell = list[isShift ? 'prev' : 'next'](cells, cell);
+ if (nextCell) {
+ range.create(nextCell, 0).select();
+ }
+ };
+
+ /**
+ * create empty table element
+ *
+ * @param {Number} rowCount
+ * @param {Number} colCount
+ * @return {Node}
+ */
+ this.createTable = function (colCount, rowCount) {
+ var tds = [], tdHTML;
+ for (var idxCol = 0; idxCol < colCount; idxCol++) {
+ tds.push('<td>' + dom.blank + '</td>');
+ }
+ tdHTML = tds.join('');
+
+ var trs = [], trHTML;
+ for (var idxRow = 0; idxRow < rowCount; idxRow++) {
+ trs.push('<tr>' + tdHTML + '</tr>');
+ }
+ trHTML = trs.join('');
+ return $('<table class="table table-bordered">' + trHTML + '</table>')[0];
+ };
+ };
+ return Table;
+});
+
diff --git a/addons/web_editor/static/lib/summernote/src/js/editing/Typing.js b/addons/web_editor/static/lib/summernote/src/js/editing/Typing.js
new file mode 100644
index 00000000..f95eba9e
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/editing/Typing.js
@@ -0,0 +1,86 @@
+define([
+ 'summernote/core/dom',
+ 'summernote/core/range',
+ 'summernote/editing/Bullet'
+], function (dom, range, Bullet) {
+
+ /**
+ * @class editing.Typing
+ *
+ * Typing
+ *
+ */
+ var Typing = function () {
+
+ // a Bullet instance to toggle lists off
+ var bullet = new Bullet();
+
+ /**
+ * insert tab
+ *
+ * @param {jQuery} $editable
+ * @param {WrappedRange} rng
+ * @param {Number} tabsize
+ */
+ this.insertTab = function ($editable, rng, tabsize) {
+ var tab = dom.createText(new Array(tabsize + 1).join(dom.NBSP_CHAR));
+ rng = rng.deleteContents();
+ rng.insertNode(tab, true);
+
+ rng = range.create(tab, tabsize);
+ rng.select();
+ };
+
+ /**
+ * insert paragraph
+ */
+ this.insertParagraph = function () {
+ var rng = range.create();
+
+ // deleteContents on range.
+ rng = rng.deleteContents();
+
+ // Wrap range if it needs to be wrapped by paragraph
+ rng = rng.wrapBodyInlineWithPara();
+
+ // finding paragraph
+ var splitRoot = dom.ancestor(rng.sc, dom.isPara);
+
+ var nextPara;
+ // on paragraph: split paragraph
+ if (splitRoot) {
+ // if it is an empty line with li
+ if (dom.isEmpty(splitRoot) && dom.isLi(splitRoot)) {
+ // disable UL/OL and escape!
+ bullet.toggleList(splitRoot.parentNode.nodeName);
+ return;
+ // if new line has content (not a line break)
+ } else {
+ nextPara = dom.splitTree(splitRoot, rng.getStartPoint());
+
+ var emptyAnchors = dom.listDescendant(splitRoot, dom.isEmptyAnchor);
+ emptyAnchors = emptyAnchors.concat(dom.listDescendant(nextPara, dom.isEmptyAnchor));
+
+ $.each(emptyAnchors, function (idx, anchor) {
+ dom.remove(anchor);
+ });
+ }
+ // no paragraph: insert empty paragraph
+ } else {
+ var next = rng.sc.childNodes[rng.so];
+ nextPara = $(dom.emptyPara)[0];
+ if (next) {
+ rng.sc.insertBefore(nextPara, next);
+ } else {
+ rng.sc.appendChild(nextPara);
+ }
+ }
+
+ range.create(nextPara, 0).normalize().select();
+
+ };
+
+ };
+
+ return Typing;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/enable_summernote.js b/addons/web_editor/static/lib/summernote/src/js/enable_summernote.js
new file mode 100644
index 00000000..7169d856
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/enable_summernote.js
@@ -0,0 +1 @@
+odoo.__enable_summernote__ = true;
diff --git a/addons/web_editor/static/lib/summernote/src/js/intro.js b/addons/web_editor/static/lib/summernote/src/js/intro.js
new file mode 100644
index 00000000..357132eb
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/intro.js
@@ -0,0 +1,21 @@
+/**
+ * Super simple wysiwyg editor on Bootstrap v@VERSION
+ * http://summernote.org/
+ *
+ * summernote.js
+ * Copyright 2013-2015 Alan Hong. and other contributors
+ * summernote may be freely distributed under the MIT license./
+ *
+ * Date: @DATE
+ */
+(function (factory) {
+ /* global define */
+ if (typeof define === 'function' && define.amd) {
+ // AMD. Register as an anonymous module.
+ define(['jquery'], factory);
+ } else {
+ // Browser globals: jQuery
+ factory(window.jQuery);
+ }
+}(function ($) {
+ 'use strict';
diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Button.js b/addons/web_editor/static/lib/summernote/src/js/module/Button.js
new file mode 100644
index 00000000..c6d28494
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/module/Button.js
@@ -0,0 +1,169 @@
+define([
+ 'summernote/core/list',
+ 'summernote/core/agent'
+], function (list, agent) {
+ /**
+ * @class module.Button
+ *
+ * Button
+ */
+ var Button = function () {
+ /**
+ * update button status
+ *
+ * @param {jQuery} $container
+ * @param {Object} styleInfo
+ */
+ this.update = function ($container, styleInfo) {
+ /**
+ * handle dropdown's check mark (for fontname, fontsize, lineHeight).
+ * @param {jQuery} $btn
+ * @param {Number} value
+ */
+ var checkDropdownMenu = function ($btn, value) {
+ $btn.find('.dropdown-menu .dropdown-item').each(function () {
+ // always compare string to avoid creating another func.
+ var isChecked = ($(this).data('value') + '') === (value + '');
+ this.className = 'dropdown-item' + (isChecked ? ' checked' : '');
+ });
+ };
+
+ /**
+ * update button state(active or not).
+ *
+ * @private
+ * @param {String} selector
+ * @param {Function} pred
+ */
+ var btnState = function (selector, pred) {
+ var $btn = $container.find(selector);
+ $btn.toggleClass('active', pred());
+ };
+
+ if (styleInfo.image) {
+ var $img = $(styleInfo.image);
+
+ btnState('button[data-event="imageShape"][data-value="rounded"]', function () {
+ return $img.hasClass('rounded');
+ });
+ btnState('button[data-event="imageShape"][data-value="rounded-circle"]', function () {
+ return $img.hasClass('rounded-circle');
+ });
+ btnState('button[data-event="imageShape"][data-value="img-thumbnail"]', function () {
+ return $img.hasClass('img-thumbnail');
+ });
+ btnState('button[data-event="imageShape"]:not([data-value])', function () {
+ return !$img.is('.rounded, .rounded-circle, .img-thumbnail');
+ });
+
+ var imgFloat = $img.css('float');
+ btnState('button[data-event="floatMe"][data-value="left"]', function () {
+ return imgFloat === 'left';
+ });
+ btnState('button[data-event="floatMe"][data-value="right"]', function () {
+ return imgFloat === 'right';
+ });
+ btnState('button[data-event="floatMe"][data-value="none"]', function () {
+ return imgFloat !== 'left' && imgFloat !== 'right';
+ });
+
+ var style = $img.attr('style');
+ btnState('button[data-event="resize"][data-value="1"]', function () {
+ return !!/(^|\s)(max-)?width\s*:\s*100%/.test(style);
+ });
+ btnState('button[data-event="resize"][data-value="0.5"]', function () {
+ return !!/(^|\s)(max-)?width\s*:\s*50%/.test(style);
+ });
+ btnState('button[data-event="resize"][data-value="0.25"]', function () {
+ return !!/(^|\s)(max-)?width\s*:\s*25%/.test(style);
+ });
+ return;
+ }
+
+ // fontname
+ var $fontname = $container.find('.note-fontname');
+ if ($fontname.length) {
+ var selectedFont = styleInfo['font-family'];
+ if (!!selectedFont) {
+
+ var list = selectedFont.split(',');
+ for (var i = 0, len = list.length; i < len; i++) {
+ selectedFont = list[i].replace(/[\'\"]/g, '').replace(/\s+$/, '').replace(/^\s+/, '');
+ if (agent.isFontInstalled(selectedFont)) {
+ break;
+ }
+ }
+
+ $fontname.find('.note-current-fontname').text(selectedFont);
+ checkDropdownMenu($fontname, selectedFont);
+
+ }
+ }
+
+ // fontsize
+ var $fontsize = $container.find('.note-fontsize');
+ $fontsize.find('.note-current-fontsize').text(styleInfo['font-size']);
+ checkDropdownMenu($fontsize, parseFloat(styleInfo['font-size']));
+
+ // lineheight
+ var $lineHeight = $container.find('.note-height');
+ checkDropdownMenu($lineHeight, parseFloat(styleInfo['line-height']));
+
+ btnState('button[data-event="bold"]', function () {
+ return styleInfo['font-bold'] === 'bold';
+ });
+ btnState('button[data-event="italic"]', function () {
+ return styleInfo['font-italic'] === 'italic';
+ });
+ btnState('button[data-event="underline"]', function () {
+ return styleInfo['font-underline'] === 'underline';
+ });
+ btnState('button[data-event="strikethrough"]', function () {
+ return styleInfo['font-strikethrough'] === 'strikethrough';
+ });
+ btnState('button[data-event="superscript"]', function () {
+ return styleInfo['font-superscript'] === 'superscript';
+ });
+ btnState('button[data-event="subscript"]', function () {
+ return styleInfo['font-subscript'] === 'subscript';
+ });
+ btnState('button[data-event="justifyLeft"]', function () {
+ return styleInfo['text-align'] === 'left' || styleInfo['text-align'] === 'start';
+ });
+ btnState('button[data-event="justifyCenter"]', function () {
+ return styleInfo['text-align'] === 'center';
+ });
+ btnState('button[data-event="justifyRight"]', function () {
+ return styleInfo['text-align'] === 'right';
+ });
+ btnState('button[data-event="justifyFull"]', function () {
+ return styleInfo['text-align'] === 'justify';
+ });
+ btnState('button[data-event="insertUnorderedList"]', function () {
+ return styleInfo['list-style'] === 'unordered';
+ });
+ btnState('button[data-event="insertOrderedList"]', function () {
+ return styleInfo['list-style'] === 'ordered';
+ });
+ };
+
+ /**
+ * update recent color
+ *
+ * @param {Node} button
+ * @param {String} eventName
+ * @param {Mixed} value
+ */
+ this.updateRecentColor = function (button, eventName, value) {
+ var $color = $(button).closest('.note-color');
+ var $recentColor = $color.find('.note-recent-color');
+ var colorInfo = JSON.parse($recentColor.attr('data-value'));
+ colorInfo[eventName] = value;
+ $recentColor.attr('data-value', JSON.stringify(colorInfo));
+ var sKey = eventName === 'backColor' ? 'background-color' : 'color';
+ $recentColor.find('i').css(sKey, value);
+ };
+ };
+
+ return Button;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js b/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js
new file mode 100644
index 00000000..0e28726d
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js
@@ -0,0 +1,42 @@
+define([
+ 'summernote/core/list',
+ 'summernote/core/dom',
+ 'summernote/core/key',
+ 'summernote/core/agent',
+ 'summernote/core/range'
+], function (list, dom, key, agent, range) {
+ // ODOO override: use 0.8.10 version of this, adapted for the old summernote
+ // version odoo is using
+ var Clipboard = function (handler) {
+ /**
+ * paste by clipboard event
+ *
+ * @param {Event} event
+ */
+ var pasteByEvent = function (event) {
+ if (["INPUT", "TEXTAREA"].indexOf(event.target.tagName) !== -1) {
+ // ODOO override: from old summernote version
+ return;
+ }
+
+ var clipboardData = event.originalEvent.clipboardData;
+ var layoutInfo = dom.makeLayoutInfo(event.currentTarget || event.target);
+ var $editable = layoutInfo.editable();
+
+ if (clipboardData && clipboardData.items && clipboardData.items.length) {
+ var item = list.head(clipboardData.items);
+ if (item.kind === 'file' && item.type.indexOf('image/') !== -1) {
+ handler.insertImages(layoutInfo, [item.getAsFile()]);
+ event.preventDefault();
+ }
+ handler.invoke('editor.afterCommand', $editable);
+ }
+ };
+
+ this.attach = function (layoutInfo) {
+ layoutInfo.editable().on('paste', pasteByEvent);
+ };
+ };
+
+ return Clipboard;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Codeview.js b/addons/web_editor/static/lib/summernote/src/js/module/Codeview.js
new file mode 100644
index 00000000..46f3d96a
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/module/Codeview.js
@@ -0,0 +1,137 @@
+define([
+ 'summernote/core/agent',
+ 'summernote/core/dom'
+], function (agent, dom) {
+
+ var CodeMirror;
+ if (agent.hasCodeMirror) {
+ if (agent.isSupportAmd) {
+ require(['CodeMirror'], function (cm) {
+ CodeMirror = cm;
+ });
+ } else {
+ CodeMirror = window.CodeMirror;
+ }
+ }
+
+ /**
+ * @class Codeview
+ */
+ var Codeview = function (handler) {
+
+ this.sync = function (layoutInfo) {
+ var isCodeview = handler.invoke('codeview.isActivated', layoutInfo);
+ if (isCodeview && agent.hasCodeMirror) {
+ layoutInfo.codable().data('cmEditor').save();
+ }
+ };
+
+ /**
+ * @param {Object} layoutInfo
+ * @return {Boolean}
+ */
+ this.isActivated = function (layoutInfo) {
+ var $editor = layoutInfo.editor();
+ return $editor.hasClass('codeview');
+ };
+
+ /**
+ * toggle codeview
+ *
+ * @param {Object} layoutInfo
+ */
+ this.toggle = function (layoutInfo) {
+ if (this.isActivated(layoutInfo)) {
+ this.deactivate(layoutInfo);
+ } else {
+ this.activate(layoutInfo);
+ }
+ };
+
+ /**
+ * activate code view
+ *
+ * @param {Object} layoutInfo
+ */
+ this.activate = function (layoutInfo) {
+ var $editor = layoutInfo.editor(),
+ $toolbar = layoutInfo.toolbar(),
+ $editable = layoutInfo.editable(),
+ $codable = layoutInfo.codable(),
+ $popover = layoutInfo.popover(),
+ $handle = layoutInfo.handle();
+
+ var options = $editor.data('options');
+
+ $codable.val(dom.html($editable, options.prettifyHtml));
+ $codable.height($editable.height());
+
+ handler.invoke('toolbar.updateCodeview', $toolbar, true);
+ handler.invoke('popover.hide', $popover);
+ handler.invoke('handle.hide', $handle);
+
+ $editor.addClass('codeview');
+
+ $codable.focus();
+
+ // activate CodeMirror as codable
+ if (agent.hasCodeMirror) {
+ var cmEditor = CodeMirror.fromTextArea($codable[0], options.codemirror);
+
+ // CodeMirror TernServer
+ if (options.codemirror.tern) {
+ var server = new CodeMirror.TernServer(options.codemirror.tern);
+ cmEditor.ternServer = server;
+ cmEditor.on('cursorActivity', function (cm) {
+ server.updateArgHints(cm);
+ });
+ }
+
+ // CodeMirror hasn't Padding.
+ cmEditor.setSize(null, $editable.outerHeight());
+ $codable.data('cmEditor', cmEditor);
+ }
+ };
+
+ /**
+ * deactivate code view
+ *
+ * @param {Object} layoutInfo
+ */
+ this.deactivate = function (layoutInfo) {
+ var $holder = layoutInfo.holder(),
+ $editor = layoutInfo.editor(),
+ $toolbar = layoutInfo.toolbar(),
+ $editable = layoutInfo.editable(),
+ $codable = layoutInfo.codable();
+
+ var options = $editor.data('options');
+
+ // deactivate CodeMirror as codable
+ if (agent.hasCodeMirror) {
+ var cmEditor = $codable.data('cmEditor');
+ $codable.val(cmEditor.getValue());
+ cmEditor.toTextArea();
+ }
+
+ var value = dom.value($codable, options.prettifyHtml) || dom.emptyPara;
+ var isChange = $editable.html() !== value;
+
+ $editable.html(value);
+ $editable.height(options.height ? $codable.height() : 'auto');
+ $editor.removeClass('codeview');
+
+ if (isChange) {
+ handler.bindCustomEvent(
+ $holder, $editable.data('callbacks'), 'change'
+ )($editable.html(), $editable);
+ }
+
+ $editable.focus();
+
+ handler.invoke('toolbar.updateCodeview', $toolbar, false);
+ };
+ };
+
+ return Codeview;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/module/DragAndDrop.js b/addons/web_editor/static/lib/summernote/src/js/module/DragAndDrop.js
new file mode 100644
index 00000000..9e96dc32
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/module/DragAndDrop.js
@@ -0,0 +1,108 @@
+define([
+ 'summernote/core/dom'
+], function (dom) {
+ var DragAndDrop = function (handler) {
+ var $document = $(document);
+
+ /**
+ * attach Drag and Drop Events
+ *
+ * @param {Object} layoutInfo - layout Informations
+ * @param {Object} options
+ */
+ this.attach = function (layoutInfo, options) {
+ if (options.airMode || options.disableDragAndDrop) {
+ // prevent default drop event
+ $document.on('drop', function (e) {
+ e.preventDefault();
+ });
+ } else {
+ this.attachDragAndDropEvent(layoutInfo, options);
+ }
+ };
+
+ /**
+ * attach Drag and Drop Events
+ *
+ * @param {Object} layoutInfo - layout Informations
+ * @param {Object} options
+ */
+ this.attachDragAndDropEvent = function (layoutInfo, options) {
+ var collection = $(),
+ $editor = layoutInfo.editor(),
+ $dropzone = layoutInfo.dropzone(),
+ $dropzoneMessage = $dropzone.find('.note-dropzone-message');
+
+ // show dropzone on dragenter when dragging a object to document
+ // -but only if the editor is visible, i.e. has a positive width and height
+ $document.on('dragenter', function (e) {
+ var isCodeview = handler.invoke('codeview.isActivated', layoutInfo);
+ var hasEditorSize = $editor.width() > 0 && $editor.height() > 0;
+ if (!isCodeview && !collection.length && hasEditorSize) {
+ $editor.addClass('dragover');
+ $dropzone.width($editor.width());
+ $dropzone.height($editor.height());
+ $dropzoneMessage.text(options.langInfo.image.dragImageHere);
+ }
+ collection = collection.add(e.target);
+ }).on('dragleave', function (e) {
+ collection = collection.not(e.target);
+ if (!collection.length) {
+ $editor.removeClass('dragover');
+ }
+ }).on('drop', function () {
+ collection = $();
+ $editor.removeClass('dragover');
+ });
+
+ // change dropzone's message on hover.
+ $dropzone.on('dragenter', function () {
+ $dropzone.addClass('hover');
+ $dropzoneMessage.text(options.langInfo.image.dropImage);
+ }).on('dragleave', function () {
+ $dropzone.removeClass('hover');
+ $dropzoneMessage.text(options.langInfo.image.dragImageHere);
+ });
+
+ // attach dropImage
+ $dropzone.on('drop', function (event) {
+
+ var dataTransfer = event.originalEvent.dataTransfer;
+ var layoutInfo = dom.makeLayoutInfo(event.currentTarget || event.target);
+
+ /* ODOO: start_modification */
+ event.preventDefault();
+ /* ODOO: end_modification */
+
+ if (dataTransfer && dataTransfer.files && dataTransfer.files.length) {
+ event.preventDefault();
+ layoutInfo.editable().focus();
+ handler.insertImages(layoutInfo, dataTransfer.files);
+ } else {
+ var insertNodefunc = function () {
+ layoutInfo.holder().summernote('insertNode', this);
+ };
+
+ for (var i = 0, len = dataTransfer.types.length; i < len; i++) {
+ var type = dataTransfer.types[i];
+ var content = dataTransfer.getData(type);
+
+ /* ODOO: start_modification */
+ if (type.toLowerCase().indexOf('_moz_') > -1) {
+ return;
+ }
+ /* ODOO: end_modification */
+
+ if (type.toLowerCase().indexOf('text') > -1) {
+ layoutInfo.holder().summernote('pasteHTML', content);
+ } else {
+ $(content).each(insertNodefunc);
+ }
+ }
+ }
+ }).on('dragover', false); // prevent default dragover event
+ };
+ };
+
+ return DragAndDrop;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Editor.js b/addons/web_editor/static/lib/summernote/src/js/module/Editor.js
new file mode 100644
index 00000000..cdba12eb
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/module/Editor.js
@@ -0,0 +1,910 @@
+define([
+ 'summernote/core/agent',
+ 'summernote/core/func',
+ 'summernote/core/list',
+ 'summernote/core/dom',
+ 'summernote/core/range',
+ 'summernote/core/async',
+ 'summernote/editing/Style',
+ 'summernote/editing/Typing',
+ 'summernote/editing/Table',
+ 'summernote/editing/Bullet'
+], function (agent, func, list, dom, range, async,
+ Style, Typing, Table, Bullet) {
+
+ var KEY_BOGUS = 'bogus';
+
+ /**
+ * @class editing.Editor
+ *
+ * Editor
+ *
+ */
+ var Editor = function (handler) {
+
+ var self = this;
+ var style = new Style();
+ var table = new Table();
+ var typing = new Typing();
+ var bullet = new Bullet();
+
+ this.style = style; // ODOO: allow access for override
+ this.table = table; // ODOO: allow access for override
+ this.typing = typing; // ODOO: allow access for override
+ this.bullet = bullet; // ODOO: allow access for override
+
+ /**
+ * @method createRange
+ *
+ * create range
+ *
+ * @param {jQuery} $editable
+ * @return {WrappedRange}
+ */
+ this.createRange = function ($editable) {
+ this.focus($editable);
+ return range.create();
+ };
+
+ /**
+ * @method saveRange
+ *
+ * save current range
+ *
+ * @param {jQuery} $editable
+ * @param {Boolean} [thenCollapse=false]
+ */
+ this.saveRange = function ($editable, thenCollapse) {
+ // ODOO: scroll to top when click on input in editable m (start_modification
+ // this.focus($editable);
+ var r = range.create();
+ if (!r || ($editable[0] !== r.sc && !$.contains($editable[0], r.sc))) {
+ $editable.focus();
+ }
+ // ODOO: end_modication)
+ $editable.data('range', range.create());
+ if (thenCollapse) {
+ range.create().collapse().select();
+ }
+ };
+
+ /**
+ * @method saveRange
+ *
+ * save current node list to $editable.data('childNodes')
+ *
+ * @param {jQuery} $editable
+ */
+ this.saveNode = function ($editable) {
+ // copy child node reference
+ var copy = [];
+ for (var key = 0, len = $editable[0].childNodes.length; key < len; key++) {
+ copy.push($editable[0].childNodes[key]);
+ }
+ $editable.data('childNodes', copy);
+ };
+
+ /**
+ * @method restoreRange
+ *
+ * restore lately range
+ *
+ * @param {jQuery} $editable
+ */
+ this.restoreRange = function ($editable) {
+ var rng = $editable.data('range');
+ if (rng) {
+ rng.select();
+ this.focus($editable);
+ }
+ };
+
+ /**
+ * @method restoreNode
+ *
+ * restore lately node list
+ *
+ * @param {jQuery} $editable
+ */
+ this.restoreNode = function ($editable) {
+ $editable.html('');
+ var child = $editable.data('childNodes');
+ for (var index = 0, len = child.length; index < len; index++) {
+ $editable[0].appendChild(child[index]);
+ }
+ };
+
+ /**
+ * @method currentStyle
+ *
+ * current style
+ *
+ * @param {Node} target
+ * @return {Object|Boolean} unfocus
+ */
+ this.currentStyle = function (target) {
+ var rng = range.create();
+ var styleInfo = rng && rng.isOnEditable() ? style.current(rng.normalize()) : {};
+ if (dom.isImg(target)) {
+ styleInfo.image = target;
+ }
+ return styleInfo;
+ };
+
+ /**
+ * style from node
+ *
+ * @param {jQuery} $node
+ * @return {Object}
+ */
+ this.styleFromNode = function ($node) {
+ return style.fromNode($node);
+ };
+
+ var triggerOnBeforeChange = function ($editable) {
+ var $holder = dom.makeLayoutInfo($editable).holder();
+ handler.bindCustomEvent(
+ $holder, $editable.data('callbacks'), 'before.command'
+ )($editable.html(), $editable);
+ };
+
+ var triggerOnChange = function ($editable) {
+ var $holder = dom.makeLayoutInfo($editable).holder();
+ handler.bindCustomEvent(
+ $holder, $editable.data('callbacks'), 'change'
+ )($editable.html(), $editable);
+ };
+
+ /**
+ * @method undo
+ * undo
+ * @param {jQuery} $editable
+ */
+ this.undo = function ($editable) {
+ triggerOnBeforeChange($editable);
+ $editable.data('NoteHistory').undo();
+ triggerOnChange($editable);
+ };
+
+ /**
+ * @method redo
+ * redo
+ * @param {jQuery} $editable
+ */
+ this.redo = function ($editable) {
+ triggerOnBeforeChange($editable);
+ $editable.data('NoteHistory').redo();
+ triggerOnChange($editable);
+ };
+
+ /**
+ * @method beforeCommand
+ * before command
+ * @param {jQuery} $editable
+ */
+ var beforeCommand = this.beforeCommand = function ($editable) {
+ triggerOnBeforeChange($editable);
+ // keep focus on editable before command execution
+ self.focus($editable);
+ };
+
+ /**
+ * @method afterCommand
+ * after command
+ * @param {jQuery} $editable
+ * @param {Boolean} isPreventTrigger
+ */
+ var afterCommand = this.afterCommand = function ($editable, isPreventTrigger) {
+ $editable.data('NoteHistory').recordUndo();
+ if (!isPreventTrigger) {
+ triggerOnChange($editable);
+ }
+ };
+
+ /**
+ * @method bold
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method italic
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method underline
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method strikethrough
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method formatBlock
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method superscript
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method subscript
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method justifyLeft
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method justifyCenter
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method justifyRight
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method justifyFull
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method formatBlock
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method removeFormat
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method backColor
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method foreColor
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method insertHorizontalRule
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /**
+ * @method fontName
+ *
+ * change font name
+ *
+ * @param {jQuery} $editable
+ * @param {Mixed} value
+ */
+
+ /* jshint ignore:start */
+ // native commands(with execCommand), generate function for execCommand
+ var commands = ['bold', 'italic', 'underline', 'strikethrough', 'superscript', 'subscript',
+ 'justifyLeft', 'justifyCenter', 'justifyRight', 'justifyFull',
+ 'formatBlock', 'removeFormat',
+ 'backColor', 'foreColor', 'fontName'];
+
+ for (var idx = 0, len = commands.length; idx < len; idx ++) {
+ this[commands[idx]] = (function (sCmd) {
+ return function ($editable, value) {
+ beforeCommand($editable);
+
+ document.execCommand(sCmd, false, value);
+
+ afterCommand($editable, true);
+ };
+ })(commands[idx]);
+ }
+ /* jshint ignore:end */
+
+ /**
+ * @method tab
+ *
+ * handle tab key
+ *
+ * @param {jQuery} $editable
+ * @param {Object} options
+ */
+ this.tab = function ($editable, options) {
+ var rng = this.createRange($editable);
+ if (rng.isCollapsed() && rng.isOnCell()) {
+ table.tab(rng);
+ } else {
+ beforeCommand($editable);
+ typing.insertTab($editable, rng, options.tabsize);
+ afterCommand($editable);
+ }
+ };
+
+ /**
+ * @method untab
+ *
+ * handle shift+tab key
+ *
+ */
+ this.untab = function ($editable) {
+ var rng = this.createRange($editable);
+ if (rng.isCollapsed() && rng.isOnCell()) {
+ table.tab(rng, true);
+ }
+ };
+
+ /**
+ * @method insertParagraph
+ *
+ * insert paragraph
+ *
+ * @param {Node} $editable
+ */
+ this.insertParagraph = function ($editable) {
+ beforeCommand($editable);
+ typing.insertParagraph($editable);
+ afterCommand($editable);
+ };
+
+ /**
+ * @method insertOrderedList
+ *
+ * @param {jQuery} $editable
+ */
+ this.insertOrderedList = function ($editable) {
+ beforeCommand($editable);
+ bullet.insertOrderedList($editable);
+ afterCommand($editable);
+ };
+
+ /**
+ * @param {jQuery} $editable
+ */
+ this.insertUnorderedList = function ($editable) {
+ beforeCommand($editable);
+ bullet.insertUnorderedList($editable);
+ afterCommand($editable);
+ };
+
+ /**
+ * @param {jQuery} $editable
+ */
+ this.indent = function ($editable) {
+ beforeCommand($editable);
+ bullet.indent($editable);
+ afterCommand($editable);
+ };
+
+ /**
+ * @param {jQuery} $editable
+ */
+ this.outdent = function ($editable) {
+ beforeCommand($editable);
+ bullet.outdent($editable);
+ afterCommand($editable);
+ };
+
+ /**
+ * insert image
+ *
+ * @param {jQuery} $editable
+ * @param {String} sUrl
+ */
+ this.insertImage = function ($editable, sUrl, filename) {
+ async.createImage(sUrl, filename).then(function ($image) {
+ beforeCommand($editable);
+ $image.css({
+ display: '',
+ width: Math.min($editable.width(), $image.width())
+ });
+ range.create().insertNode($image[0]);
+ range.createFromNodeAfter($image[0]).select();
+ afterCommand($editable);
+ }).fail(function () {
+ var $holder = dom.makeLayoutInfo($editable).holder();
+ handler.bindCustomEvent(
+ $holder, $editable.data('callbacks'), 'image.upload.error'
+ )();
+ });
+ };
+
+ /**
+ * @method insertNode
+ * insert node
+ * @param {Node} $editable
+ * @param {Node} node
+ */
+ this.insertNode = function ($editable, node) {
+ beforeCommand($editable);
+ range.create().insertNode(node);
+ range.createFromNodeAfter(node).select();
+ afterCommand($editable);
+ };
+
+ /**
+ * insert text
+ * @param {Node} $editable
+ * @param {String} text
+ */
+ this.insertText = function ($editable, text) {
+ beforeCommand($editable);
+ var textNode = range.create().insertNode(dom.createText(text));
+ range.create(textNode, dom.nodeLength(textNode)).select();
+ afterCommand($editable);
+ };
+
+ /**
+ * paste HTML
+ * @param {Node} $editable
+ * @param {String} markup
+ */
+ this.pasteHTML = function ($editable, markup) {
+ beforeCommand($editable);
+ var contents = range.create().pasteHTML(markup);
+ range.createFromNodeAfter(list.last(contents)).select();
+ afterCommand($editable);
+ };
+
+ /**
+ * formatBlock
+ *
+ * @param {jQuery} $editable
+ * @param {String} tagName
+ */
+ this.formatBlock = function ($editable, tagName) {
+ beforeCommand($editable);
+ // [workaround] for MSIE, IE need `<`
+ tagName = agent.isMSIE ? '<' + tagName + '>' : tagName;
+ document.execCommand('FormatBlock', false, tagName);
+ afterCommand($editable);
+ };
+
+ this.formatPara = function ($editable) {
+ beforeCommand($editable);
+ this.formatBlock($editable, 'P');
+ afterCommand($editable);
+ };
+
+ /* jshint ignore:start */
+ for (var idx = 1; idx <= 6; idx ++) {
+ this['formatH' + idx] = function (idx) {
+ return function ($editable) {
+ this.formatBlock($editable, 'H' + idx);
+ };
+ }(idx);
+ };
+ /* jshint ignore:end */
+
+ /**
+ * fontSize
+ *
+ * @param {jQuery} $editable
+ * @param {String} value - px
+ */
+ this.fontSize = function ($editable, value) {
+ var rng = range.create();
+
+ if (rng.isCollapsed()) {
+ var spans = style.styleNodes(rng);
+ var firstSpan = list.head(spans);
+
+ $(spans).css({
+ 'font-size': value + 'px'
+ });
+
+ // [workaround] added styled bogus span for style
+ // - also bogus character needed for cursor position
+ if (firstSpan && !dom.nodeLength(firstSpan)) {
+ firstSpan.innerHTML = dom.ZERO_WIDTH_NBSP_CHAR;
+ range.createFromNodeAfter(firstSpan.firstChild).select();
+ $editable.data(KEY_BOGUS, firstSpan);
+ }
+ } else {
+ beforeCommand($editable);
+ $(style.styleNodes(rng)).css({
+ 'font-size': value + 'px'
+ });
+ afterCommand($editable);
+ }
+ };
+
+ /**
+ * insert horizontal rule
+ * @param {jQuery} $editable
+ */
+ this.insertHorizontalRule = function ($editable) {
+ beforeCommand($editable);
+
+ var rng = range.create();
+ var hrNode = rng.insertNode($('<HR/>')[0]);
+ if (hrNode.nextSibling) {
+ range.create(hrNode.nextSibling, 0).normalize().select();
+ }
+
+ afterCommand($editable);
+ };
+
+ /**
+ * remove bogus node and character
+ */
+ this.removeBogus = function ($editable) {
+ var bogusNode = $editable.data(KEY_BOGUS);
+ if (!bogusNode) {
+ return;
+ }
+
+ var textNode = list.find(list.from(bogusNode.childNodes), dom.isText);
+
+ var bogusCharIdx = textNode.nodeValue.indexOf(dom.ZERO_WIDTH_NBSP_CHAR);
+ if (bogusCharIdx !== -1) {
+ textNode.deleteData(bogusCharIdx, 1);
+ }
+
+ if (dom.isEmpty(bogusNode)) {
+ dom.remove(bogusNode);
+ }
+
+ $editable.removeData(KEY_BOGUS);
+ };
+
+ /**
+ * lineHeight
+ * @param {jQuery} $editable
+ * @param {String} value
+ */
+ this.lineHeight = function ($editable, value) {
+ beforeCommand($editable);
+ style.stylePara(range.create(), {
+ lineHeight: value
+ });
+ afterCommand($editable);
+ };
+
+ /**
+ * unlink
+ *
+ * @type command
+ *
+ * @param {jQuery} $editable
+ */
+ this.unlink = function ($editable) {
+ var rng = this.createRange($editable);
+ if (rng.isOnAnchor()) {
+ var anchor = dom.ancestor(rng.sc, dom.isAnchor);
+ rng = range.createFromNode(anchor);
+ rng.select();
+
+ beforeCommand($editable);
+ document.execCommand('unlink');
+ afterCommand($editable);
+ }
+ };
+
+ /**
+ * create link (command)
+ *
+ * @param {jQuery} $editable
+ * @param {Object} linkInfo
+ * @param {Object} options
+ */
+ this.createLink = function ($editable, linkInfo, options) {
+ var linkUrl = linkInfo.url;
+ var linkText = linkInfo.text;
+ var isNewWindow = linkInfo.isNewWindow;
+ var rng = linkInfo.range || this.createRange($editable);
+ var isTextChanged = rng.toString() !== linkText;
+ // Hack : This method was updated to create buttons as well (using the same logic as anchor nodes).
+ const nodeName = linkInfo.isButton ? 'BUTTON' : 'A';
+ const pred = dom.makePredByNodeName(nodeName);
+
+ options = options || dom.makeLayoutInfo($editable).editor().data('options');
+
+ beforeCommand($editable);
+
+ if (options.onCreateLink) {
+ linkUrl = options.onCreateLink(linkUrl);
+ }
+
+ var anchors = [];
+ // ODOO: adding this branch to modify existing anchor if it fully contains the range
+ var ancestor_anchor = dom.ancestor(rng.sc, pred);
+ if (ancestor_anchor && ancestor_anchor === dom.ancestor(rng.ec, pred)) {
+ anchors.push($(ancestor_anchor).html(linkText).get(0));
+ } else if (isTextChanged) {
+ // Create a new element when text changed.
+ var anchor = rng.insertNode($(`<${nodeName}>${linkText}</${nodeName}>`)[0]);
+ anchors.push(anchor);
+ } else {
+ anchors = style.styleNodes(rng, {
+ nodeName: nodeName,
+ expandClosestSibling: true,
+ onlyPartialContains: true
+ });
+ }
+
+ $.each(anchors, function (idx, anchor) {
+ if (!linkInfo.isButton) {
+ $(anchor).attr('href', linkUrl);
+ }
+ $(anchor).attr('class', linkInfo.className || null); // ODOO: addition
+ $(anchor).css(linkInfo.style || {}); // ODOO: addition
+ if (isNewWindow) {
+ $(anchor).attr('target', '_blank');
+ } else {
+ $(anchor).removeAttr('target');
+ }
+ });
+
+ var startRange = range.createFromNodeBefore(list.head(anchors));
+ var startPoint = startRange.getStartPoint();
+ var endRange = range.createFromNodeAfter(list.last(anchors));
+ var endPoint = endRange.getEndPoint();
+
+ range.create(
+ startPoint.node,
+ startPoint.offset,
+ endPoint.node,
+ endPoint.offset
+ ).select();
+
+ afterCommand($editable);
+ };
+
+ /**
+ * returns link info
+ * Hack : This method was updated to return a boolean attribute 'isButton' to allow
+ * handling buttons in linkDialog.
+ *
+ * @return {Object}
+ * @return {WrappedRange} return.range
+ * @return {String} return.text
+ * @return {Boolean} [return.isNewWindow=true]
+ * @return {String} [return.url=""]
+ */
+ this.getLinkInfo = function ($editable) {
+ // ODOO MODIFICATION START
+ var selection;
+ var currentSelection = null;
+ if (document.getSelection) {
+ selection = document.getSelection();
+ if (selection.getRangeAt && selection.rangeCount) {
+ currentSelection = selection.getRangeAt(0);
+ }
+ }
+ // ODOO MODIFICATION END
+
+ this.focus($editable);
+
+ // ODOO MODIFICATION START
+ if (currentSelection && document.getSelection) {
+ selection = document.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(currentSelection);
+ }
+ // ODOO MODIFICATION END
+
+ var rng = range.create().expand(dom.isAnchor);
+
+ // Get the first anchor on range(for edit).
+ var anchor = list.head(rng.nodes(dom.isAnchor));
+ const $anchor = $(anchor);
+
+ if ($anchor.length && !rng.nodes()[0].isSameNode(anchor)) {
+ rng = range.createFromNode(anchor);
+ rng.select();
+ }
+
+ // Check if the target is a button element.
+ let isButton = false;
+ if (!$anchor.length) {
+ const pred = dom.makePredByNodeName('BUTTON');
+ const rngNew = range.create().expand(pred);
+ const target = list.head(rngNew.nodes(pred));
+ if (target && target.nodeName === 'BUTTON') {
+ isButton = true;
+ rng = rngNew;
+ }
+ }
+
+ return {
+ range: rng,
+ text: rng.toString(),
+ isNewWindow: $anchor.length ? $anchor.attr('target') === '_blank' : false,
+ url: $anchor.length ? $anchor.attr('href') : '',
+ isButton: isButton,
+ };
+ };
+
+ /**
+ * setting color
+ *
+ * @param {Node} $editable
+ * @param {Object} sObjColor color code
+ * @param {String} sObjColor.foreColor foreground color
+ * @param {String} sObjColor.backColor background color
+ */
+ this.color = function ($editable, sObjColor) {
+ var oColor = JSON.parse(sObjColor);
+ var foreColor = oColor.foreColor, backColor = oColor.backColor;
+
+ beforeCommand($editable);
+
+ if (foreColor) { document.execCommand('foreColor', false, foreColor); }
+ if (backColor) { document.execCommand('backColor', false, backColor); }
+
+ afterCommand($editable);
+ };
+
+ /**
+ * insert Table
+ *
+ * @param {Node} $editable
+ * @param {String} sDim dimension of table (ex : "5x5")
+ */
+ this.insertTable = function ($editable, sDim) {
+ var dimension = sDim.split('x');
+ beforeCommand($editable);
+
+ var rng = range.create().deleteContents();
+ rng.insertNode(table.createTable(dimension[0], dimension[1]));
+ afterCommand($editable);
+ };
+
+ /**
+ * float me
+ *
+ * @param {jQuery} $editable
+ * @param {String} value
+ * @param {jQuery} $target
+ */
+ this.floatMe = function ($editable, value, $target) {
+ beforeCommand($editable);
+ // bootstrap
+ $target.removeClass('float-left float-right');
+ if (value && value !== 'none') {
+ $target.addClass('pull-' + value);
+ }
+
+ // fallback for non-bootstrap
+ $target.css('float', value);
+ afterCommand($editable);
+ };
+
+ /**
+ * change image shape
+ *
+ * @param {jQuery} $editable
+ * @param {String} value css class
+ * @param {Node} $target
+ */
+ this.imageShape = function ($editable, value, $target) {
+ beforeCommand($editable);
+
+ $target.removeClass('rounded rounded-circle img-thumbnail');
+
+ if (value) {
+ $target.addClass(value);
+ }
+
+ afterCommand($editable);
+ };
+
+ /**
+ * resize overlay element
+ * @param {jQuery} $editable
+ * @param {String} value
+ * @param {jQuery} $target - target element
+ */
+ this.resize = function ($editable, value, $target) {
+ beforeCommand($editable);
+
+ $target.css({
+ width: value * 100 + '%',
+ height: ''
+ });
+
+ afterCommand($editable);
+ };
+
+ /**
+ * @param {Position} pos
+ * @param {jQuery} $target - target element
+ * @param {Boolean} [bKeepRatio] - keep ratio
+ */
+ this.resizeTo = function (pos, $target, bKeepRatio) {
+ var imageSize;
+ if (bKeepRatio) {
+ var newRatio = pos.y / pos.x;
+ var ratio = $target.data('ratio');
+ imageSize = {
+ width: ratio > newRatio ? pos.x : pos.y / ratio,
+ height: ratio > newRatio ? pos.x * ratio : pos.y
+ };
+ } else {
+ imageSize = {
+ width: pos.x,
+ height: pos.y
+ };
+ }
+
+ $target.css(imageSize);
+ };
+
+ /**
+ * remove media object
+ *
+ * @param {jQuery} $editable
+ * @param {String} value - dummy argument (for keep interface)
+ * @param {jQuery} $target - target element
+ */
+ this.removeMedia = function ($editable, value, $target) {
+ beforeCommand($editable);
+ $target.detach();
+
+ handler.bindCustomEvent(
+ $(), $editable.data('callbacks'), 'media.delete'
+ )($target, $editable);
+
+ afterCommand($editable);
+ };
+
+ /**
+ * set focus
+ *
+ * @param $editable
+ */
+ this.focus = function ($editable) {
+ $editable.focus();
+
+ // [workaround] for firefox bug http://goo.gl/lVfAaI
+ if (agent.isFF) {
+ var rng = range.create();
+ if (!rng || rng.isOnEditable()) {
+ return;
+ }
+
+ range.createFromNode($editable[0])
+ .normalize()
+ .collapse()
+ .select();
+ }
+ };
+
+ /**
+ * returns whether contents is empty or not.
+ *
+ * @param {jQuery} $editable
+ * @return {Boolean}
+ */
+ this.isEmpty = function ($editable) {
+ return dom.isEmpty($editable[0]) || dom.emptyPara === $editable.html();
+ };
+ };
+
+ return Editor;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Fullscreen.js b/addons/web_editor/static/lib/summernote/src/js/module/Fullscreen.js
new file mode 100644
index 00000000..fd837483
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/module/Fullscreen.js
@@ -0,0 +1,51 @@
+define([], function () { // ODOO: remove error from Odoo define
+ var Fullscreen = function (handler) {
+ var $window = $(window);
+ var $scrollbar = $('html, body');
+
+ /**
+ * toggle fullscreen
+ *
+ * @param {Object} layoutInfo
+ */
+ this.toggle = function (layoutInfo) {
+
+ var $editor = layoutInfo.editor(),
+ $toolbar = layoutInfo.toolbar(),
+ $editable = layoutInfo.editable(),
+ $codable = layoutInfo.codable();
+
+ var resize = function (size) {
+ $editable.css('height', size.h);
+ $codable.css('height', size.h);
+ if ($codable.data('cmeditor')) {
+ $codable.data('cmeditor').setsize(null, size.h);
+ }
+ };
+
+ $editor.toggleClass('fullscreen');
+ var isFullscreen = $editor.hasClass('fullscreen');
+ if (isFullscreen) {
+ $editable.data('orgheight', $editable.css('height'));
+
+ $window.on('resize', function () {
+ resize({
+ h: $window.height() - $toolbar.outerHeight()
+ });
+ }).trigger('resize');
+
+ $scrollbar.css('overflow', 'hidden');
+ } else {
+ $window.off('resize');
+ resize({
+ h: $editable.data('orgheight')
+ });
+ $scrollbar.css('overflow', 'visible');
+ }
+
+ handler.invoke('toolbar.updateFullscreen', $toolbar, isFullscreen);
+ };
+ };
+
+ return Fullscreen;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Handle.js b/addons/web_editor/static/lib/summernote/src/js/module/Handle.js
new file mode 100644
index 00000000..3b62304d
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/module/Handle.js
@@ -0,0 +1,101 @@
+define([
+ 'summernote/core/dom'
+], function (dom) {
+ /**
+ * @class module.Handle
+ *
+ * Handle
+ */
+ var Handle = function (handler) {
+ var $document = $(document);
+
+ /**
+ * `mousedown` event handler on $handle
+ * - controlSizing: resize image
+ *
+ * @param {MouseEvent} event
+ */
+ var hHandleMousedown = function (event) {
+ if (dom.isControlSizing(event.target)) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ var layoutInfo = dom.makeLayoutInfo(event.target),
+ $handle = layoutInfo.handle(),
+ $popover = layoutInfo.popover(),
+ $editable = layoutInfo.editable(),
+ $editor = layoutInfo.editor();
+
+ var target = $handle.find('.note-control-selection').data('target'),
+ $target = $(target), posStart = $target.offset(),
+ scrollTop = $document.scrollTop();
+
+ var isAirMode = $editor.data('options').airMode;
+
+ $document.on('mousemove', function (event) {
+ handler.invoke('editor.resizeTo', {
+ x: event.clientX - posStart.left,
+ y: event.clientY - (posStart.top - scrollTop)
+ }, $target, !event.shiftKey);
+
+ handler.invoke('handle.update', $handle, {image: target}, isAirMode);
+ handler.invoke('popover.update', $popover, {image: target}, isAirMode);
+ }).one('mouseup', function () {
+ $document.off('mousemove');
+ handler.invoke('editor.afterCommand', $editable);
+ });
+
+ if (!$target.data('ratio')) { // original ratio.
+ $target.data('ratio', $target.height() / $target.width());
+ }
+ }
+ };
+
+ this.attach = function (layoutInfo) {
+ layoutInfo.handle().on('mousedown', hHandleMousedown);
+ };
+
+ /**
+ * update handle
+ * @param {jQuery} $handle
+ * @param {Object} styleInfo
+ * @param {Boolean} isAirMode
+ */
+ this.update = function ($handle, styleInfo, isAirMode) {
+ var $selection = $handle.find('.note-control-selection');
+ if (styleInfo.image) {
+ var $image = $(styleInfo.image);
+ var pos = isAirMode ? $image.offset() : $image.position();
+
+ // include margin
+ var imageSize = {
+ w: $image.outerWidth(true),
+ h: $image.outerHeight(true)
+ };
+
+ $selection.css({
+ display: 'block',
+ left: pos.left,
+ top: pos.top,
+ width: imageSize.w,
+ height: imageSize.h
+ }).data('target', styleInfo.image); // save current image element.
+ var sizingText = imageSize.w + 'x' + imageSize.h;
+ $selection.find('.note-control-selection-info').text(sizingText);
+ } else {
+ $selection.hide();
+ }
+ };
+
+ /**
+ * hide
+ *
+ * @param {jQuery} $handle
+ */
+ this.hide = function ($handle) {
+ $handle.children().hide();
+ };
+ };
+
+ return Handle;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/module/HelpDialog.js b/addons/web_editor/static/lib/summernote/src/js/module/HelpDialog.js
new file mode 100644
index 00000000..e4ff7b77
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/module/HelpDialog.js
@@ -0,0 +1,35 @@
+define([], function () { // ODOO: remove error from Odoo define
+ var HelpDialog = function (handler) {
+ /**
+ * show help dialog
+ *
+ * @param {jQuery} $editable
+ * @param {jQuery} $dialog
+ * @return {Promise}
+ */
+ this.showHelpDialog = function ($editable, $dialog) {
+ return $.Deferred(function (deferred) {
+ var $helpDialog = $dialog.find('.note-help-dialog');
+
+ $helpDialog.one('hidden.bs.modal', function () {
+ deferred.resolve();
+ }).modal('show');
+ }).promise();
+ };
+
+ /**
+ * @param {Object} layoutInfo
+ */
+ this.show = function (layoutInfo) {
+ var $dialog = layoutInfo.dialog(),
+ $editable = layoutInfo.editable();
+
+ handler.invoke('editor.saveRange', $editable, true);
+ this.showHelpDialog($editable, $dialog).then(function () {
+ handler.invoke('editor.restoreRange', $editable);
+ });
+ };
+ };
+
+ return HelpDialog;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/module/ImageDialog.js b/addons/web_editor/static/lib/summernote/src/js/module/ImageDialog.js
new file mode 100644
index 00000000..04292118
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/module/ImageDialog.js
@@ -0,0 +1,110 @@
+define([
+ 'summernote/core/key'
+], function (key) {
+ var ImageDialog = function (handler) {
+ /**
+ * toggle button status
+ *
+ * @private
+ * @param {jQuery} $btn
+ * @param {Boolean} isEnable
+ */
+ var toggleBtn = function ($btn, isEnable) {
+ $btn.toggleClass('disabled', !isEnable);
+ $btn.attr('disabled', !isEnable);
+ };
+
+ /**
+ * bind enter key
+ *
+ * @private
+ * @param {jQuery} $input
+ * @param {jQuery} $btn
+ */
+ var bindEnterKey = function ($input, $btn) {
+ $input.on('keypress', function (event) {
+ if (event.keyCode === key.code.ENTER) {
+ $btn.trigger('click');
+ }
+ });
+ };
+
+ this.show = function (layoutInfo) {
+ var $dialog = layoutInfo.dialog(),
+ $editable = layoutInfo.editable();
+
+ handler.invoke('editor.saveRange', $editable);
+ this.showImageDialog($editable, $dialog).then(function (data) {
+ handler.invoke('editor.restoreRange', $editable);
+
+ if (typeof data === 'string') {
+ // image url
+ handler.invoke('editor.insertImage', $editable, data);
+ } else {
+ // array of files
+ handler.insertImages(layoutInfo, data);
+ }
+ }).fail(function () {
+ handler.invoke('editor.restoreRange', $editable);
+ });
+ };
+
+ /**
+ * show image dialog
+ *
+ * @param {jQuery} $editable
+ * @param {jQuery} $dialog
+ * @return {Promise}
+ */
+ this.showImageDialog = function ($editable, $dialog) {
+ return $.Deferred(function (deferred) {
+ var $imageDialog = $dialog.find('.note-image-dialog');
+
+ var $imageInput = $dialog.find('.note-image-input'),
+ $imageUrl = $dialog.find('.note-image-url'),
+ $imageBtn = $dialog.find('.note-image-btn');
+
+ $imageDialog.one('shown.bs.modal', function () {
+ // Cloning imageInput to clear element.
+ $imageInput.replaceWith($imageInput.clone()
+ .on('change', function () {
+ deferred.resolve(this.files || this.value);
+ $imageDialog.modal('hide');
+ })
+ .val('')
+ );
+
+ $imageBtn.click(function (event) {
+ event.preventDefault();
+
+ deferred.resolve($imageUrl.val());
+ $imageDialog.modal('hide');
+ });
+
+ $imageUrl.on('keyup paste', function (event) {
+ var url;
+
+ if (event.type === 'paste') {
+ url = event.originalEvent.clipboardData.getData('text');
+ } else {
+ url = $imageUrl.val();
+ }
+
+ toggleBtn($imageBtn, url);
+ }).val('').trigger('focus');
+ bindEnterKey($imageUrl, $imageBtn);
+ }).one('hidden.bs.modal', function () {
+ $imageInput.off('change');
+ $imageUrl.off('keyup paste keypress');
+ $imageBtn.off('click');
+
+ if (deferred.state() === 'pending') {
+ deferred.reject();
+ }
+ }).modal('show');
+ });
+ };
+ };
+
+ return ImageDialog;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/module/LinkDialog.js b/addons/web_editor/static/lib/summernote/src/js/module/LinkDialog.js
new file mode 100644
index 00000000..045119ee
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/module/LinkDialog.js
@@ -0,0 +1,129 @@
+define([
+ 'summernote/core/key'
+], function (key) {
+ var LinkDialog = function (handler) {
+
+ /**
+ * toggle button status
+ *
+ * @private
+ * @param {jQuery} $btn
+ * @param {Boolean} isEnable
+ */
+ var toggleBtn = function ($btn, isEnable) {
+ $btn.toggleClass('disabled', !isEnable);
+ $btn.attr('disabled', !isEnable);
+ };
+
+ /**
+ * bind enter key
+ *
+ * @private
+ * @param {jQuery} $input
+ * @param {jQuery} $btn
+ */
+ var bindEnterKey = function ($input, $btn) {
+ $input.on('keypress', function (event) {
+ if (event.keyCode === key.code.ENTER) {
+ $btn.trigger('click');
+ }
+ });
+ };
+
+ /**
+ * Show link dialog and set event handlers on dialog controls.
+ *
+ * @param {jQuery} $editable
+ * @param {jQuery} $dialog
+ * @param {Object} linkInfo
+ * @return {Promise}
+ */
+ this.showLinkDialog = function ($editable, $dialog, linkInfo) {
+ return $.Deferred(function (deferred) {
+ var $linkDialog = $dialog.find('.note-link-dialog');
+
+ var $linkText = $linkDialog.find('.note-link-text'),
+ $linkUrl = $linkDialog.find('.note-link-url'),
+ $linkBtn = $linkDialog.find('.note-link-btn'),
+ $openInNewWindow = $linkDialog.find('input[type=checkbox]');
+
+ $linkDialog.one('shown.bs.modal', function () {
+ $linkText.val(linkInfo.text);
+
+ $linkText.on('input', function () {
+ toggleBtn($linkBtn, $linkText.val() && $linkUrl.val());
+ // if linktext was modified by keyup,
+ // stop cloning text from linkUrl
+ linkInfo.text = $linkText.val();
+ });
+
+ // if no url was given, copy text to url
+ if (!linkInfo.url) {
+ linkInfo.url = linkInfo.text || 'http://';
+ toggleBtn($linkBtn, linkInfo.text);
+ }
+
+ $linkUrl.on('input', function () {
+ toggleBtn($linkBtn, $linkText.val() && $linkUrl.val());
+ // display same link on `Text to display` input
+ // when create a new link
+ if (!linkInfo.text) {
+ $linkText.val($linkUrl.val());
+ }
+ }).val(linkInfo.url).trigger('focus').trigger('select');
+
+ bindEnterKey($linkUrl, $linkBtn);
+ bindEnterKey($linkText, $linkBtn);
+
+ $openInNewWindow.prop('checked', linkInfo.isNewWindow);
+
+ $linkBtn.one('click', function (event) {
+ event.preventDefault();
+
+ deferred.resolve({
+ range: linkInfo.range,
+ url: $linkUrl.val(),
+ text: $linkText.val(),
+ isNewWindow: $openInNewWindow.is(':checked')
+ });
+ $linkDialog.modal('hide');
+ });
+ }).one('hidden.bs.modal', function () {
+ // detach events
+ $linkText.off('input keypress');
+ $linkUrl.off('input keypress');
+ $linkBtn.off('click');
+
+ if (deferred.state() === 'pending') {
+ deferred.reject();
+ }
+ }).modal('show');
+ }).promise();
+ };
+
+ /**
+ * @param {Object} layoutInfo
+ */
+ this.show = function (layoutInfo) {
+ var $editor = layoutInfo.editor(),
+ $dialog = layoutInfo.dialog(),
+ $editable = layoutInfo.editable(),
+ $popover = layoutInfo.popover(),
+ linkInfo = handler.invoke('editor.getLinkInfo', $editable);
+
+ var options = $editor.data('options');
+
+ handler.invoke('editor.saveRange', $editable);
+ this.showLinkDialog($editable, $dialog, linkInfo).then(function (linkInfo) {
+ handler.invoke('editor.restoreRange', $editable);
+ handler.invoke('editor.createLink', $editable, linkInfo, options);
+ // hide popover after creating link
+ handler.invoke('popover.hide', $popover);
+ }).fail(function () {
+ handler.invoke('editor.restoreRange', $editable);
+ });
+ };
+ };
+
+ return LinkDialog;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Popover.js b/addons/web_editor/static/lib/summernote/src/js/module/Popover.js
new file mode 100644
index 00000000..00061f39
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/module/Popover.js
@@ -0,0 +1,129 @@
+define([
+ 'summernote/core/func',
+ 'summernote/core/list',
+ 'summernote/module/Button'
+], function (func, list, Button) {
+ /**
+ * @class module.Popover
+ *
+ * Popover (http://getbootstrap.com/javascript/#popovers)
+ *
+ */
+ var Popover = function () {
+ var button = new Button();
+
+ this.button = button; // ODOO: allow access for override
+
+ /**
+ * returns position from placeholder
+ *
+ * @private
+ * @param {Node} placeholder
+ * @param {Object} options
+ * @param {Boolean} options.isAirMode
+ * @return {Position}
+ */
+ var posFromPlaceholder = function (placeholder, options) {
+ var isAirMode = options && options.isAirMode;
+ var isLeftTop = options && options.isLeftTop;
+
+ var $placeholder = $(placeholder);
+ var pos = isAirMode ? $placeholder.offset() : $placeholder.position();
+ var height = isLeftTop ? 0 : $placeholder.outerHeight(true); // include margin
+
+ // popover below placeholder.
+ return {
+ left: pos.left,
+ top: pos.top + height
+ };
+ };
+
+ /**
+ * show popover
+ *
+ * @private
+ * @param {jQuery} popover
+ * @param {Position} pos
+ */
+ var showPopover = function ($popover, pos) {
+ $popover.css({
+ display: 'block',
+ left: pos.left,
+ top: pos.top
+ });
+ };
+
+ var PX_POPOVER_ARROW_OFFSET_X = 20;
+
+ /**
+ * update current state
+ * @param {jQuery} $popover - popover container
+ * @param {Object} styleInfo - style object
+ * @param {Boolean} isAirMode
+ */
+ this.update = function ($popover, styleInfo, isAirMode) {
+ button.update($popover, styleInfo);
+
+ var $linkPopover = $popover.find('.note-link-popover');
+ if (styleInfo.anchor) {
+ var $anchor = $linkPopover.find('a');
+ var href = $(styleInfo.anchor).attr('href');
+ var target = $(styleInfo.anchor).attr('target');
+ $anchor.attr('href', href).text(href);
+ if (!target) {
+ $anchor.removeAttr('target');
+ } else {
+ $anchor.attr('target', '_blank');
+ }
+ showPopover($linkPopover, posFromPlaceholder(styleInfo.anchor, {
+ isAirMode: isAirMode
+ }));
+ } else {
+ $linkPopover.hide();
+ }
+
+ var $imagePopover = $popover.find('.note-image-popover');
+ if (styleInfo.image) {
+ showPopover($imagePopover, posFromPlaceholder(styleInfo.image, {
+ isAirMode: isAirMode,
+ isLeftTop: true
+ }));
+ } else {
+ $imagePopover.hide();
+ }
+
+ var $airPopover = $popover.find('.note-air-popover');
+ if (isAirMode && styleInfo.range && !styleInfo.range.isCollapsed()) {
+ var rect = list.last(styleInfo.range.getClientRects());
+ if (rect) {
+ var bnd = func.rect2bnd(rect);
+ showPopover($airPopover, {
+ left: Math.max(bnd.left + bnd.width / 2 - PX_POPOVER_ARROW_OFFSET_X, 0),
+ top: bnd.top + bnd.height
+ });
+ }
+ } else {
+ $airPopover.hide();
+ }
+ };
+
+ /**
+ * @param {Node} button
+ * @param {String} eventName
+ * @param {String} value
+ */
+ this.updateRecentColor = function (button, eventName, value) {
+ button.updateRecentColor(button, eventName, value);
+ };
+
+ /**
+ * hide all popovers
+ * @param {jQuery} $popover - popover container
+ */
+ this.hide = function ($popover) {
+ $popover.children().hide();
+ };
+ };
+
+ return Popover;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Statusbar.js b/addons/web_editor/static/lib/summernote/src/js/module/Statusbar.js
new file mode 100644
index 00000000..b50fce45
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/module/Statusbar.js
@@ -0,0 +1,44 @@
+define([
+ 'summernote/core/dom'
+], function (dom) {
+ var EDITABLE_PADDING = 24;
+
+ var Statusbar = function () {
+ var $document = $(document);
+
+ this.attach = function (layoutInfo, options) {
+ if (!options.disableResizeEditor) {
+ layoutInfo.statusbar().on('mousedown', hStatusbarMousedown);
+ }
+ };
+
+ /**
+ * `mousedown` event handler on statusbar
+ *
+ * @param {MouseEvent} event
+ */
+ var hStatusbarMousedown = function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ var $editable = dom.makeLayoutInfo(event.target).editable();
+ var editableTop = $editable.offset().top - $document.scrollTop();
+
+ var layoutInfo = dom.makeLayoutInfo(event.currentTarget || event.target);
+ var options = layoutInfo.editor().data('options');
+
+ $document.on('mousemove', function (event) {
+ var nHeight = event.clientY - (editableTop + EDITABLE_PADDING);
+
+ nHeight = (options.minHeight > 0) ? Math.max(nHeight, options.minHeight) : nHeight;
+ nHeight = (options.maxHeight > 0) ? Math.min(nHeight, options.maxHeight) : nHeight;
+
+ $editable.height(nHeight);
+ }).one('mouseup', function () {
+ $document.off('mousemove');
+ });
+ };
+ };
+
+ return Statusbar;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Toolbar.js b/addons/web_editor/static/lib/summernote/src/js/module/Toolbar.js
new file mode 100644
index 00000000..5e5e5721
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/module/Toolbar.js
@@ -0,0 +1,101 @@
+define([
+ 'summernote/core/list',
+ 'summernote/core/dom',
+ 'summernote/module/Button'
+], function (list, dom, Button) {
+ /**
+ * @class module.Toolbar
+ *
+ * Toolbar
+ */
+ var Toolbar = function () {
+ var button = new Button();
+
+ this.button = button; // ODOO: allow access for override
+
+ this.update = function ($toolbar, styleInfo) {
+ button.update($toolbar, styleInfo);
+ };
+
+ /**
+ * @param {Node} button
+ * @param {String} eventName
+ * @param {String} value
+ */
+ this.updateRecentColor = function (buttonNode, eventName, value) {
+ button.updateRecentColor(buttonNode, eventName, value);
+ };
+
+ /**
+ * activate buttons exclude codeview
+ * @param {jQuery} $toolbar
+ */
+ this.activate = function ($toolbar) {
+ $toolbar.find('button')
+ .not('button[data-event="codeview"]')
+ .removeClass('disabled');
+ };
+
+ /**
+ * deactivate buttons exclude codeview
+ * @param {jQuery} $toolbar
+ */
+ this.deactivate = function ($toolbar) {
+ $toolbar.find('button')
+ .not('button[data-event="codeview"]')
+ .addClass('disabled');
+ };
+
+ /**
+ * @param {jQuery} $container
+ * @param {Boolean} [bFullscreen=false]
+ */
+ this.updateFullscreen = function ($container, bFullscreen) {
+ var $btn = $container.find('button[data-event="fullscreen"]');
+ $btn.toggleClass('active', bFullscreen);
+ };
+
+ /**
+ * @param {jQuery} $container
+ * @param {Boolean} [isCodeview=false]
+ */
+ this.updateCodeview = function ($container, isCodeview) {
+ var $btn = $container.find('button[data-event="codeview"]');
+ $btn.toggleClass('active', isCodeview);
+
+ if (isCodeview) {
+ this.deactivate($container);
+ } else {
+ this.activate($container);
+ }
+ };
+
+ /**
+ * get button in toolbar
+ *
+ * @param {jQuery} $editable
+ * @param {String} name
+ * @return {jQuery}
+ */
+ this.get = function ($editable, name) {
+ var $toolbar = dom.makeLayoutInfo($editable).toolbar();
+
+ return $toolbar.find('[data-name=' + name + ']');
+ };
+
+ /**
+ * set button state
+ * @param {jQuery} $editable
+ * @param {String} name
+ * @param {Boolean} [isActive=true]
+ */
+ this.setButtonState = function ($editable, name, isActive) {
+ isActive = (isActive === false) ? false : true;
+
+ var $button = this.get($editable, name);
+ $button.toggleClass('active', isActive);
+ };
+ };
+
+ return Toolbar;
+});
diff --git a/addons/web_editor/static/lib/summernote/src/js/outro.js b/addons/web_editor/static/lib/summernote/src/js/outro.js
new file mode 100644
index 00000000..be4600a5
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/outro.js
@@ -0,0 +1 @@
+}));
diff --git a/addons/web_editor/static/lib/summernote/src/js/summernote.js b/addons/web_editor/static/lib/summernote/src/js/summernote.js
new file mode 100644
index 00000000..329ba7fa
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/js/summernote.js
@@ -0,0 +1,328 @@
+define([
+ 'summernote/core/agent',
+ 'summernote/core/list',
+ 'summernote/core/dom',
+ 'summernote/core/range',
+ 'summernote/defaults',
+ 'summernote/EventHandler',
+ 'summernote/Renderer',
+ 'summernote/core/key' // ODOO: change for override
+], function (agent, list, dom, range,
+ defaults, EventHandler, Renderer, key) {
+
+ // jQuery namespace for summernote
+ /**
+ * @class $.summernote
+ *
+ * summernote attribute
+ *
+ * @mixin defaults
+ * @singleton
+ *
+ */
+ $.summernote = $.summernote || {};
+
+ // extends default settings
+ // - $.summernote.version
+ // - $.summernote.options
+ // - $.summernote.lang
+ $.extend($.summernote, defaults);
+
+ var renderer = new Renderer();
+ var eventHandler = new EventHandler();
+
+ $.extend($.summernote, {
+ /** @property {Renderer} */
+ renderer: renderer,
+ /** @property {EventHandler} */
+ eventHandler: eventHandler,
+ /**
+ * @property {Object} core
+ * @property {core.agent} core.agent
+ * @property {core.dom} core.dom
+ * @property {core.range} core.range
+ */
+ core: {
+ agent: agent,
+ list : list,
+ dom: dom,
+ range: range,
+ key: key // ODOO: change for override
+ },
+ /**
+ * @property {Object}
+ * pluginEvents event list for plugins
+ * event has name and callback function.
+ *
+ * ```
+ * $.summernote.addPlugin({
+ * events : {
+ * 'hello' : function(layoutInfo, value, $target) {
+ * console.log('event name is hello, value is ' + value );
+ * }
+ * }
+ * })
+ * ```
+ *
+ * * event name is data-event property.
+ * * layoutInfo is a summernote layout information.
+ * * value is data-value property.
+ */
+ pluginEvents: {},
+
+ plugins : []
+ });
+
+ /**
+ * @method addPlugin
+ *
+ * add Plugin in Summernote
+ *
+ * Summernote can make a own plugin.
+ *
+ * ### Define plugin
+ * ```
+ * // get template function
+ * var tmpl = $.summernote.renderer.getTemplate();
+ *
+ * // add a button
+ * $.summernote.addPlugin({
+ * buttons : {
+ * // "hello" is button's namespace.
+ * "hello" : function(lang, options) {
+ * // make icon button by template function
+ * return tmpl.iconButton(options.iconPrefix + 'header', {
+ * // callback function name when button clicked
+ * event : 'hello',
+ * // set data-value property
+ * value : 'hello',
+ * hide : true
+ * });
+ * }
+ *
+ * },
+ *
+ * events : {
+ * "hello" : function(layoutInfo, value) {
+ * // here is event code
+ * }
+ * }
+ * });
+ * ```
+ * ### Use a plugin in toolbar
+ *
+ * ```
+ * $("#editor").summernote({
+ * ...
+ * toolbar : [
+ * // display hello plugin in toolbar
+ * ['group', [ 'hello' ]]
+ * ]
+ * ...
+ * });
+ * ```
+ *
+ *
+ * @param {Object} plugin
+ * @param {Object} [plugin.buttons] define plugin button. for detail, see to Renderer.addButtonInfo
+ * @param {Object} [plugin.dialogs] define plugin dialog. for detail, see to Renderer.addDialogInfo
+ * @param {Object} [plugin.events] add event in $.summernote.pluginEvents
+ * @param {Object} [plugin.langs] update $.summernote.lang
+ * @param {Object} [plugin.options] update $.summernote.options
+ */
+ $.summernote.addPlugin = function (plugin) {
+
+ // save plugin list
+ $.summernote.plugins.push(plugin);
+
+ if (plugin.buttons) {
+ $.each(plugin.buttons, function (name, button) {
+ renderer.addButtonInfo(name, button);
+ });
+ }
+
+ if (plugin.dialogs) {
+ $.each(plugin.dialogs, function (name, dialog) {
+ renderer.addDialogInfo(name, dialog);
+ });
+ }
+
+ if (plugin.events) {
+ $.each(plugin.events, function (name, event) {
+ $.summernote.pluginEvents[name] = event;
+ });
+ }
+
+ if (plugin.langs) {
+ $.each(plugin.langs, function (locale, lang) {
+ if ($.summernote.lang[locale]) {
+ $.extend($.summernote.lang[locale], lang);
+ }
+ });
+ }
+
+ if (plugin.options) {
+ $.extend($.summernote.options, plugin.options);
+ }
+ };
+
+ /*
+ * extend $.fn
+ */
+ $.fn.extend({
+ /**
+ * @method
+ * Initialize summernote
+ * - create editor layout and attach Mouse and keyboard events.
+ *
+ * ```
+ * $("#summernote").summernote( { options ..} );
+ * ```
+ *
+ * @member $.fn
+ * @param {Object|String} options reference to $.summernote.options
+ * @return {this}
+ */
+ summernote: function () {
+ // check first argument's type
+ // - {String}: External API call {{module}}.{{method}}
+ // - {Object}: init options
+ var type = $.type(list.head(arguments));
+ var isExternalAPICalled = type === 'string';
+ var hasInitOptions = type === 'object';
+
+ // extend default options with custom user options
+ var options = hasInitOptions ? list.head(arguments) : {};
+
+ options = $.extend({}, $.summernote.options, options);
+ options.icons = $.extend({}, $.summernote.options.icons, options.icons);
+
+ // Include langInfo in options for later use, e.g. for image drag-n-drop
+ // Setup language info with en-US as default
+ options.langInfo = $.extend(true, {}, $.summernote.lang['en-US'], $.summernote.lang[options.lang]);
+
+ // override plugin options
+ if (!isExternalAPICalled && hasInitOptions) {
+ for (var i = 0, len = $.summernote.plugins.length; i < len; i++) {
+ var plugin = $.summernote.plugins[i];
+
+ if (options.plugin[plugin.name]) {
+ $.summernote.plugins[i] = $.extend(true, plugin, options.plugin[plugin.name]);
+ }
+ }
+ }
+
+ this.each(function (idx, holder) {
+ var $holder = $(holder);
+
+ // if layout isn't created yet, createLayout and attach events
+ if (!renderer.hasNoteEditor($holder)) {
+ renderer.createLayout($holder, options);
+
+ var layoutInfo = renderer.layoutInfoFromHolder($holder);
+ $holder.data('layoutInfo', layoutInfo);
+
+ eventHandler.attach(layoutInfo, options);
+ eventHandler.attachCustomEvent(layoutInfo, options);
+ }
+ });
+
+ var $first = this.first();
+ if ($first.length) {
+ var layoutInfo = renderer.layoutInfoFromHolder($first);
+
+ // external API
+ if (isExternalAPICalled) {
+ var moduleAndMethod = list.head(list.from(arguments));
+ var args = list.tail(list.from(arguments));
+
+ // TODO now external API only works for editor
+ var params = [moduleAndMethod, layoutInfo.editable()].concat(args);
+ return eventHandler.invoke.apply(eventHandler, params);
+ } else if (options.focus) {
+ // focus on first editable element for initialize editor
+ layoutInfo.editable().focus();
+ }
+ }
+
+ return this;
+ },
+
+ /**
+ * @method
+ *
+ * get the HTML contents of note or set the HTML contents of note.
+ *
+ * * get contents
+ * ```
+ * var content = $("#summernote").code();
+ * ```
+ * * set contents
+ *
+ * ```
+ * $("#summernote").code(html);
+ * ```
+ *
+ * @member $.fn
+ * @param {String} [html] - HTML contents(optional, set)
+ * @return {this|String} - context(set) or HTML contents of note(get).
+ */
+ code: function (html) {
+ // get the HTML contents of note
+ if (html === undefined) {
+ var $holder = this.first();
+ if (!$holder.length) {
+ return;
+ }
+
+ var layoutInfo = renderer.layoutInfoFromHolder($holder);
+ var $editable = layoutInfo && layoutInfo.editable();
+
+ if ($editable && $editable.length) {
+ var isCodeview = eventHandler.invoke('codeview.isActivated', layoutInfo);
+ eventHandler.invoke('codeview.sync', layoutInfo);
+ return isCodeview ? layoutInfo.codable().val() :
+ layoutInfo.editable().html();
+ }
+ return dom.value($holder);
+ }
+
+ // set the HTML contents of note
+ this.each(function (i, holder) {
+ var layoutInfo = renderer.layoutInfoFromHolder($(holder));
+ var $editable = layoutInfo && layoutInfo.editable();
+ if ($editable) {
+ $editable.html(html);
+ }
+ });
+
+ return this;
+ },
+
+ /**
+ * @method
+ *
+ * destroy Editor Layout and detach Key and Mouse Event
+ *
+ * @member $.fn
+ * @return {this}
+ */
+ destroy: function () {
+ this.each(function (idx, holder) {
+ var $holder = $(holder);
+
+ if (!renderer.hasNoteEditor($holder)) {
+ return;
+ }
+
+ var info = renderer.layoutInfoFromHolder($holder);
+ var options = info.editor().data('options');
+
+ eventHandler.detach(info, options);
+ renderer.removeLayout($holder, info, options);
+ });
+
+ return this;
+ }
+ });
+});
diff --git a/addons/web_editor/static/lib/summernote/src/less/summernote.less b/addons/web_editor/static/lib/summernote/src/less/summernote.less
new file mode 100644
index 00000000..dc8da251
--- /dev/null
+++ b/addons/web_editor/static/lib/summernote/src/less/summernote.less
@@ -0,0 +1,467 @@
+/* Theme Variables
+ ------------------------------------------*/
+@border-color: #a9a9a9;
+@background-color: #f5f5f5;
+
+/* Frame Mode Layout
+ ------------------------------------------*/
+.note-editor {
+ border: 1px solid @border-color;
+ position: relative;
+ /* overflow: hidden; ODOO: removed for embedded editor */
+
+ /* dropzone */
+ @dropzone-color: lightskyblue;
+ @dropzone-active-color: darken(@dropzone-color, 30);
+ .note-dropzone {
+ position: absolute;
+ display: none;
+ z-index: 100;
+ color: @dropzone-color;
+ background-color: white;
+ opacity: 0.95;
+ pointer-event: none;
+
+ .note-dropzone-message {
+ display: table-cell;
+ vertical-align: middle;
+ text-align: center;
+ font-size: 28px;
+ font-weight: bold;
+ }
+
+ &.hover {
+ color: @dropzone-active-color;
+ }
+ }
+
+ &.dragover .note-dropzone {
+ display: table;
+ }
+
+ /* codeview mode */
+ &.codeview {
+ .note-editing-area {
+ .note-editable {
+ display: none;
+ }
+ .note-codable {
+ display: block;
+ }
+ }
+ }
+
+ /* fullscreen mode */
+ &.fullscreen {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ z-index: 1050; /* bs3 modal-backdrop: 1030, bs2: 1040 */
+ .note-editable {
+ background-color: white;
+ }
+ .note-resizebar {
+ display: none;
+ }
+ }
+
+ .note-editing-area {
+ position: relative;
+ overflow: hidden;
+
+ /* editable */
+ .note-editable {
+ background-color: #fff;
+ color: #000;
+ padding: 10px;
+ overflow: auto;
+ outline: none;
+
+ &[contenteditable=true]:empty:not(:focus):before {
+ content:attr(data-placeholder);
+ }
+ &[contenteditable="false"] {
+ background-color: #e5e5e5;
+ }
+ }
+
+ /* codeable */
+ .note-codable {
+ display: none;
+ width: 100%;
+ padding: 10px;
+ border: none;
+ box-shadow: none;
+ font-family: Menlo, Monaco, monospace, sans-serif;
+ font-size: 14px;
+ color: #ccc;
+ background-color: #222;
+ resize: none;
+
+ /* override BS2 default style */
+ box-sizing: border-box;
+ border-radius: 0;
+ margin-bottom: 0;
+ }
+ }
+
+ /* statusbar */
+ .note-statusbar {
+ background-color: @background-color;
+ .note-resizebar {
+ padding-top: 1px;
+ height: 8px;
+ width: 100%;
+ cursor: ns-resize;
+ .note-icon-bar {
+ width: 20px;
+ margin: 1px auto;
+ border-top: 1px solid @border-color;
+ }
+ }
+ }
+}
+
+/* Air Mode Layout
+------------------------------------------*/
+.note-air-editor {
+ outline: none;
+}
+
+/* Popover
+------------------------------------------*/
+.note-popover .popover {
+ max-width: none;
+ .popover-body {
+ a {
+ display: inline-block;
+ max-width: 200px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap; /* for FF */
+ vertical-align: middle; /* for FF */
+ }
+ }
+ & .arrow {
+ left: 20px;
+ }
+}
+
+/* Popover and Toolbar (Button container)
+------------------------------------------*/
+.note-popover .popover .popover-body, .panel-heading.note-toolbar {
+ margin: 0;
+ padding: 0 0 5px 5px;
+
+ &>.btn-group {
+ margin-top: 5px;
+ margin-left: 0;
+ margin-right: 5px;
+ }
+
+ .btn-group {
+ .note-table {
+ min-width: 0;
+ padding: 5px;
+ .note-dimension-picker {
+ font-size: 18px;
+ .note-dimension-picker-mousecatcher {
+ position: absolute !important;
+ z-index: 3;
+ width: 10em;
+ height: 10em;
+ cursor: pointer;
+ }
+ .note-dimension-picker-unhighlighted {
+ position: relative !important;
+ z-index: 1;
+ width: 5em;
+ height: 5em;
+ background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASAgMAAAAroGbEAAAACVBMVEUAAIj4+Pjp6ekKlAqjAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfYAR0BKhmnaJzPAAAAG0lEQVQI12NgAAOtVatWMTCohoaGUY+EmIkEAEruEzK2J7tvAAAAAElFTkSuQmCC') repeat;
+ }
+ .note-dimension-picker-highlighted {
+ position: absolute !important;
+ z-index: 2;
+ width: 1em;
+ height: 1em;
+ background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASAgMAAAAroGbEAAAACVBMVEUAAIjd6vvD2f9LKLW+AAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfYAR0BKwNDEVT0AAAAG0lEQVQI12NgAAOtVatWMTCohoaGUY+EmIkEAEruEzK2J7tvAAAAAElFTkSuQmCC') repeat;
+ }
+ }
+ }
+ }
+
+ .note-style {
+ h1, h2, h3, h4, h5, h6, blockquote {
+ margin: 0;
+ }
+ }
+
+ .note-color {
+ .dropdown-toggle {
+ width: 20px;
+ padding-left: 5px;
+ }
+ .dropdown-menu {
+ min-width: 340px;
+ .btn-group {
+ margin: 0;
+ &:first-child {
+ margin: 0 5px;
+ }
+
+ .note-palette-title {
+ font-size: 12px;
+ margin: 2px 7px;
+ text-align: center;
+ border-bottom: 1px solid #eee;
+ }
+
+ .note-color-reset {
+ font-size: 11px;
+ margin: 3px;
+ padding: 0 3px;
+ cursor: pointer;
+ border-radius: 5px;
+ }
+
+ .note-color-row {
+ height: 20px;
+ }
+
+ .note-color-reset:hover {
+ background: #eee;
+ }
+ }
+ }
+ }
+
+ .note-para {
+ .dropdown-menu {
+ min-width: 216px;
+ padding: 5px;
+ &>div:first-child {
+ margin-right: 5px;
+ }
+ }
+ }
+
+ /* dropdown-menu for toolbar and popover */
+ .dropdown-menu {
+ min-width: 90px;
+
+ /* dropdown-menu right position */
+ /* http://forrst.com/posts/Bootstrap_right_positioned_dropdown-2KB */
+ &.right {
+ right: 0;
+ left: auto;
+ &::before { right: 9px; left: auto !important; }
+ &::after { right: 10px; left: auto !important; }
+ }
+ /* dropdown-menu for selectbox */
+ &.note-check {
+ li a i {
+ color: deepskyblue;
+ visibility: hidden;
+ }
+ li a.checked i {
+ visibility: visible;
+ }
+ }
+
+ }
+
+ .note-fontsize-10 {
+ font-size: 10px;
+ }
+
+ /* color palette for toolbar and popover */
+ .note-color-palette {
+ line-height: 1;
+ div {
+ .note-color-btn {
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ margin: 0;
+ border: 1px solid #fff;
+ }
+ .note-color-btn:hover {
+ border: 1px solid #000;
+ }
+ }
+ }
+}
+
+/* Dialog
+------------------------------------------*/
+.note-dialog {
+ &>div {
+ display: none; /* BS2's hide pacth. */
+ }
+ .form-group { /* overwrite BS's form-horizontal minus margins */
+ margin-left: 0;
+ margin-right: 0;
+ }
+ .note-modal-form {
+ margin: 0; /* overwrite BS2's form margin bottom */
+ }
+ .note-image-dialog {
+ .note-dropzone {
+ min-height: 100px;
+ font-size: 30px;
+ line-height: 4; /* vertical-align */
+ color: lightgray;
+ text-align: center;
+ border: 4px dashed lightgray;
+ margin-bottom: 10px;
+ }
+ }
+ .note-help-dialog {
+ @note-shortcut-hl: #dd0;
+ font-size: 12px;
+ color: #ccc;
+ background-color: #222 !important;
+ opacity: 0.9;
+ .modal-content {
+ background: transparent;
+ border: 1px solid white;
+ box-shadow: none;
+ border-radius: 5px;
+ }
+
+ /* BS2's background pacth. */
+ background: transparent;
+ border: none;
+
+ a {
+ font-size: 12px;
+ color: white;
+ }
+
+ .title {
+ color: white;
+ font-size: 14px;
+ font-weight: bold;
+ padding-bottom: 5px;
+ margin-bottom: 10px;
+ border-bottom: white 1px solid;
+ }
+
+ .modal-close {
+ font-size: 14px;
+ color: @note-shortcut-hl;
+ cursor: pointer;
+ }
+
+ .text-center {
+ margin: 10px 0 0;
+ }
+
+ .note-shortcut {
+ padding-top: 8px;
+ padding-bottom: 8px;
+
+ &-row {
+ margin-right: -5px;
+ margin-left: -5px;
+ }
+
+ &-col {
+ padding-right: 5px;
+ padding-left: 5px;
+ }
+
+ &-title {
+ font-size: 13px;
+ font-weight: bold;
+ color: @note-shortcut-hl;
+ }
+
+ &-key {
+ font-family: "Courier New";
+ color: @note-shortcut-hl;
+ text-align: right;
+ }
+ }
+ }
+}
+
+/* Handle
+------------------------------------------*/
+.note-handle {
+ /* control selection */
+ .note-control-selection {
+ position: absolute;
+ display: none;
+ border: 1px solid black;
+ &>div { position: absolute; }
+
+ .note-control-selection-bg {
+ width: 100%;
+ height: 100%;
+ background-color: black;
+ opacity: 0.3;
+ }
+
+ .note-control-handle {
+ width: 7px;
+ height: 7px;
+ border: 1px solid black;
+ }
+
+ .note-control-holder {
+ .note-control-handle;
+ }
+
+ .note-control-sizing {
+ .note-control-handle;
+ background-color: white;
+ }
+
+ .note-control-nw {
+ top: -5px;
+ left: -5px;
+ border-right: none;
+ border-bottom: none;
+ }
+
+ .note-control-ne {
+ top: -5px;
+ right: -5px;
+ border-bottom: none;
+ border-left: none;
+ }
+
+ .note-control-sw {
+ bottom: -5px;
+ left: -5px;
+ border-top: none;
+ border-right: none;
+ }
+
+ .note-control-se {
+ right: -5px;
+ bottom: -5px;
+ cursor: se-resize;
+ }
+
+ .note-control-se.note-control-holder {
+ cursor: default;
+ border-top: none;
+ border-left: none;
+ }
+
+ .note-control-selection-info {
+ right: 0;
+ bottom: 0;
+ padding: 5px;
+ margin: 5px;
+ color: white;
+ background-color: black;
+ font-size: 12px;
+ border-radius: 5px;
+ opacity: 0.7;
+ }
+ }
+}
diff --git a/addons/web_editor/static/lib/vkbeautify/vkbeautify.0.99.00.beta.js b/addons/web_editor/static/lib/vkbeautify/vkbeautify.0.99.00.beta.js
new file mode 100644
index 00000000..57effaec
--- /dev/null
+++ b/addons/web_editor/static/lib/vkbeautify/vkbeautify.0.99.00.beta.js
@@ -0,0 +1,358 @@
+/**
+* vkBeautify - javascript plugin to pretty-print or minify text in XML, JSON, CSS and SQL formats.
+*
+* Version - 0.99.00.beta
+* Copyright (c) 2012 Vadim Kiryukhin
+* vkiryukhin @ gmail.com
+* http://www.eslinstructor.net/vkbeautify/
+*
+* Dual licensed under the MIT and GPL licenses:
+* http://www.opensource.org/licenses/mit-license.php
+* http://www.gnu.org/licenses/gpl.html
+*
+* Pretty print
+*
+* vkbeautify.xml(text [,indent_pattern]);
+* vkbeautify.json(text [,indent_pattern]);
+* vkbeautify.css(text [,indent_pattern]);
+* vkbeautify.sql(text [,indent_pattern]);
+*
+* @text - String; text to beatufy;
+* @indent_pattern - Integer | String;
+* Integer: number of white spaces;
+* String: character string to visualize indentation ( can also be a set of white spaces )
+* Minify
+*
+* vkbeautify.xmlmin(text [,preserve_comments]);
+* vkbeautify.jsonmin(text);
+* vkbeautify.cssmin(text [,preserve_comments]);
+* vkbeautify.sqlmin(text);
+*
+* @text - String; text to minify;
+* @preserve_comments - Bool; [optional];
+* Set this flag to true to prevent removing comments from @text ( minxml and mincss functions only. )
+*
+* Examples:
+* vkbeautify.xml(text); // pretty print XML
+* vkbeautify.json(text, 4 ); // pretty print JSON
+* vkbeautify.css(text, '. . . .'); // pretty print CSS
+* vkbeautify.sql(text, '----'); // pretty print SQL
+*
+* vkbeautify.xmlmin(text, true);// minify XML, preserve comments
+* vkbeautify.jsonmin(text);// minify JSON
+* vkbeautify.cssmin(text);// minify CSS, remove comments ( default )
+* vkbeautify.sqlmin(text);// minify SQL
+*
+*/
+
+(function() {
+
+function createShiftArr(step) {
+
+ var space = ' ';
+
+ if ( isNaN(parseInt(step)) ) { // argument is string
+ space = step;
+ } else { // argument is integer
+ switch(step) {
+ case 1: space = ' '; break;
+ case 2: space = ' '; break;
+ case 3: space = ' '; break;
+ case 4: space = ' '; break;
+ case 5: space = ' '; break;
+ case 6: space = ' '; break;
+ case 7: space = ' '; break;
+ case 8: space = ' '; break;
+ case 9: space = ' '; break;
+ case 10: space = ' '; break;
+ case 11: space = ' '; break;
+ case 12: space = ' '; break;
+ }
+ }
+
+ var shift = ['\n']; // array of shifts
+ for(ix=0;ix<100;ix++){
+ shift.push(shift[ix]+space);
+ }
+ return shift;
+}
+
+function vkbeautify(){
+ this.step = ' '; // 4 spaces
+ this.shift = createShiftArr(this.step);
+};
+
+vkbeautify.prototype.xml = function(text,step) {
+
+ var ar = text.replace(/>\s{0,}</g,"><")
+ .replace(/</g,"~::~<")
+ .replace(/\s*xmlns\:/g,"~::~xmlns:")
+ .replace(/\s*xmlns\=/g,"~::~xmlns=")
+ .split('~::~'),
+ len = ar.length,
+ inComment = false,
+ deep = 0,
+ str = '',
+ ix = 0,
+ shift = step ? createShiftArr(step) : this.shift;
+
+ for(ix=0;ix<len;ix++) {
+ // start comment or <![CDATA[...]]> or <!DOCTYPE //
+ if(ar[ix].search(/<!/) > -1) {
+ str += shift[deep]+ar[ix];
+ inComment = true;
+ // end comment or <![CDATA[...]]> //
+ if(ar[ix].search(/-->/) > -1 || ar[ix].search(/\]>/) > -1 || ar[ix].search(/!DOCTYPE/) > -1 ) {
+ inComment = false;
+ }
+ } else
+ // end comment or <![CDATA[...]]> //
+ if(ar[ix].search(/-->/) > -1 || ar[ix].search(/\]>/) > -1) {
+ str += ar[ix];
+ inComment = false;
+ } else
+ // <elm></elm> //
+ if( /^<\w/.exec(ar[ix-1]) && /^<\/\w/.exec(ar[ix]) &&
+ /^<[\w:\-\.\,]+/.exec(ar[ix-1]) == /^<\/[\w:\-\.\,]+/.exec(ar[ix])[0].replace('/','')) {
+ str += ar[ix];
+ if(!inComment) deep--;
+ } else
+ // <elm> //
+ if(ar[ix].search(/<\w/) > -1 && ar[ix].search(/<\//) == -1 && ar[ix].search(/\/>/) == -1 ) {
+ str = !inComment ? str += shift[deep++]+ar[ix] : str += ar[ix];
+ } else
+ // <elm>...</elm> //
+ if(ar[ix].search(/<\w/) > -1 && ar[ix].search(/<\//) > -1) {
+ str = !inComment ? str += shift[deep]+ar[ix] : str += ar[ix];
+ } else
+ // </elm> //
+ if(ar[ix].search(/<\//) > -1) {
+ str = !inComment ? str += shift[--deep]+ar[ix] : str += ar[ix];
+ } else
+ // <elm/> //
+ if(ar[ix].search(/\/>/) > -1 ) {
+ str = !inComment ? str += shift[deep]+ar[ix] : str += ar[ix];
+ } else
+ // <? xml ... ?> //
+ if(ar[ix].search(/<\?/) > -1) {
+ str += shift[deep]+ar[ix];
+ } else
+ // xmlns //
+ if( ar[ix].search(/xmlns\:/) > -1 || ar[ix].search(/xmlns\=/) > -1) {
+ str += shift[deep]+ar[ix];
+ }
+
+ else {
+ str += ar[ix];
+ }
+ }
+
+ return (str[0] == '\n') ? str.slice(1) : str;
+}
+
+vkbeautify.prototype.json = function(text,step) {
+
+ var step = step ? step : this.step;
+
+ if (typeof JSON === 'undefined' ) return text;
+
+ if ( typeof text === "string" ) return JSON.stringify(JSON.parse(text), null, step);
+ if ( typeof text === "object" ) return JSON.stringify(text, null, step);
+
+ return text; // text is not string nor object
+}
+
+vkbeautify.prototype.css = function(text, step) {
+
+ var ar = text.replace(/\s{1,}/g,' ')
+ .replace(/\{/g,"{~::~")
+ .replace(/\}/g,"~::~}~::~")
+ .replace(/\;/g,";~::~")
+ .replace(/\/\*/g,"~::~/*")
+ .replace(/\*\//g,"*/~::~")
+ .replace(/~::~\s{0,}~::~/g,"~::~")
+ .split('~::~'),
+ len = ar.length,
+ deep = 0,
+ str = '',
+ ix = 0,
+ shift = step ? createShiftArr(step) : this.shift;
+
+ for(ix=0;ix<len;ix++) {
+
+ if( /\{/.exec(ar[ix])) {
+ str += shift[deep++]+ar[ix];
+ } else
+ if( /\}/.exec(ar[ix])) {
+ str += shift[--deep]+ar[ix];
+ } else
+ if( /\*\\/.exec(ar[ix])) {
+ str += shift[deep]+ar[ix];
+ }
+ else {
+ str += shift[deep]+ar[ix];
+ }
+ }
+ return str.replace(/^\n{1,}/,'');
+}
+
+//----------------------------------------------------------------------------
+
+function isSubquery(str, parenthesisLevel) {
+ return parenthesisLevel - (str.replace(/\(/g,'').length - str.replace(/\)/g,'').length )
+}
+
+function split_sql(str, tab) {
+
+ return str.replace(/\s{1,}/g," ")
+
+ .replace(/ AND /ig,"~::~"+tab+tab+"AND ")
+ .replace(/ BETWEEN /ig,"~::~"+tab+"BETWEEN ")
+ .replace(/ CASE /ig,"~::~"+tab+"CASE ")
+ .replace(/ ELSE /ig,"~::~"+tab+"ELSE ")
+ .replace(/ END /ig,"~::~"+tab+"END ")
+ .replace(/ FROM /ig,"~::~FROM ")
+ .replace(/ GROUP\s{1,}BY/ig,"~::~GROUP BY ")
+ .replace(/ HAVING /ig,"~::~HAVING ")
+ //.replace(/ SET /ig," SET~::~")
+ .replace(/ IN /ig," IN ")
+
+ .replace(/ JOIN /ig,"~::~JOIN ")
+ .replace(/ CROSS~::~{1,}JOIN /ig,"~::~CROSS JOIN ")
+ .replace(/ INNER~::~{1,}JOIN /ig,"~::~INNER JOIN ")
+ .replace(/ LEFT~::~{1,}JOIN /ig,"~::~LEFT JOIN ")
+ .replace(/ RIGHT~::~{1,}JOIN /ig,"~::~RIGHT JOIN ")
+
+ .replace(/ ON /ig,"~::~"+tab+"ON ")
+ .replace(/ OR /ig,"~::~"+tab+tab+"OR ")
+ .replace(/ ORDER\s{1,}BY/ig,"~::~ORDER BY ")
+ .replace(/ OVER /ig,"~::~"+tab+"OVER ")
+
+ .replace(/\(\s{0,}SELECT /ig,"~::~(SELECT ")
+ .replace(/\)\s{0,}SELECT /ig,")~::~SELECT ")
+
+ .replace(/ THEN /ig," THEN~::~"+tab+"")
+ .replace(/ UNION /ig,"~::~UNION~::~")
+ .replace(/ USING /ig,"~::~USING ")
+ .replace(/ WHEN /ig,"~::~"+tab+"WHEN ")
+ .replace(/ WHERE /ig,"~::~WHERE ")
+ .replace(/ WITH /ig,"~::~WITH ")
+
+ //.replace(/\,\s{0,}\(/ig,",~::~( ")
+ //.replace(/\,/ig,",~::~"+tab+tab+"")
+
+ .replace(/ ALL /ig," ALL ")
+ .replace(/ AS /ig," AS ")
+ .replace(/ ASC /ig," ASC ")
+ .replace(/ DESC /ig," DESC ")
+ .replace(/ DISTINCT /ig," DISTINCT ")
+ .replace(/ EXISTS /ig," EXISTS ")
+ .replace(/ NOT /ig," NOT ")
+ .replace(/ NULL /ig," NULL ")
+ .replace(/ LIKE /ig," LIKE ")
+ .replace(/\s{0,}SELECT /ig,"SELECT ")
+ .replace(/\s{0,}UPDATE /ig,"UPDATE ")
+ .replace(/ SET /ig," SET ")
+
+ .replace(/~::~{1,}/g,"~::~")
+ .split('~::~');
+}
+
+vkbeautify.prototype.sql = function(text,step) {
+
+ var ar_by_quote = text.replace(/\s{1,}/g," ")
+ .replace(/\'/ig,"~::~\'")
+ .split('~::~'),
+ len = ar_by_quote.length,
+ ar = [],
+ deep = 0,
+ tab = this.step,//+this.step,
+ inComment = true,
+ inQuote = false,
+ parenthesisLevel = 0,
+ str = '',
+ ix = 0,
+ shift = step ? createShiftArr(step) : this.shift;;
+
+ for(ix=0;ix<len;ix++) {
+ if(ix%2) {
+ ar = ar.concat(ar_by_quote[ix]);
+ } else {
+ ar = ar.concat(split_sql(ar_by_quote[ix], tab) );
+ }
+ }
+
+ len = ar.length;
+ for(ix=0;ix<len;ix++) {
+
+ parenthesisLevel = isSubquery(ar[ix], parenthesisLevel);
+
+ if( /\s{0,}\s{0,}SELECT\s{0,}/.exec(ar[ix])) {
+ ar[ix] = ar[ix].replace(/\,/g,",\n"+tab+tab+"")
+ }
+
+ if( /\s{0,}\s{0,}SET\s{0,}/.exec(ar[ix])) {
+ ar[ix] = ar[ix].replace(/\,/g,",\n"+tab+tab+"")
+ }
+
+ if( /\s{0,}\(\s{0,}SELECT\s{0,}/.exec(ar[ix])) {
+ deep++;
+ str += shift[deep]+ar[ix];
+ } else
+ if( /\'/.exec(ar[ix]) ) {
+ if(parenthesisLevel<1 && deep) {
+ deep--;
+ }
+ str += ar[ix];
+ }
+ else {
+ str += shift[deep]+ar[ix];
+ if(parenthesisLevel<1 && deep) {
+ deep--;
+ }
+ }
+ var junk = 0;
+ }
+
+ str = str.replace(/^\n{1,}/,'').replace(/\n{1,}/g,"\n");
+ return str;
+}
+
+
+vkbeautify.prototype.xmlmin = function(text, preserveComments) {
+
+ var str = preserveComments ? text
+ : text.replace(/\<![ \r\n\t]*(--([^\-]|[\r\n]|-[^\-])*--[ \r\n\t]*)\>/g,"")
+ .replace(/[ \r\n\t]{1,}xmlns/g, ' xmlns');
+ return str.replace(/>\s{0,}</g,"><");
+}
+
+vkbeautify.prototype.jsonmin = function(text) {
+
+ if (typeof JSON === 'undefined' ) return text;
+
+ return JSON.stringify(JSON.parse(text), null, 0);
+
+}
+
+vkbeautify.prototype.cssmin = function(text, preserveComments) {
+
+ var str = preserveComments ? text
+ : text.replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+\//g,"") ;
+
+ return str.replace(/\s{1,}/g,' ')
+ .replace(/\{\s{1,}/g,"{")
+ .replace(/\}\s{1,}/g,"}")
+ .replace(/\;\s{1,}/g,";")
+ .replace(/\/\*\s{1,}/g,"/*")
+ .replace(/\*\/\s{1,}/g,"*/");
+}
+
+vkbeautify.prototype.sqlmin = function(text) {
+ return text.replace(/\s{1,}/g," ").replace(/\s{1,}\(/,"(").replace(/\s{1,}\)/,")");
+}
+
+window.vkbeautify = new vkbeautify();
+
+})();
+
diff --git a/addons/web_editor/static/lib/webgl-image-filter/LICENSE b/addons/web_editor/static/lib/webgl-image-filter/LICENSE
new file mode 100644
index 00000000..1faa2fd4
--- /dev/null
+++ b/addons/web_editor/static/lib/webgl-image-filter/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Dominic Szablewski - phoboslab.org
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/addons/web_editor/static/lib/webgl-image-filter/webgl-image-filter.js b/addons/web_editor/static/lib/webgl-image-filter/webgl-image-filter.js
new file mode 100644
index 00000000..40ffe4dd
--- /dev/null
+++ b/addons/web_editor/static/lib/webgl-image-filter/webgl-image-filter.js
@@ -0,0 +1,640 @@
+/*
+WebGLImageFilter - MIT Licensed
+
+2013, Dominic Szablewski - phoboslab.org
+*/
+
+(function(window){
+
+var WebGLProgram = function( gl, vertexSource, fragmentSource ) {
+
+ var _collect = function( source, prefix, collection ) {
+ var r = new RegExp('\\b' + prefix + ' \\w+ (\\w+)', 'ig');
+ source.replace(r, function(match, name) {
+ collection[name] = 0;
+ return match;
+ });
+ };
+
+ var _compile = function( gl, source, type ) {
+ var shader = gl.createShader(type);
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+
+ if( !gl.getShaderParameter(shader, gl.COMPILE_STATUS) ) {
+ console.log(gl.getShaderInfoLog(shader));
+ return null;
+ }
+ return shader;
+ };
+
+
+ this.uniform = {};
+ this.attribute = {};
+
+ var _vsh = _compile(gl, vertexSource, gl.VERTEX_SHADER);
+ var _fsh = _compile(gl, fragmentSource, gl.FRAGMENT_SHADER);
+
+ this.id = gl.createProgram();
+ gl.attachShader(this.id, _vsh);
+ gl.attachShader(this.id, _fsh);
+ gl.linkProgram(this.id);
+
+ if( !gl.getProgramParameter(this.id, gl.LINK_STATUS) ) {
+ console.log(gl.getProgramInfoLog(this.id));
+ }
+
+ gl.useProgram(this.id);
+
+ // Collect attributes
+ _collect(vertexSource, 'attribute', this.attribute);
+ for( var a in this.attribute ) {
+ this.attribute[a] = gl.getAttribLocation(this.id, a);
+ }
+
+ // Collect uniforms
+ _collect(vertexSource, 'uniform', this.uniform);
+ _collect(fragmentSource, 'uniform', this.uniform);
+ for( var u in this.uniform ) {
+ this.uniform[u] = gl.getUniformLocation(this.id, u);
+ }
+};
+
+const identityMatrix = [
+ 1, 0, 0, 0, 0,
+ 0, 1, 0, 0, 0,
+ 0, 0, 1, 0, 0,
+ 0, 0, 0, 1, 0,
+];
+
+const weightedAvg = (a, b, w) => a * w + b * (1 - w);
+
+var WebGLImageFilter = window.WebGLImageFilter = function (params) {
+ if (!params)
+ params = { };
+
+ var
+ gl = null,
+ _drawCount = 0,
+ _sourceTexture = null,
+ _lastInChain = false,
+ _currentFramebufferIndex = -1,
+ _tempFramebuffers = [null, null],
+ _filterChain = [],
+ _width = -1,
+ _height = -1,
+ _vertexBuffer = null,
+ _currentProgram = null,
+ _canvas = params.canvas || document.createElement('canvas');
+
+ // key is the shader program source, value is the compiled program
+ var _shaderProgramCache = { };
+
+ var gl = _canvas.getContext("webgl") || _canvas.getContext("experimental-webgl");
+ if( !gl ) {
+ throw "Couldn't get WebGL context";
+ }
+
+
+ this.addFilter = function( name ) {
+ var args = Array.prototype.slice.call(arguments, 1);
+ var filter = _filter[name];
+
+ _filterChain.push({func:filter, args:args});
+ };
+
+ this.reset = function() {
+ _filterChain = [];
+ };
+
+ this.apply = function( image ) {
+ _resize( image.width, image.height );
+ _drawCount = 0;
+
+ // Create the texture for the input image if we haven't yet
+ if (!_sourceTexture)
+ _sourceTexture = gl.createTexture();
+
+ gl.bindTexture(gl.TEXTURE_2D, _sourceTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
+
+ // No filters? Just draw
+ if( _filterChain.length == 0 ) {
+ var program = _compileShader(SHADER.FRAGMENT_IDENTITY);
+ _draw();
+ return _canvas;
+ }
+
+ for( var i = 0; i < _filterChain.length; i++ ) {
+ _lastInChain = (i == _filterChain.length-1);
+ var f = _filterChain[i];
+
+ f.func.apply(this, f.args || []);
+ }
+
+ return _canvas;
+ };
+
+ var _resize = function( width, height ) {
+ // Same width/height? Nothing to do here
+ if( width == _width && height == _height ) { return; }
+
+
+ _canvas.width = _width = width;
+ _canvas.height = _height = height;
+
+ // Create the context if we don't have it yet
+ if( !_vertexBuffer ) {
+ // Create the vertex buffer for the two triangles [x, y, u, v] * 6
+ var vertices = new Float32Array([
+ -1, -1, 0, 1, 1, -1, 1, 1, -1, 1, 0, 0,
+ -1, 1, 0, 0, 1, -1, 1, 1, 1, 1, 1, 0
+ ]);
+ _vertexBuffer = gl.createBuffer(),
+ gl.bindBuffer(gl.ARRAY_BUFFER, _vertexBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
+
+ // Note sure if this is a good idea; at least it makes texture loading
+ // in Ejecta instant.
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
+ }
+
+ gl.viewport(0, 0, _width, _height);
+
+ // Delete old temp framebuffers
+ _tempFramebuffers = [null, null];
+ };
+
+ var _getTempFramebuffer = function( index ) {
+ _tempFramebuffers[index] =
+ _tempFramebuffers[index] ||
+ _createFramebufferTexture( _width, _height );
+
+ return _tempFramebuffers[index];
+ };
+
+ var _createFramebufferTexture = function( width, height ) {
+ var fbo = gl.createFramebuffer();
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
+
+ var renderbuffer = gl.createRenderbuffer();
+ gl.bindRenderbuffer(gl.RENDERBUFFER, renderbuffer);
+
+ var texture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
+
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
+
+ gl.bindTexture(gl.TEXTURE_2D, null);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+
+ return {fbo: fbo, texture: texture};
+ };
+
+ var _draw = function( flags ) {
+ var source = null,
+ target = null,
+ flipY = false;
+
+ // Set up the source
+ if( _drawCount == 0 ) {
+ // First draw call - use the source texture
+ source = _sourceTexture;
+ }
+ else {
+ // All following draw calls use the temp buffer last drawn to
+ source = _getTempFramebuffer(_currentFramebufferIndex).texture;
+ }
+ _drawCount++;
+
+
+ // Set up the target
+ if( _lastInChain && !(flags & DRAW.INTERMEDIATE) ) {
+ // Last filter in our chain - draw directly to the WebGL Canvas. We may
+ // also have to flip the image vertically now
+ target = null;
+ flipY = _drawCount % 2 == 0;
+ }
+ else {
+ // Intermediate draw call - get a temp buffer to draw to
+ _currentFramebufferIndex = (_currentFramebufferIndex+1) % 2;
+ target = _getTempFramebuffer(_currentFramebufferIndex).fbo;
+ }
+
+ // Bind the source and target and draw the two triangles
+ gl.bindTexture(gl.TEXTURE_2D, source);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, target);
+
+ gl.uniform1f(_currentProgram.uniform.flipY, (flipY ? -1 : 1) );
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+ };
+
+ var _compileShader = function( fragmentSource ) {
+ if (_shaderProgramCache[fragmentSource]) {
+ _currentProgram = _shaderProgramCache[fragmentSource];
+ gl.useProgram(_currentProgram.id);
+ return _currentProgram;
+ }
+
+ // Compile shaders
+ _currentProgram = new WebGLProgram( gl, SHADER.VERTEX_IDENTITY, fragmentSource );
+
+ var floatSize = Float32Array.BYTES_PER_ELEMENT;
+ var vertSize = 4 * floatSize;
+ gl.enableVertexAttribArray(_currentProgram.attribute.pos);
+ gl.vertexAttribPointer(_currentProgram.attribute.pos, 2, gl.FLOAT, false, vertSize , 0 * floatSize);
+ gl.enableVertexAttribArray(_currentProgram.attribute.uv);
+ gl.vertexAttribPointer(_currentProgram.attribute.uv, 2, gl.FLOAT, false, vertSize, 2 * floatSize);
+
+ _shaderProgramCache[fragmentSource] = _currentProgram;
+ return _currentProgram;
+ };
+
+
+ var DRAW = { INTERMEDIATE: 1 };
+
+ var SHADER = {};
+ SHADER.VERTEX_IDENTITY = [
+ 'precision highp float;',
+ 'attribute vec2 pos;',
+ 'attribute vec2 uv;',
+ 'varying vec2 vUv;',
+ 'uniform float flipY;',
+
+ 'void main(void) {',
+ 'vUv = uv;',
+ 'gl_Position = vec4(pos.x, pos.y*flipY, 0.0, 1.);',
+ '}'
+ ].join('\n');
+
+ SHADER.FRAGMENT_IDENTITY = [
+ 'precision highp float;',
+ 'varying vec2 vUv;',
+ 'uniform sampler2D texture;',
+
+ 'void main(void) {',
+ 'gl_FragColor = texture2D(texture, vUv);',
+ '}',
+ ].join('\n');
+
+
+ var _filter = {};
+
+
+
+ // -------------------------------------------------------------------------
+ // Color Matrix Filter
+
+ _filter.colorMatrix = function( matrix , amount = 1 ) {
+ matrix = matrix.map((coef, index) => weightedAvg(coef, identityMatrix[index], amount));
+ // Create a Float32 Array and normalize the offset component to 0-1
+ var m = new Float32Array(matrix);
+ m[4] /= 255;
+ m[9] /= 255;
+ m[14] /= 255;
+ m[19] /= 255;
+
+ // Can we ignore the alpha value? Makes things a bit faster.
+ var shader = (1==m[18]&&0==m[3]&&0==m[8]&&0==m[13]&&0==m[15]&&0==m[16]&&0==m[17]&&0==m[19])
+ ? _filter.colorMatrix.SHADER.WITHOUT_ALPHA
+ : _filter.colorMatrix.SHADER.WITH_ALPHA;
+
+ var program = _compileShader(shader);
+ gl.uniform1fv(program.uniform.m, m);
+ _draw();
+ };
+
+ _filter.colorMatrix.SHADER = {};
+ _filter.colorMatrix.SHADER.WITH_ALPHA = [
+ 'precision highp float;',
+ 'varying vec2 vUv;',
+ 'uniform sampler2D texture;',
+ 'uniform float m[20];',
+
+ 'void main(void) {',
+ 'vec4 c = texture2D(texture, vUv);',
+ 'gl_FragColor.r = m[0] * c.r + m[1] * c.g + m[2] * c.b + m[3] * c.a + m[4];',
+ 'gl_FragColor.g = m[5] * c.r + m[6] * c.g + m[7] * c.b + m[8] * c.a + m[9];',
+ 'gl_FragColor.b = m[10] * c.r + m[11] * c.g + m[12] * c.b + m[13] * c.a + m[14];',
+ 'gl_FragColor.a = m[15] * c.r + m[16] * c.g + m[17] * c.b + m[18] * c.a + m[19];',
+ '}',
+ ].join('\n');
+ _filter.colorMatrix.SHADER.WITHOUT_ALPHA = [
+ 'precision highp float;',
+ 'varying vec2 vUv;',
+ 'uniform sampler2D texture;',
+ 'uniform float m[20];',
+
+ 'void main(void) {',
+ 'vec4 c = texture2D(texture, vUv);',
+ 'gl_FragColor.r = m[0] * c.r + m[1] * c.g + m[2] * c.b + m[4];',
+ 'gl_FragColor.g = m[5] * c.r + m[6] * c.g + m[7] * c.b + m[9];',
+ 'gl_FragColor.b = m[10] * c.r + m[11] * c.g + m[12] * c.b + m[14];',
+ 'gl_FragColor.a = c.a;',
+ '}',
+ ].join('\n');
+
+ _filter.brightness = function( brightness ) {
+ var b = (brightness || 0) + 1;
+ _filter.colorMatrix([
+ b, 0, 0, 0, 0,
+ 0, b, 0, 0, 0,
+ 0, 0, b, 0, 0,
+ 0, 0, 0, 1, 0
+ ]);
+ };
+
+ _filter.saturation = function( amount ) {
+ var x = (amount || 0) * 2/3 + 1;
+ var y = ((x-1) *-0.5);
+ _filter.colorMatrix([
+ x, y, y, 0, 0,
+ y, x, y, 0, 0,
+ y, y, x, 0, 0,
+ 0, 0, 0, 1, 0
+ ]);
+ };
+
+ _filter.desaturate = function() {
+ _filter.saturation(-1);
+ };
+
+ _filter.contrast = function( amount ) {
+ var v = (amount || 0) + 1;
+ var o = -128 * (v-1);
+
+ _filter.colorMatrix([
+ v, 0, 0, 0, o,
+ 0, v, 0, 0, o,
+ 0, 0, v, 0, o,
+ 0, 0, 0, 1, 0
+ ]);
+ };
+
+ _filter.negative = function() {
+ _filter.contrast(-2);
+ };
+
+ _filter.hue = function( rotation ) {
+ rotation = (rotation || 0)/180 * Math.PI;
+ var cos = Math.cos(rotation),
+ sin = Math.sin(rotation),
+ lumR = 0.213,
+ lumG = 0.715,
+ lumB = 0.072;
+
+ _filter.colorMatrix([
+ lumR+cos*(1-lumR)+sin*(-lumR),lumG+cos*(-lumG)+sin*(-lumG),lumB+cos*(-lumB)+sin*(1-lumB),0,0,
+ lumR+cos*(-lumR)+sin*(0.143),lumG+cos*(1-lumG)+sin*(0.140),lumB+cos*(-lumB)+sin*(-0.283),0,0,
+ lumR+cos*(-lumR)+sin*(-(1-lumR)),lumG+cos*(-lumG)+sin*(lumG),lumB+cos*(1-lumB)+sin*(lumB),0,0,
+ 0, 0, 0, 1, 0
+ ]);
+ };
+
+ _filter.desaturateLuminance = function( amount ) {
+ _filter.colorMatrix([
+ 0.2764723, 0.9297080, 0.0938197, 0, -37.1,
+ 0.2764723, 0.9297080, 0.0938197, 0, -37.1,
+ 0.2764723, 0.9297080, 0.0938197, 0, -37.1,
+ 0, 0, 0, 1, 0
+ ], amount);
+ };
+
+ _filter.sepia = function( amount ) {
+ _filter.colorMatrix([
+ 0.393, 0.7689999, 0.18899999, 0, 0,
+ 0.349, 0.6859999, 0.16799999, 0, 0,
+ 0.272, 0.5339999, 0.13099999, 0, 0,
+ 0,0,0,1,0
+ ], amount);
+ };
+
+ _filter.brownie = function( amount ) {
+ _filter.colorMatrix([
+ 0.5997023498159715,0.34553243048391263,-0.2708298674538042,0,47.43192855600873,
+ -0.037703249837783157,0.8609577587992641,0.15059552388459913,0,-36.96841498319127,
+ 0.24113635128153335,-0.07441037908422492,0.44972182064877153,0,-7.562075277591283,
+ 0,0,0,1,0
+ ], amount);
+ };
+
+ _filter.vintagePinhole = function( amount ) {
+ _filter.colorMatrix([
+ 0.6279345635605994,0.3202183420819367,-0.03965408211312453,0,9.651285835294123,
+ 0.02578397704808868,0.6441188644374771,0.03259127616149294,0,7.462829176470591,
+ 0.0466055556782719,-0.0851232987247891,0.5241648018700465,0,5.159190588235296,
+ 0,0,0,1,0
+ ], amount);
+ };
+
+ _filter.kodachrome = function( amount ) {
+ _filter.colorMatrix([
+ 1.1285582396593525,-0.3967382283601348,-0.03992559172921793,0,63.72958762196502,
+ -0.16404339962244616,1.0835251566291304,-0.05498805115633132,0,24.732407896706203,
+ -0.16786010706155763,-0.5603416277695248,1.6014850761964943,0,35.62982807460946,
+ 0,0,0,1,0
+ ], amount);
+ };
+
+ _filter.technicolor = function( amount ) {
+ _filter.colorMatrix([
+ 1.9125277891456083,-0.8545344976951645,-0.09155508482755585,0,11.793603434377337,
+ -0.3087833385928097,1.7658908555458428,-0.10601743074722245,0,-70.35205161461398,
+ -0.231103377548616,-0.7501899197440212,1.847597816108189,0,30.950940869491138,
+ 0,0,0,1,0
+ ], amount);
+ };
+
+ _filter.polaroid = function( amount ) {
+ _filter.colorMatrix([
+ 1.438,-0.062,-0.062,0,0,
+ -0.122,1.378,-0.122,0,0,
+ -0.016,-0.016,1.483,0,0,
+ 0,0,0,1,0
+ ], amount);
+ };
+
+ _filter.shiftToBGR = function(amount) {
+ _filter.colorMatrix([
+ 0,0,1,0,0,
+ 0,1,0,0,0,
+ 1,0,0,0,0,
+ 0,0,0,1,0
+ ], amount);
+ };
+
+
+ // -------------------------------------------------------------------------
+ // Convolution Filter
+
+ _filter.convolution = function( matrix ) {
+ var m = new Float32Array(matrix);
+ var pixelSizeX = 1 / _width;
+ var pixelSizeY = 1 / _height;
+
+ var program = _compileShader(_filter.convolution.SHADER);
+ gl.uniform1fv(program.uniform.m, m);
+ gl.uniform2f(program.uniform.px, pixelSizeX, pixelSizeY);
+ _draw();
+ };
+
+ _filter.convolution.SHADER = [
+ 'precision highp float;',
+ 'varying vec2 vUv;',
+ 'uniform sampler2D texture;',
+ 'uniform vec2 px;',
+ 'uniform float m[9];',
+
+ 'void main(void) {',
+ 'vec4 c11 = texture2D(texture, vUv - px);', // top left
+ 'vec4 c12 = texture2D(texture, vec2(vUv.x, vUv.y - px.y));', // top center
+ 'vec4 c13 = texture2D(texture, vec2(vUv.x + px.x, vUv.y - px.y));', // top right
+
+ 'vec4 c21 = texture2D(texture, vec2(vUv.x - px.x, vUv.y) );', // mid left
+ 'vec4 c22 = texture2D(texture, vUv);', // mid center
+ 'vec4 c23 = texture2D(texture, vec2(vUv.x + px.x, vUv.y) );', // mid right
+
+ 'vec4 c31 = texture2D(texture, vec2(vUv.x - px.x, vUv.y + px.y) );', // bottom left
+ 'vec4 c32 = texture2D(texture, vec2(vUv.x, vUv.y + px.y) );', // bottom center
+ 'vec4 c33 = texture2D(texture, vUv + px );', // bottom right
+
+ 'gl_FragColor = ',
+ 'c11 * m[0] + c12 * m[1] + c22 * m[2] +',
+ 'c21 * m[3] + c22 * m[4] + c23 * m[5] +',
+ 'c31 * m[6] + c32 * m[7] + c33 * m[8];',
+ 'gl_FragColor.a = c22.a;',
+ '}',
+ ].join('\n');
+
+
+ _filter.detectEdges = function() {
+ _filter.convolution.call(this, [
+ 0, 1, 0,
+ 1, -4, 1,
+ 0, 1, 0
+ ]);
+ };
+
+ _filter.sobelX = function() {
+ _filter.convolution.call(this, [
+ -1, 0, 1,
+ -2, 0, 2,
+ -1, 0, 1
+ ]);
+ };
+
+ _filter.sobelY = function() {
+ _filter.convolution.call(this, [
+ -1, -2, -1,
+ 0, 0, 0,
+ 1, 2, 1
+ ]);
+ };
+
+ _filter.sharpen = function( amount ) {
+ var a = amount || 1;
+ _filter.convolution.call(this, [
+ 0, -1*a, 0,
+ -1*a, 1 + 4*a, -1*a,
+ 0, -1*a, 0
+ ]);
+ };
+
+ _filter.emboss = function( size ) {
+ var s = size || 1;
+ _filter.convolution.call(this, [
+ -2*s, -1*s, 0,
+ -1*s, 1, 1*s,
+ 0, 1*s, 2*s
+ ]);
+ };
+
+
+ // -------------------------------------------------------------------------
+ // Blur Filter
+
+ _filter.blur = function( size ) {
+ var blurSizeX = (size/7) / _width;
+ var blurSizeY = (size/7) / _height;
+
+ var program = _compileShader(_filter.blur.SHADER);
+
+ // Vertical
+ gl.uniform2f(program.uniform.px, 0, blurSizeY);
+ _draw(DRAW.INTERMEDIATE);
+
+ // Horizontal
+ gl.uniform2f(program.uniform.px, blurSizeX, 0);
+ _draw();
+ };
+
+ _filter.blur.SHADER = [
+ 'precision highp float;',
+ 'varying vec2 vUv;',
+ 'uniform sampler2D texture;',
+ 'uniform vec2 px;',
+
+ 'void main(void) {',
+ 'gl_FragColor = vec4(0.0);',
+ 'gl_FragColor += texture2D(texture, vUv + vec2(-7.0*px.x, -7.0*px.y))*0.0044299121055113265;',
+ 'gl_FragColor += texture2D(texture, vUv + vec2(-6.0*px.x, -6.0*px.y))*0.00895781211794;',
+ 'gl_FragColor += texture2D(texture, vUv + vec2(-5.0*px.x, -5.0*px.y))*0.0215963866053;',
+ 'gl_FragColor += texture2D(texture, vUv + vec2(-4.0*px.x, -4.0*px.y))*0.0443683338718;',
+ 'gl_FragColor += texture2D(texture, vUv + vec2(-3.0*px.x, -3.0*px.y))*0.0776744219933;',
+ 'gl_FragColor += texture2D(texture, vUv + vec2(-2.0*px.x, -2.0*px.y))*0.115876621105;',
+ 'gl_FragColor += texture2D(texture, vUv + vec2(-1.0*px.x, -1.0*px.y))*0.147308056121;',
+ 'gl_FragColor += texture2D(texture, vUv )*0.159576912161;',
+ 'gl_FragColor += texture2D(texture, vUv + vec2( 1.0*px.x, 1.0*px.y))*0.147308056121;',
+ 'gl_FragColor += texture2D(texture, vUv + vec2( 2.0*px.x, 2.0*px.y))*0.115876621105;',
+ 'gl_FragColor += texture2D(texture, vUv + vec2( 3.0*px.x, 3.0*px.y))*0.0776744219933;',
+ 'gl_FragColor += texture2D(texture, vUv + vec2( 4.0*px.x, 4.0*px.y))*0.0443683338718;',
+ 'gl_FragColor += texture2D(texture, vUv + vec2( 5.0*px.x, 5.0*px.y))*0.0215963866053;',
+ 'gl_FragColor += texture2D(texture, vUv + vec2( 6.0*px.x, 6.0*px.y))*0.00895781211794;',
+ 'gl_FragColor += texture2D(texture, vUv + vec2( 7.0*px.x, 7.0*px.y))*0.0044299121055113265;',
+ '}',
+ ].join('\n');
+
+
+ // -------------------------------------------------------------------------
+ // Pixelate Filter
+
+ _filter.pixelate = function( size ) {
+ var blurSizeX = (size) / _width;
+ var blurSizeY = (size) / _height;
+
+ var program = _compileShader(_filter.pixelate.SHADER);
+
+ // Horizontal
+ gl.uniform2f(program.uniform.size, blurSizeX, blurSizeY);
+ _draw();
+ };
+
+ _filter.pixelate.SHADER = [
+ 'precision highp float;',
+ 'varying vec2 vUv;',
+ 'uniform vec2 size;',
+ 'uniform sampler2D texture;',
+
+ 'vec2 pixelate(vec2 coord, vec2 size) {',
+ 'return floor( coord / size ) * size;',
+ '}',
+
+ 'void main(void) {',
+ 'gl_FragColor = vec4(0.0);',
+ 'vec2 coord = pixelate(vUv, size);',
+ 'gl_FragColor += texture2D(texture, coord);',
+ '}',
+ ].join('\n');
+};
+
+})(window);
diff --git a/addons/web_editor/static/shapes/Airy/01.svg b/addons/web_editor/static/shapes/Airy/01.svg
new file mode 100644
index 00000000..749eab07
--- /dev/null
+++ b/addons/web_editor/static/shapes/Airy/01.svg
@@ -0,0 +1,42 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+<style type="text/css">
+ .st0{fill:none;stroke:#3AADAA;stroke-width:9;stroke-miterlimit:10}
+ .st1{fill:none;stroke:#3AADAA;stroke-width:3;stroke-miterlimit:10}
+ .st2{fill:#3AADAA}
+ .selector{display:none}
+ @media only screen and (max-width: 300px) {
+ .selector{display:block}
+ .page{display:none}
+ }
+</style>
+<g class="page">
+ <polygon class="st2" points="1398.6,570 700.1,286.6 5.9,570 0,570 0,569.2 700.1,283.4 700.7,283.6 1400,567.3 1400,570"/>
+ <polygon class="st2" points="727.3,570 700.1,559.1 674,570 671.4,570 700.1,558.1 730,570"/>
+ <polygon class="st2" points="783.5,570 700.1,536.3 618,570 615.4,570 700.1,535.3 786.1,570"/>
+ <polygon class="st2" points="839.6,570 700.1,513.5 562.1,570 559.5,570 700.1,512.5 700.3,512.5 842.2,570"/>
+ <polygon class="st2" points="895.6,570 700.1,490.7 506.1,570 503.5,570 700.1,489.7 700.3,489.7 898.2,570"/>
+ <polygon class="st2" points="951.8,570 700.1,467.9 450.2,570 447.6,570 700.1,466.9 700.3,466.9 954.4,570"/>
+ <polygon class="st2" points="1007.8,570 700.1,445.1 394.2,570 391.6,570 700.1,444.1 700.3,444.1 1010.4,570"/>
+ <polygon class="st2" points="1064,570 700.1,422.3 338.3,570 335.7,570 700.1,421.3 700.3,421.3 1066.6,570"/>
+ <polygon class="st2" points="1120.2,570 700.1,399.5 282.4,570 279.8,570 700.1,398.5 700.3,398.5 1122.8,570"/>
+ <polygon class="st2" points="226.5,570 223.9,570 700.1,375.7 700.3,375.7 1179,570 1176.4,570 700.1,376.7"/>
+ <polygon class="st2" points="170.7,570 168.1,570 700.1,352.9 700.3,352.9 1235.2,570 1232.6,570 700.1,353.9"/>
+ <polygon class="st2" points="114.9,570 112.3,570 700.1,330.1 700.3,330.1 1291.4,570 1288.8,570 700.1,331.1"/>
+ <polygon class="st2" points="1345.1,570 700.1,308.3 59,570 56.4,570 700.1,307.3 700.3,307.3 1347.7,570"/>
+</g>
+<g class="selector">
+ <polyline class="st0" points="1.9,570 700.1,285 1402.6,570"/>
+ <polyline class="st1" points="728.7,570 700.1,558.6 672.7,570"/>
+ <polyline class="st1" points="784.8,570 700.1,535.8 616.7,570"/>
+ <polyline class="st1" points="841.4,570.2 700.1,513 560.8,570"/>
+ <polyline class="st1" points="896.9,570 700.1,490.2 504.8,570"/>
+ <polyline class="st1" points="953.6,570.2 700.1,467.4 448.9,570"/>
+ <polyline class="st1" points="1009.1,570 700.1,444.6 392.9,570"/>
+ <polyline class="st1" points="1065.3,570 700.1,421.8 337,570"/>
+ <polyline class="st1" points="1121.5,570 700.1,399 281.1,570"/>
+ <polyline class="st1" points="1177,569.7 700.1,376.2 225.2,570"/>
+ <polyline class="st1" points="1233.2,569.7 700.1,353.4 169.4,570"/>
+ <polyline class="st1" points="1289.4,569.7 700.1,330.6 113.6,570"/>
+ <polyline class="st1" points="1346.9,570.2 700.1,307.8 57.7,570"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Airy/02.svg b/addons/web_editor/static/shapes/Airy/02.svg
new file mode 100644
index 00000000..51c5a6ab
--- /dev/null
+++ b/addons/web_editor/static/shapes/Airy/02.svg
@@ -0,0 +1,66 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+<style type="text/css">
+ .st0{fill:none;stroke:#3AADAA;stroke-width:9;stroke-miterlimit:10}
+ .st1{fill:none;stroke:#3AADAA;stroke-width:3;stroke-miterlimit:10}
+ .st2{fill:#3AADAA}
+ .selector{display:none}
+ @media only screen and (max-width: 300px) {
+ .selector{display:block}
+ .page{display:none}
+ }
+</style>
+<g class="page">
+ <polygon class="st2" points="1400,296.7 1394.9,296.7 700,14.1 5.7,296.7 0,296.7 0,295.8 700,10.8 1400,295.5"/>
+ <polygon class="st2" points="0,275.2 0,274.1 671.4,0 674,0"/>
+ <polygon class="st2" points="1400,275.2 727.3,0 730,0 1400,274.1"/>
+ <polygon class="st2" points="0,252.4 0,251.3 615.4,0 618,0"/>
+ <polygon class="st2" points="1400,252.4 783.5,0 786.1,0 1400,251.3"/>
+ <polygon class="st2" points="0,229.6 0,228.5 559.5,0 562.1,0"/>
+ <polygon class="st2" points="1400,229.6 839.6,0 842.2,0 1400,228.5"/>
+ <polygon class="st2" points="0,206.8 0,205.7 503.5,0 506.1,0"/>
+ <polygon class="st2" points="1400,206.8 895.6,0 898.2,0 1400,205.7"/>
+ <polygon class="st2" points="0,184 0,182.9 447.6,0 450.2,0"/>
+ <polygon class="st2" points="1400,183.9 951.8,0 954.4,0 1400,182.8"/>
+ <polygon class="st2" points="0,161.2 0,160.1 391.6,0 394.2,0"/>
+ <polygon class="st2" points="1400,161.1 1007.8,0 1010.4,0 1400,160"/>
+ <polygon class="st2" points="0,138.4 0,137.3 335.7,0 338.3,0"/>
+ <polygon class="st2" points="1400,138.3 1064,0 1066.6,0 1400,137.2"/>
+ <polygon class="st2" points="0,115.6 0,114.5 279.8,0 282.4,0"/>
+ <polygon class="st2" points="1400,115.5 1120.2,0 1122.8,0 1400,114.4"/>
+ <polygon class="st2" points="0,92.8 0,91.7 223.9,0 226.5,0"/>
+ <polygon class="st2" points="1400,92.7 1176.4,0 1179,0 1400,91.6"/>
+ <polygon class="st2" points="0,70 0,68.9 168.1,0 170.7,0"/>
+ <polygon class="st2" points="1400,69.9 1232.6,0 1235.2,0 1400,68.8"/>
+ <polygon class="st2" points="0,47.2 0,46.1 112.3,0 114.9,0"/>
+ <polygon class="st2" points="1400,47.1 1288.8,0 1291.4,0 1400,46"/>
+ <polygon class="st2" points="0,24.4 0,23.3 56.4,0 59,0"/>
+ <polygon class="st2" points="1400,24.3 1345.1,0 1347.7,0 1400,23.2"/>
+</g>
+<g class="selector">
+ <polyline class="st0" points="0,296.7 700,11.7 1400.7,296.7"/>
+ <line class="st1" x1="672.6" y1="0" x2="0" y2="273.9"/>
+ <line class="st1" x1="1400" y1="273.9" x2="728.6" y2="0"/>
+ <line class="st1" x1="616.1" y1="0" x2="0" y2="251.1"/>
+ <line class="st1" x1="1400" y1="251.1" x2="784.6" y2="0"/>
+ <line class="st1" x1="559.8" y1="0" x2="0" y2="228.3"/>
+ <line class="st1" x1="1400" y1="228.3" x2="841.1" y2="0"/>
+ <line class="st1" x1="503.7" y1="0" x2="0" y2="205.5"/>
+ <line class="st1" x1="1400" y1="205.5" x2="896.6" y2="0"/>
+ <line class="st1" x1="447.7" y1="0" x2="0" y2="182.7"/>
+ <line class="st1" x1="1400" y1="182.6" x2="952.4" y2="0"/>
+ <line class="st1" x1="391.7" y1="0" x2="0" y2="159.9"/>
+ <line class="st1" x1="1400" y1="159.8" x2="1008.9" y2="0"/>
+ <line class="st1" x1="335.7" y1="0" x2="0" y2="137.1"/>
+ <line class="st1" x1="1400" y1="137" x2="1063.1" y2="0"/>
+ <line class="st1" x1="279.7" y1="0" x2="0" y2="114.3"/>
+ <line class="st1" x1="1400" y1="114.2" x2="1121.2" y2="0"/>
+ <line class="st1" x1="223.7" y1="0" x2="0" y2="91.5"/>
+ <line class="st1" x1="1400" y1="91.4" x2="1177.4" y2="0"/>
+ <line class="st1" x1="168" y1="0" x2="0" y2="68.7"/>
+ <line class="st1" x1="1400" y1="68.6" x2="1231.5" y2="0"/>
+ <line class="st1" x1="111.8" y1="0" x2="0" y2="45.9"/>
+ <line class="st1" x1="1400" y1="45.8" x2="1289.1" y2="0"/>
+ <line class="st1" x1="55.9" y1="0" x2="0" y2="23.1"/>
+ <line class="st1" x1="1400" y1="23" x2="1343.4" y2="0"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Airy/03.svg b/addons/web_editor/static/shapes/Airy/03.svg
new file mode 100644
index 00000000..33c51924
--- /dev/null
+++ b/addons/web_editor/static/shapes/Airy/03.svg
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1400 244.7" xml:space="preserve">
+<style type="text/css">
+ .st0{opacity:0.58;}
+ .st1{fill:none;stroke:#383E45;stroke-width:1.4665;stroke-miterlimit:10;}
+ .st2{fill:none;stroke:#383E45;stroke-width:1.3094;stroke-miterlimit:10;}
+ .st3{fill:none;stroke:#383E45;stroke-width:1.1523;stroke-miterlimit:10;}
+ .st4{fill:none;stroke:#383E45;stroke-width:0.9952;stroke-miterlimit:10;}
+ .st5{fill:none;stroke:#383E45;stroke-width:0.838;stroke-miterlimit:10;}
+ .st6{fill:none;stroke:#383E45;stroke-width:0.6809;stroke-miterlimit:10;}
+ .st7{fill:none;stroke:#383E45;stroke-width:0.5238;stroke-miterlimit:10;}
+ .st8{fill:none;stroke:#383E45;stroke-width:0.3666;stroke-miterlimit:10;}
+ @media only screen and (max-width: 300px) {
+ .st1{stroke-width:4.3995}
+ .st2{stroke-width:3.9282}
+ .st3{stroke-width:3.4569}
+ .st4{stroke-width:2.9856}
+ .st5{stroke-width:2.5140}
+ .st6{stroke-width:2.0427}
+ .st7{stroke-width:1.5714}
+ .st8{stroke-width:1.0998}
+ }
+</style>
+<g class="st0">
+ <path class="st1" d="M-1.9,79.2c27.4,7.3,60.7,4,88.4-3s52.1-17.2,79.6-24.4c46.9-12.3,102.3-15.2,152.2-7.1
+ c50.2,8.1,88.1,28.1,135.8,38.4c35.5,7.6,94,5.8,125-7c25.4-10.4,35.2-28.2,49-43.8c18-20,43.9-31,70.8-30
+ c25.5,1,44.7,12.7,59,24.1c54.7,43.4,94,87.4,177.5,118c24.5,9,52.1,16.8,81.8,17.2c9.2,0.1,18.7-0.4,27-2.6
+ c7.6-2,14.8-5.2,21.4-9.5c39.4-24.6,49.2-58.6,65.6-89.8c4-7.7,9.3-15.8,21.4-20.4c8.7-3.3,19.7-4.2,30.3-4.1
+ c16.4,0,32.7,2.5,48.4,7.2c48.7,15.3,80.6,68.2,146.9,64.6l22.3-1.2"/>
+ <path class="st2" d="M1400.7,122.7l-13.9,0.1c-1-0.1-10.9,0.1-11.9,0.1c-75.5-4.4-118.8-45-174.5-60.2c-14.5-4-29.5-6.1-44.6-6.4
+ c-9.7-0.1-19.8,0.6-27.9,3.4c-11.1,3.8-16.2,10.8-20.3,17.4c-16.6,26.6-27.2,55.9-63.4,76.7c-6.2,3.7-12.8,6.4-19.8,8.1
+ c-8.2,1.7-16.5,2.4-24.8,2.1c-27.2-0.5-52.6-7.1-75.4-14.9C848,123,810,84.9,758.6,47.9c-13.6-9.8-31.4-19.7-54.7-20.6
+ c-29-1.1-52.1,12.6-65.7,25.9C624.4,66.7,614,82.1,590.1,91c-30.4,11.3-84.5,13.1-118.8,6.8c-45.2-8.3-82-25.1-129.4-31.6
+ s-99.5-2.9-144.5,7.9c-31.8,7.7-61.6,17.7-94.6,25.5c-27.8,6.5-59.6,11-89.2,9.8c-3.7-0.5-11.3-0.3-14.8-1.3"/>
+ <path class="st3" d="M1400.7,139.5L1385,139c-2.1-0.3-11.5-0.2-13.5-0.4c-84.7-6.5-139.2-40.9-202.1-55.8
+ c-13.3-3.3-27-5.2-40.7-5.6c-8.9-0.1-18,0.4-25.5,2.7c-10,3.1-15,9-19.1,14.4c-16.7,22-28.1,46.5-61.1,63.5
+ c-5.7,3-11.9,5.3-18.2,6.7c-7.5,1.4-15.1,2-22.7,1.7c-24.8-0.4-48-6-69-12.6c-68.9-21.7-105.7-53.8-153.8-84.3
+ c-12.9-8.2-29.4-16.4-50.3-17.1c-26.2-0.9-47.3,10.7-60.6,21.8c-13.8,11.4-24.9,24.4-47.2,31.8c-29.8,9.9-79.4,11.5-112.6,6.6
+ c-42.7-6.4-78.3-20-122.9-24.8c-44.9-4.7-93.7-0.9-136.8,8.8c-36.1,8.1-71.2,17.9-109.7,26.5c-32.1,7.2-67.9,12.9-102.4,14.2
+ c-3-0.4-14.1,0.8-17,0"/>
+ <path class="st4" d="M1400.7,156.3l-17.6-1c-3.1-0.4-12-0.6-15.1-0.9c-93.9-8.5-159.4-37.1-229.7-51.5c-12.1-2.7-24.5-4.2-36.9-4.7
+ c-8-0.2-16.2,0.2-23.1,2c-8.9,2.4-13.8,7-18,11.4c-16.7,17.4-29.1,37.1-58.8,50.4c-5.3,2.4-10.9,4.2-16.6,5.3
+ c-6.8,1.1-13.7,1.5-20.6,1.3c-22.3-0.4-43.4-4.9-62.6-10.2c-61.6-17.3-97.2-43.4-141.9-67.5c-12.2-6.6-27.3-13-46-13.5
+ c-23.2-0.6-42.4,8.8-55.5,17.6c-13.8,9.4-25.6,19.9-46.3,25.8c-29.2,8.4-74.3,10-106.3,6.4c-40.1-4.5-74.7-14.9-116.5-18.1
+ c-42.3-3-87.9,1.1-129,9.6c-40.5,8.4-80.9,18.1-124.7,27.6c-36.3,7.8-76.2,14.8-115.7,18.5c-2.4-0.4-16.9,1.8-19.2,1.3"/>
+ <path class="st5" d="M1400.7,161.1l-19.4-1.5c-4.1-0.5-12.6-0.9-16.7-1.3c-103.2-10.5-179.6-33.7-257.3-47.1
+ c-10.5-1.8-21.7-3.6-33.1-3.9c-7.1-0.2-14.4,0-20.7,1.3c-7.8,1.7-12.6,5.1-16.8,8.3c-16.8,12.7-30.1,27.6-56.6,37.2
+ c-4.9,1.8-9.9,3.1-15,3.9c-6.1,0.8-12.3,1.1-18.4,0.9c-19.8-0.4-38.7-3.7-56.2-7.9c-54.2-12.9-88.7-32.9-130.1-50.7
+ c-11.5-5-25.2-9.7-41.6-10c-20.4-0.4-37.7,6.8-50.3,13.5c-13.9,7.3-26.3,15.3-45.4,19.9c-28.6,6.8-69.3,8.4-100.1,6.2
+ c-37.6-2.7-71-9.9-110-11.3c-39.8-1.4-82.1,2.9-121.3,10.4c-45.1,8.6-90.7,18.4-140,28.6c-40.6,8.5-84.5,16.7-129,22.8
+ c-1.8-0.3-19.7,3-21.4,2.5"/>
+ <path class="st6" d="M1400.7,164.7l-21.3-2.1c-5.2-0.7-13.2-1.2-18.3-1.8c-112.5-12.6-199.7-30.8-284.9-42.8
+ c-9.7-1.5-19.4-2.5-29.2-3c-6.1-0.3-12.2-0.1-18.3,0.6c-5.5,0.9-10.7,2.7-15.6,5.3c-16.8,8.1-31.2,18-54.3,24.1
+ c-4.4,1.2-8.9,2-13.5,2.4c-5.4,0.5-10.9,0.6-16.3,0.5c-16.7-0.5-33.4-2.4-49.8-5.6c-46.8-8.6-80.3-22.3-118.2-33.8
+ c-10.9-3.3-23.2-6.3-37.3-6.5c-17.5-0.2-32.9,4.9-45.2,9.4c-14,5.1-27,10.7-44.5,13.9c-28.1,5.2-64.2,6.7-93.8,6
+ c-35-0.9-67.4-4.7-103.5-4.5c-37.3,0.3-76.4,4.6-113.6,11.2c-49.3,8.7-100.2,18.6-154.8,29.7c-44.8,9.1-92.8,18.5-142.3,27.1
+ c-1.2-0.2-22.5,4.1-23.7,3.8"/>
+ <path class="st7" d="M1400.7,168.2l-23.2-2.7c-6.2-0.8-13.8-1.6-19.9-2.3c-121.9-14.6-219.8-28.6-312.6-38.4
+ c-8.3-0.9-16.9-1.8-25.4-2.2c-5.3-0.3-10.6-0.3-15.9-0.1c-4.9,0.4-9.7,1.2-14.5,2.3c-16.8,3.6-32.3,8.3-52.1,10.9
+ c-3.8,0.5-7.8,0.9-11.9,1.1c-4.6,0.2-9.4,0.2-14.1,0c-14.9-0.4-29.4-1.6-43.4-3.2c-39.4-4.5-71.9-11.6-106.4-17
+ c-10.9-1.9-21.9-2.9-32.9-3c-14.6,0.1-28.1,2.8-40.1,5.2c-14.1,2.9-27.8,6-43.7,7.9c-27.5,3.4-59.1,5.1-87.6,5.8
+ c-32.5,0.8-63.7,0.4-97.1,2.2c-35.5,2.2-70.8,6.3-105.8,12.1c-53.8,8.7-109.8,18.9-169.9,30.8C135.1,187.4,83.1,198,28.6,209
+ c-0.6-0.1-25.3,5.2-25.9,5.1"/>
+ <path class="st8" d="M1400.7,172.1l-25-3.2c-141-18.3-255.9-30-361.6-36.8c-112.2-7.3-215.4-9-315.5-5.2
+ c-101.8,3.8-204.2,13.5-312.9,29.4C280,171.8,164.3,194,31.8,224l-28.1,6.4"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Airy/04.svg b/addons/web_editor/static/shapes/Airy/04.svg
new file mode 100644
index 00000000..c6ceba19
--- /dev/null
+++ b/addons/web_editor/static/shapes/Airy/04.svg
@@ -0,0 +1,32 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570" preserveAspectRatio="none">
+<style type="text/css">
+ .st0{fill:none;stroke:#3AADAA;stroke-miterlimit:10;}
+ @media only screen and (max-width: 300px) {
+ .st0{stroke-width:4}
+ }
+</style>
+<g>
+ <path class="st0" d="M1260.5,0.5c3.9,4.4,7.7,9.1,11.5,13.8c57.8,72.7-222,169-222,271.2s279.8,198.6,221.9,271.3
+ c-3.8,4.7-7.5,8.7-11.5,13.2"/>
+ <path class="st0" d="M1273.5,0.5c4,4.4,8,9,11.8,13.7c32,38.8-25.9,83.9-85.6,134c-51.4,43.1-104.3,89.8-104.3,137.4
+ s53,94.4,104.3,137.4c59.7,50,117.6,95.2,85.6,134c-3.9,4.7-7.8,8.6-11.8,13"/>
+ <path class="st0" d="M1286.5,0.5c4.2,4.4,8.2,8.9,12.2,13.6c32.9,38.4-16.4,82.9-67.6,132.8c-44.2,43.2-90.4,90.6-90.4,138.7
+ s46.2,95.5,90.4,138.7c51.1,49.9,100.4,94.4,67.6,132.8c-3.8,4.4-8,8.8-12,13"/>
+ <path class="st0" d="M1299.6,0.5c4.3,4.3,8.5,8.9,12.6,13.5c33.7,38.1-7,81.9-49.5,131.6c-37.2,43.5-76.5,91.4-76.5,140
+ s39.3,96.5,76.5,140c42.5,49.8,83.2,93.5,49.5,131.6c-4,4.4-8.2,8.7-12.3,12.8"/>
+ <path class="st0" d="M1312.7,0.5c4.4,4.3,8.7,8.8,12.9,13.4c34.7,37.7,2.5,81-31.4,130.5c-30,43.8-62.6,92.2-62.6,141.2
+ s32.5,97.5,62.6,141.2c33.9,49.5,66.1,92.8,31.4,130.5c-4.1,4.4-8.3,8.6-12.6,12.8"/>
+ <path class="st0" d="M1325.7,0.5c4.5,4.3,8.9,8.8,13.2,13.3c35.6,37.3,12,80.3-13.4,129.3c-22.9,44.3-48.6,93.1-48.6,142.5
+ s25.7,98.2,48.6,142.5c25.4,49,48.9,92,13.4,129.3c-4.2,4.4-8.5,8.5-12.9,12.7"/>
+ <path class="st0" d="M1338.8,0.5c4.6,4.2,9.1,8.7,13.6,13.2c36.4,37,21.6,79.7,4.7,128.1c-15.7,44.9-34.7,93.9-34.7,143.7
+ s19,98.9,34.7,143.7c17,48.4,31.8,91.2-4.7,128.1c-4.4,4.4-8.8,8.4-13.4,12.6"/>
+ <path class="st0" d="M1351.8,0.5c4.7,4.2,9.4,8.6,13.9,13.1c37.2,36.7,31.4,79.3,22.7,127c-8.3,45.6-20.8,94.7-20.8,145
+ s12.4,99.4,20.8,145c8.7,47.7,14.5,90.2-22.7,127c-4.5,4.4-9,8.3-13.7,12.5"/>
+ <path class="st0" d="M1400,532c-5.8,9.4-12.8,18-20.9,25.6c-4.6,4.4-9.2,8.3-14,12.4"/>
+ <path class="st0" d="M1364.9,0c4.8,4.2,9.6,8.6,14.2,13c8,7.6,15,16.2,20.9,25.6"/>
+ <path class="st0" d="M1400,550.3c-2.4,2.5-4.9,4.9-7.5,7.3c-4.7,4.4-9.4,8.2-14.3,12.3"/>
+ <path class="st0" d="M1377.9,0c5,4.2,9.9,8.5,14.7,12.9c2.4,2.3,4.8,4.5,7,6.8"/>
+ <path class="st0" d="M1400,563c-2.9,2.5-5.7,4.6-8.7,7"/>
+ <path class="st0" d="M1391.1,0c3,2.4,5.9,5.1,8.9,7.6"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Airy/05.svg b/addons/web_editor/static/shapes/Airy/05.svg
new file mode 100644
index 00000000..c539be50
--- /dev/null
+++ b/addons/web_editor/static/shapes/Airy/05.svg
@@ -0,0 +1,34 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570" preserveAspectRatio="none">
+<style type="text/css">
+ .st0{fill:none;stroke:#3AADAA;stroke-miterlimit:10;}
+ .st1{fill:none;stroke:#3AADAA;stroke-width:0.9994;stroke-miterlimit:10;}
+ @media only screen and (max-width: 300px) {
+ .st0{stroke-width:3}
+ .st1{stroke-width:3}
+ }
+</style>
+<g>
+ <path class="st0" d="M1260.5,0c-19.6,19.3-44.3,34.5-41.1,53.1c6.2,35.4,146.6,72.9,146.6,113.1c0,72.3-126.5,99.3-136.6,156.3
+ c-10.3,58,184.4,112.8,127.7,136.2c-27.1,11.2-188.7,10.7-99.3,112"/>
+ <path class="st0" d="M1273.5,0c-17.9,17.5-41.6,32.4-40,50.8c3,34.1,126.5,71.1,124.3,110.7c-3.9,69.5-120.8,98.2-128.4,152.4
+ c-6,42.6,89.7,79.2,124.2,109.4c14.5,12.7,18.8,22.2,1.3,29.6c-14.8,6.2-55.1,11.6-81.4,23.7c-32.3,14.9-48,43.4-2.8,94"/>
+ <path class="st0" d="M1286.7,0c-16.3,15.8-39,30.2-39.1,48.5c-0.2,32.8,106.5,69.3,102,108.3c-7.7,66.6-114.8,97.3-120.1,148.6
+ c-4.7,45,94.8,79.2,126.2,112c12.4,13,14.8,22.1-2.9,29.7c-17.2,7.4-57.2,15.2-78.6,28.5c-26.3,16.4-32.4,48.8,9.4,95"/>
+ <path class="st0" d="M1299.8,0c-14.6,14.1-36.2,28-38,46.2c-3.2,31.4,86.6,67.5,79.8,106c-11.3,63.6-108.5,96.4-111.9,144.8
+ c-3.3,47.5,99.8,78.8,128.3,114.7c10.4,13.1,10.8,22-7.1,29.9c-19.7,8.5-61.9,15.5-75.8,33.3c-22.9,13.6-16.7,54.3,21.6,95.9"/>
+ <path class="st0" d="M1313,0c-12.9,12.3-33.4,25.8-37.1,43.8c-6.2,30,66.7,65.7,57.5,103.6c-14.8,60.7-102,95.5-103.7,140.9
+ c-1.8,50,105,78.1,130.3,117.3c8.5,13.1,6.8,22-11.3,30c-22,9.8-64.9,18.2-72.9,38.2c-17.9,13.7-1,59.7,33.8,96.9"/>
+ <path class="st0" d="M1326,0c-11.3,10.6-30.4,23.6-36,41.5c-8.9,28.6,46.9,63.8,35.2,101.2c-18.1,57.8-95.3,94.7-95.4,137.1
+ c-0.1,52.5,110.4,77.1,132.3,119.9c6.7,13,2.8,21.9-15.5,30.2c-24.4,11-68,20.8-70,43c-12.9,13.9,14.6,65.1,46,97.8"/>
+ <path class="st0" d="M1339.1,0c-9.6,8.8-27.4,21.4-35,39.2c-11.6,27.2,27.2,62,12.9,98.8c-21.3,54.9-88.5,93.9-87.2,133.2
+ c1.8,55.1,116.2,75.8,134.3,122.6c5,12.9-1.1,21.9-19.6,30.4c-26.9,12.3-71.1,23.4-67.2,47.8c-7.9,14,30.3,70.6,58.3,98.8"/>
+ <path class="st0" d="M1352,0c-8,7.1-24.2,19.3-33.8,36.9c-14.2,25.9,7.6,60.2-9.4,96.4c-24.4,52.1-81.4,93-79,129.4
+ c3.9,57.6,122.6,74.5,136.3,125.2c3.4,12.6-5.1,21.9-23.8,30.5c-29.3,13.5-74.2,26-64.3,52.6c-2.9,14.1,46,76,70.5,99.7"/>
+ <path class="st0" d="M1365.1,0c-6.3,5.3-21.1,17.2-32.8,34.6c-16.7,24.7-12,58.5-31.7,94c-27.3,49.3-74.3,92.1-70.7,125.6
+ c6.4,60,129.6,73.1,138.3,127.8c2,12.5-9.1,21.8-28,30.7c-31.7,14.8-77.3,28.5-61.4,57.4c2.1,14.2,61.6,81.5,82.7,100.6"/>
+ <path class="st0" d="M1378.2,0c-4.7,3.5-17.9,15.2-31.7,32.3c-19.1,23.6-31.4,56.9-53.9,91.6c-30.2,46.7-67,91.2-62.5,121.7
+ c9.2,62.4,137.2,71.9,140.3,130.5c0.7,12.4-13,21.8-32.2,30.8c-34,16.1-77.9,29.5-58.6,62.2c8.2,13.8,77.3,86.9,94.9,101.6"/>
+ <path class="st1" d="M1391.3,0c-12.3,7.4-173.8,172.3-161.2,236.8s145.5,70.9,142.3,133c-1.8,35.5-134.5,47-92.1,97.8
+ c12.1,14.5,92.9,92.3,107.2,102.4"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Airy/06.svg b/addons/web_editor/static/shapes/Airy/06.svg
new file mode 100644
index 00000000..c79ad3f6
--- /dev/null
+++ b/addons/web_editor/static/shapes/Airy/06.svg
@@ -0,0 +1,303 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+<style type="text/css">
+ .st0{opacity:0.14;}
+ .st1{fill:#7C6576;}
+ @media only screen and (max-width: 300px) {
+ .st0{opacity:.7;}
+ }
+</style>
+<g class="st0">
+ <path class="st1" d="M1025.2,425.8c-12.3-40.9-35.6-78.6-69.2-112.2c-44.6-44.7-107.5-82.5-187.1-112.5
+ c-73.3-27.6-158.4-47.9-253-60.3C343.4,118.2,150.6,124.1,0,156.5v2c150.4-32.4,343.3-38.2,515.6-15.7
+ c94.5,12.4,179.4,32.6,252.6,60.1c79.3,29.9,142,67.6,186.3,112.1c33.4,33.4,56.5,70.8,68.8,111.4c11.3,37.4,12.6,102,3,143.6
+ c0,0,1.3,0,2,0C1038.1,527.3,1036.7,464.1,1025.2,425.8z"/>
+ <path class="st1" d="M1013.3,567.7c2.6-10.7,5.8-24.1,6.7-37.2c2.7-37-0.6-76.4-8.5-102.8c-1.8-6.1-3.9-12-6.3-18.2
+ c-13.2-33.4-34.1-64.6-62-92.5c-44-44.1-106.1-81.5-184.5-111c-72.1-27.3-156.1-47.3-249.6-59.5C339.1,124.3,148.8,130.1,0,162v2
+ c148.6-31.9,339-37.7,508.8-15.5c93.3,12.2,177.2,32.1,249.2,59.3c78.1,29.5,140,66.7,183.8,110.6c27.8,27.8,48.5,58.7,61.6,91.9
+ c2.5,6.1,4.5,12,6.3,18c7.9,26.1,11.1,65.2,8.5,102c-0.9,13-4.1,26.3-6.7,36.9l-0.7,2.7c0.7,0,1.4,0,2,0L1013.3,567.7z"/>
+ <path class="st1" d="M1000.2,564.5c2.4-10.2,5.2-21.7,6.1-33.4c2.7-36.5-0.6-75.4-8.4-101.5c-1.7-5.8-3.8-11.8-6.2-18
+ c-13-32.9-33.6-63.7-61.2-91.3c-43.6-43.6-104.8-80.4-182-109.5c-71.1-26.8-154-46.5-246.3-58.7c-167.7-21.9-355.3-16.2-502,15.3
+ v1.9c146.5-31.5,334.2-37.2,501.8-15.3c92.2,12.1,174.9,31.8,245.9,58.5c77,29,138,65.7,181.4,109.1c27.5,27.5,47.9,58,60.8,90.7
+ c2.4,6.1,4.5,12.1,6.2,17.8c7.8,25.8,11,64.4,8.4,100.7c-0.8,11.5-3.6,23-6,33.1c-0.5,2-0.9,4.1-1.4,6c0,0,1.3,0,1.9,0
+ C999.3,568.2,999.8,566.4,1000.2,564.5z"/>
+ <path class="st1" d="M986.3,563.2c2.4-9.7,5.1-20.6,5.9-31.7c2.7-36-0.5-74.4-8.2-100c-1.9-6.4-3.9-12.2-6.1-17.8
+ c-12.7-32.4-33-62.7-60.3-90.1c-42.8-42.9-103.2-79.3-179.4-108c-70.4-26.5-152.1-46-242.9-57.9c-165.5-21.7-350.6-16-495.2,15.1
+ v1.9c144.5-31.1,329.6-36.7,495-15.1c90.6,11.9,172.2,31.3,242.5,57.7c76,28.7,136.1,64.9,178.8,107.6
+ c27.1,27.2,47.3,57.3,59.9,89.5c2.2,5.5,4.2,11.2,6.1,17.6c7.7,25.5,10.8,63.5,8.2,99.4c-0.8,10.9-3.5,21.7-5.9,31.3
+ c-0.6,2.4-1.2,5-1.7,7.2c0,0,1.3,0,1.9,0C985.2,567.8,985.8,565.6,986.3,563.2z"/>
+ <path class="st1" d="M972.6,561.8c2.3-9.1,4.9-19.5,5.7-29.8c2.7-35.5-0.5-73.4-8.1-98.7c-1.8-6.2-3.8-12-6-17.5
+ c-12.5-32-32.6-61.9-59.5-88.8c-42.2-42.3-101.7-78.1-176.9-106.5c-68.9-26-149.4-45.2-239.5-57.1
+ C325.3,142.1,142.8,147.7,0,178.3v1.9c142.6-30.6,325.2-36.2,487.9-14.9c89.9,11.8,170.4,31,239.1,56.9
+ c74.9,28.3,134.3,64,176.3,106.1c26.8,26.8,46.7,56.4,59.1,88.2c2.2,5.4,4.2,11.2,6,17.3c7.6,25.2,10.7,62.7,8.1,98.1
+ c-0.8,10.1-3.3,20.4-5.6,29.5c-0.7,3-1.4,6-2.1,8.6c0,0,1.2,0,1.9,0C971.2,567.4,971.8,564.7,972.6,561.8z"/>
+ <path class="st1" d="M958,569.2c2.5-10.5,5.7-23.7,6.7-36.6c2.7-35-0.4-72.3-7.9-97.3c-1.8-5.7-3.7-11.3-6-17.3
+ c-12.5-31.6-32.2-61.1-58.7-87.6c-41.8-41.8-100.5-77.1-174.5-105c-68.1-25.7-147.6-44.6-236.1-56.3
+ C320.6,148,140.7,153.5,0,183.7v1.9c140.6-30.2,320.5-35.7,481.1-14.7c88.4,11.6,167.7,30.5,235.7,56.1
+ c73.8,27.8,132.3,63,173.9,104.6c26.3,26.3,45.9,55.6,58.3,87c2.3,5.9,4.2,11.5,6,17.1c7.5,24.8,10.5,61.8,7.9,96.7
+ c-1,12.8-4.1,25.9-6.6,36.3l-0.3,1.2c0.6,0,1.3,0,1.9,0L958,569.2z"/>
+ <path class="st1" d="M950.6,533.2c2.6-34.8-0.4-71.6-7.8-96c-2-6.5-3.9-11.9-5.9-17.1c-12.1-31-31.5-60-57.8-86.4
+ c-41.2-41.2-99.1-76-172-103.5c-67.4-25.4-145.7-44.1-232.7-55.5C315.9,153.9,138.6,159.4,0,189.2v1.8
+ c138.5-29.8,315.8-35.2,474.1-14.5c86.8,11.4,165,30,232.3,55.3c72.7,27.4,130.4,62.1,171.4,103.1c26.1,26.2,45.4,55.1,57.4,85.8
+ c2,5.1,3.9,10.5,5.9,16.9c7.3,24.2,10.3,60.8,7.8,95.4c-1.1,12.5-3.1,25-6,37c0,0,1.2,0,1.8,0C947.5,558,949.5,545.7,950.6,533.2z
+ "/>
+ <path class="st1" d="M930.8,565c2.4-9.6,5.1-20.5,5.9-31.4c2.6-34.2-0.4-70.5-7.7-94.6c-1.9-6.2-3.8-11.6-5.8-16.8
+ c-12.1-30.8-31.3-59.5-57-85.2c-40.6-40.6-97.6-74.9-169.5-102c-66.3-25.1-143.4-43.5-229.3-54.7c-156.1-20.4-330.8-15-467.3,14.3
+ v1.8c136.4-29.3,311.1-34.6,467.1-14.3c85.7,11.2,162.8,29.5,228.9,54.5c71.7,27,128.5,61.2,168.9,101.6
+ c25.6,25.6,44.6,54,56.6,84.6c2,5.1,3.9,10.5,5.8,16.6c7.3,24,10.2,60,7.7,94c-0.8,10.7-3.5,21.6-5.9,31.1
+ c-0.5,1.8-0.9,3.8-1.3,5.5c0,0,1.2,0,1.8,0C929.9,568.4,930.3,566.7,930.8,565z"/>
+ <path class="st1" d="M923,534.2c2.5-33.5-0.3-69.2-7.5-93.2c-1.7-5.6-3.6-11.2-5.7-16.6c-11.8-30.3-30.7-58.5-56.2-84
+ c-40-40-96.2-73.8-167-100.5c-65.2-24.6-141.3-42.7-226-53.9C306.6,166,134.5,171.2,0,200.1v1.8c134.3-28.9,306.5-34.1,460.3-14.1
+ c84.6,11.1,160.5,29.2,225.6,53.8c70.6,26.6,126.6,60.3,166.4,100.1c25.3,25.3,44.1,53.3,55.8,83.4c2.1,5.4,4,10.9,5.7,16.4
+ c7.1,23.8,10,59.3,7.5,92.6c-1,12.3-3.7,25.1-6.3,36c0,0,1.2,0,1.8,0C919.3,559.1,922,546.5,923,534.2z"/>
+ <path class="st1" d="M903.3,567.6c2.3-9.5,5.1-21.4,5.9-32.9c2.5-33-0.3-68.2-7.4-91.9c-1.5-5.3-3.4-10.6-5.6-16.3
+ c-11.7-29.7-30.3-57.6-55.4-82.7c-39.3-39.3-94.7-72.7-164.5-99c-64.1-24.3-139-42.1-222.6-53C302,171.9,132.4,177,0,205.5v1.8
+ c132.3-28.5,301.9-33.6,453.4-13.8c83.4,10.9,158.2,28.7,222.2,53c69.6,26.3,124.7,59.4,163.9,98.6c24.9,25,43.4,52.6,55,82.1
+ c2.2,5.6,4.1,10.9,5.6,16.1c7.1,23.5,9.9,58.5,7.4,91.3c-0.8,11.4-3.6,23.2-5.9,32.6l-0.7,2.8c0.6,0,1.2,0,1.8,0L903.3,567.6z"/>
+ <path class="st1" d="M889.8,565c2.2-9.2,4.6-19.5,5.4-29.9c2.4-32.7-0.4-67.3-7.3-90.5c-1.7-5.7-3.5-10.9-5.5-16.1
+ c-11.5-29.4-29.8-56.8-54.5-81.5c-38.8-38.8-93.3-71.6-162-97.5c-63-23.8-136.7-41.4-219.2-52.2c-149-19.5-316-14.5-446.6,13.6
+ v1.7c130.5-28,297.5-33.1,446.4-13.6c82.3,10.8,155.9,28.4,218.8,52.2c68.5,25.8,122.8,58.5,161.4,97.1
+ c24.5,24.5,42.7,51.7,54.1,80.9c2,5.1,3.8,10.3,5.5,15.9c6.9,23,9.7,57.4,7.3,89.9c-0.7,10.2-3.2,20.5-5.4,29.6
+ c-0.4,1.8-0.8,3.7-1.2,5.4c0,0,1.1,0,1.7,0C889,568.4,889.4,566.7,889.8,565z"/>
+ <path class="st1" d="M882.5,536.2c2.5-32-0.2-66.1-7.1-89.2c-1.5-5.2-3.3-10.4-5.4-15.9c-11.4-29-29.5-56.1-53.7-80.3
+ c-38-38.1-91.7-70.4-159.5-96c-62.3-23.5-134.9-40.8-215.8-51.4C293.7,184,128.8,189,0,216.7v1.7
+ c128.7-27.7,293.5-32.7,440.7-13.4c80.8,10.6,153.3,27.9,215.4,51.4c67.6,25.5,121.1,57.7,158.9,95.6c24.1,24.1,42,50.9,53.3,79.7
+ c2.1,5.5,3.9,10.6,5.4,15.7c6.9,22.9,9.6,56.8,7.1,88.6c-0.8,11.7-2.5,23.2-4.9,34c0,0,1.1,0,1.7,0
+ C880,559.2,881.6,547.8,882.5,536.2z"/>
+ <path class="st1" d="M868.6,536.7c2.4-31.7-0.3-65.3-7-87.8c-1.4-5-3.2-10.2-5.3-15.6c-11.2-28.5-28.9-55.1-52.9-79.1
+ c-37.6-37.6-90.4-69.4-157-94.5c-61.2-23.2-132.6-40.2-212.4-50.6c-144.7-19-307-14.1-434,13.2v1.7
+ c126.9-27.2,289.2-32.2,433.8-13.2c79.6,10.4,151,27.4,212,50.6c66.3,25,119,56.7,156.4,94.1c23.8,23.8,41.4,50.2,52.5,78.5
+ c2.1,5.3,3.8,10.5,5.3,15.4c6.7,22.3,9.4,55.7,7,87.2c-0.8,11.4-2.4,22.7-4.8,33.5c0,0,1.1,0,1.7,0
+ C866.2,559.2,867.8,548,868.6,536.7z"/>
+ <path class="st1" d="M853.5,536.8c2.4-31.6-0.2-63.9-6.9-86.5c-1.4-5-3.2-10-5.3-15.4c-10.9-28-28.4-54.2-52-77.9
+ c-37-37-89-68.3-154.5-93c-60.6-22.8-130.9-39.6-209-49.7c-142-18.6-301.2-13.8-425.8,13v1.7c124.4-26.7,283.7-31.6,425.6-13
+ c78,10.2,148.2,26.9,208.6,49.7c65.3,24.6,117.1,55.8,153.9,92.6c23.4,23.5,40.8,49.6,51.6,77.3c2.1,5.3,3.9,10.3,5.3,15.2
+ c6.5,22.1,9.2,55,6.9,85.9c-0.8,11.3-2.4,22.6-4.8,33.4c0,0,1.1,0,1.7,0C851.1,559.3,852.7,548.1,853.5,536.8z"/>
+ <path class="st1" d="M839.8,537.2c2.4-30.4-0.2-63-6.7-85.1c-1.3-4.3-2.9-9-5.2-15.1c-10.7-27.6-27.9-53.4-51.2-76.7
+ c-36.2-36.3-87.4-67.1-152-91.5C565.2,246.5,496,230,419,219.9c-139.8-18.3-296.5-13.6-419,12.8v1.6
+ c122.4-26.3,279.1-31.1,418.8-12.8c76.8,10.1,145.9,26.5,205.3,48.9c64.4,24.3,115.4,55,151.4,91.1c23.1,23.1,40.2,48.7,50.8,76.1
+ c2.3,6,3.9,10.6,5.2,14.9c6.5,21.9,9.1,54.3,6.7,84.5c-0.8,11.7-2.4,22.9-4.7,33h0c0.6,0,1.1,0,1.7,0
+ C837.4,559.9,839,548.9,839.8,537.2z"/>
+ <path class="st1" d="M825.8,538.1c2.4-30.5-0.1-61.9-6.6-83.8c-1.5-4.9-3.2-9.8-5.1-14.9c-10.7-27.3-27.7-52.7-50.4-75.4
+ c-35.7-35.8-86-66-149.5-90c-58.4-22-126.5-38.2-202.3-48.1C274.4,208,120.4,212.7,0,238.5v1.6c120.3-25.8,274.3-30.5,411.7-12.6
+ c75.7,9.9,143.6,26.1,201.9,48.1c63.3,23.9,113.4,54,148.9,89.6c22.6,22.6,39.4,47.7,50,74.8c2,5,3.6,9.9,5.1,14.7
+ c6.5,21.7,8.9,52.9,6.6,83.2c-0.8,11.2-2.4,22.1-4.6,32.1h0c0.5,0,1.1,0,1.6,0C823.4,560,825,549.3,825.8,538.1z"/>
+ <path class="st1" d="M812.1,538.8c2.3-29.5-0.2-61.1-6.5-82.4c-1.7-5.6-3.3-10.2-5-14.7c-10.4-26.6-27.1-51.6-49.6-74.2
+ c-35-35.1-84.5-64.9-147-88.5c-57.3-21.7-124.2-37.6-198.9-47.4C269.8,214,118.4,218.6,0,244v1.6c118.2-25.4,269.7-30,404.9-12.4
+ c74.6,9.8,141.3,25.7,198.5,47.4c62.3,23.5,111.5,53.1,146.4,88.1c22.3,22.4,38.9,47.2,49.2,73.6c1.7,4.4,3.3,9,5,14.5
+ c6.3,21.1,8.8,52.5,6.5,81.8c-0.7,10.2-2.2,20.9-4.6,31.4h0c0.5,0,1.1,0,1.6,0C809.9,559.5,811.4,549,812.1,538.8z"/>
+ <path class="st1" d="M798.1,539.2c2.3-29.1-0.1-60.1-6.3-81c-1.2-4.3-2.9-9-4.9-14.4c-10.3-26.4-26.7-50.9-48.7-73
+ c-34.4-34.5-83-63.8-144.5-87c-56.3-21.3-122-36.9-195.5-46.5c-133-17.4-281.8-12.9-398.2,12.1v1.5c116.2-25,265.1-29.5,398-12.1
+ c73.3,9.6,139,25.2,195.1,46.5c61.2,23.1,109.7,52.3,143.9,86.6c21.9,21.9,38.1,46.2,48.3,72.4c2,5.3,3.6,10,4.9,14.2
+ c6.2,20.7,8.6,51.5,6.3,80.4c-0.7,10.5-2.2,21-4.5,31h0c0.5,0,1.1,0,1.6,0C795.9,560,797.4,549.7,798.1,539.2z"/>
+ <path class="st1" d="M784.3,539.8c2.3-29.2-0.1-59-6.2-79.7c-1.4-4.8-3-9.6-4.8-14.2c-10-25.9-26.1-50-47.9-71.8
+ c-33.9-34-81.6-62.7-142-85.5c-55.6-21-120.2-36.4-192.1-45.7C260.8,225.8,114.6,230.3,0,254.8v1.5
+ c114.4-24.5,260.8-29,391.1-11.9c71.7,9.4,136.3,24.7,191.7,45.7c60.1,22.7,107.7,51.3,141.4,85.1c21.6,21.6,37.6,45.5,47.5,71.2
+ c1.8,4.6,3.4,9.3,4.8,14c6.1,20.5,8.4,50.1,6.2,79.1c-0.7,10.3-2.2,20.6-4.5,30.4c0,0,1,0,1.5,0
+ C782,560.2,783.5,550.1,784.3,539.8z"/>
+ <path class="st1" d="M770.3,540.3c2.3-28.6,0-57.9-6.1-78.3c-1.7-5.6-3.2-9.9-4.7-13.9c-9.9-25.4-25.7-49.1-47.1-70.6
+ c-33.2-33.3-80.2-61.6-139.5-84c-54.4-20.5-117.9-35.6-188.7-44.9C256.1,231.8,112.4,236.2,0,260.3v1.5
+ c112.3-24.1,256-28.5,384.1-11.7c70.6,9.3,134,24.4,188.3,44.9c59.1,22.3,105.8,50.4,138.9,83.6c21.2,21.3,36.9,44.9,46.7,70
+ c1.5,4,3,8.2,4.7,13.7c6,20.2,8.3,49.3,6.1,77.7c-0.7,9.9-2.2,20.1-4.4,29.9c0,0,1,0,1.5,0C768.2,560.2,769.7,550.2,770.3,540.3z"
+ />
+ <path class="st1" d="M756.7,541c2.2-28,0-56.8-5.9-77c-1.2-4.1-2.7-8.4-4.7-13.7c-9.8-25.1-25.3-48.4-46.3-69.4
+ c-32.8-32.8-78.9-60.6-137-82.5c-53.4-20.2-115.8-35-185.4-44.1C251.8,237.9,110.6,242.2,0,265.9v1.5
+ c110.5-23.7,251.7-28,377.3-11.5c69.5,9.1,131.7,23.9,185,44.1c57.8,21.8,103.7,49.4,136.4,82.1c20.8,20.8,36.2,43.9,45.9,68.8
+ c2,5.2,3.5,9.5,4.7,13.5c5.8,19.7,8.1,49,5.9,76.4c-0.7,9.3-2.1,19.2-4.3,29.2c0,0,1,0,1.5,0C754.6,560,756.1,550.2,756.7,541z"/>
+ <path class="st1" d="M742.6,541.4c2.2-27.4,0.1-55.6-5.8-75.6c-1.6-5.2-3.1-9.5-4.6-13.4c-9.4-24.5-24.7-47.4-45.4-68
+ c-32.1-32.2-77.3-59.4-134.5-81c-52.7-19.9-113.9-34.4-182-43.3C246.7,243.7,108.3,248,0,271.3v1.4
+ c108.1-23.3,246.6-27.5,370.2-11.3c68,8.9,129.1,23.4,181.6,43.3c57,21.5,102,48.6,133.9,80.6c20.5,20.5,35.7,43.2,45,67.5
+ c1.5,3.9,3,8.1,4.6,13.3c5.7,19.5,8,48.3,5.8,75c-0.7,9.7-2.1,19.5-4.2,28.8c0,0,1,0,1.4,0C740.5,560.7,741.9,551.1,742.6,541.4z"
+ />
+ <path class="st1" d="M728.8,541.8c2.1-27,0-54.8-5.7-74.2c-1.3-4.5-2.9-9-4.5-13.2c-9.4-24.1-24.3-46.6-44.6-66.8
+ c-31.6-31.6-76-58.3-132-79.5c-51.6-19.4-111.6-33.7-178.6-42.5c-121.1-15.9-257-11.8-363.5,11.1v1.4
+ c106.3-22.8,242.3-27,363.3-11.1c66.8,8.8,126.8,23.1,178.2,42.5c55.8,21,100,47.7,131.4,79.1c20.1,20.1,35,42.4,44.2,66.4
+ c1.6,4.2,3.2,8.5,4.5,13c5.7,19.2,7.8,46.8,5.7,73.6c-0.7,9.3-2.1,19-4.1,28.4c0,0,0.9,0,1.4,0
+ C726.7,560.6,728.1,551.1,728.8,541.8z"/>
+ <path class="st1" d="M714.9,542.4c2.2-26.3,0.2-53.6-5.5-72.9c-1.2-4.1-2.6-8.3-4.4-13c-9-23.5-23.8-45.6-43.8-65.6
+ c-30.9-31-74.4-57.2-129.4-78c-50.4-19.1-109.3-33.1-175.2-41.7C237.3,255.6,104,259.7,0,282.1v1.4
+ c103.9-22.4,237.3-26.4,356.4-10.9c65.7,8.6,124.5,22.6,174.8,41.7c54.8,20.7,98.2,46.8,129,77.6c19.8,19.9,34.5,41.9,43.4,65.2
+ c1.8,4.6,3.2,8.8,4.4,12.8c5.6,19.1,7.7,46.2,5.5,72.4c-0.7,9.8-2.1,19.2-4.1,27.8c0,0,0.9,0,1.4,0
+ C712.8,561.4,714.2,552.1,714.9,542.4z"/>
+ <path class="st1" d="M701.2,542.9c2.1-26,0.1-52.7-5.4-71.5c-1.5-4.7-2.9-8.9-4.3-12.6c-9-23.3-23.5-45-43-64.4
+ c-30.3-30.4-73-56.1-126.9-76.5c-49.7-18.8-107.5-32.5-171.8-40.9C233,261.7,102.3,265.7,0,287.6v1.4
+ C102.2,267,232.9,263,349.6,278.3c64.2,8.4,121.9,22.1,171.4,40.9c53.8,20.3,96.3,45.9,126.5,76.1c19.3,19.3,33.7,40.9,42.6,64
+ c1.4,3.7,2.8,7.9,4.3,12.5c5.5,18.6,7.5,45.1,5.4,71c-0.7,9.5-2.1,18.7-4,27.3c0,0,0.9,0,1.4,0
+ C699.1,561.4,700.5,552.3,701.2,542.9z"/>
+ <path class="st1" d="M687.1,543.5c2.1-25.7,0.2-51.9-5.2-70.1c-1.2-4.1-2.6-8.2-4.2-12.5c-8.7-22.8-22.9-44.1-42.1-63.2
+ c-29.9-29.9-71.7-55.1-124.4-75c-48.2-18.3-104.8-31.8-168.4-40.1C228.3,267.6,100.2,271.5,0,293v1.3
+ c100-21.5,228.2-25.4,342.6-10.5c63.4,8.3,120,21.8,168.1,40.1c52.5,19.8,94.2,44.9,124,74.6c19,19,33.1,40.2,41.7,62.8
+ c1.6,4.2,3,8.2,4.2,12.3c5.4,18.1,7.3,44.1,5.2,69.7c-0.7,9.2-2.1,18.3-4,26.7c0,0,0.9,0,1.3,0C685,561.6,686.3,552.7,687.1,543.5
+ z"/>
+ <path class="st1" d="M673.4,544c2.1-24.7,0.2-50.4-5.1-68.7c-1.1-3.8-2.5-8-4.1-12.3c-8.6-22.3-22.5-43.2-41.2-62
+ c-29.1-29.2-70.1-53.9-121.9-73.5c-47.5-18-103.1-31.2-165-39.3C223.6,273.5,98.1,277.3,0,298.4v1.3
+ c98-21.1,223.6-24.9,335.7-10.3c61.9,8.1,117.3,21.3,164.8,39.3c51.6,19.5,92.5,44.1,121.5,73.1c18.6,18.7,32.4,39.5,41,61.6
+ c1.6,4.3,3,8.3,4.1,12.2c5.3,18.1,7.2,43.7,5.1,68.3c-0.7,8.8-2,17.7-3.9,26.2c0,0,0.9,0,1.3,0C671.3,561.5,672.6,552.7,673.4,544
+ z"/>
+ <path class="st1" d="M659.4,544.5c2-24.5,0.1-49.7-5-67.4c-1.4-4.7-2.7-8.5-4-11.9c-8.4-21.9-22-42.3-40.4-60.7
+ c-28.3-28.5-68.5-52.7-119.4-71.9c-46.8-17.7-101.2-30.6-161.6-38.5C219.3,279.5,96.4,283.2,0,303.8v1.3
+ c96.3-20.7,219.3-24.4,328.7-10c60.4,7.9,114.6,20.8,161.4,38.5c50.8,19.2,90.8,43.3,119,71.7c18.3,18.3,31.8,38.6,40.2,60.3
+ c1.3,3.5,2.6,7.2,4,11.8c5.1,17.6,7,42.6,5,67c-0.7,9.1-2,17.8-3.8,25.7c0,0,0.8,0,1.3,0C657.4,562.1,658.7,553.5,659.4,544.5z"/>
+ <path class="st1" d="M645.6,545c2.1-23.4,0.2-48.7-4.8-66c-1.2-4-2.5-7.8-4-11.7c-8.4-21.5-21.7-41.6-39.6-59.5
+ c-28.1-28.1-67.4-51.8-116.9-70.4c-45.6-17.2-98.8-29.8-158.2-37.6c-107.4-14.1-227.7-10.5-322,9.8v1.3
+ c94.2-20.2,214.5-23.9,321.8-9.8c59.3,7.8,112.5,20.4,158,37.6c49.3,18.6,88.5,42.2,116.5,70.2c17.8,17.8,31.1,37.7,39.4,59.1
+ c1.5,3.9,2.8,7.7,4,11.7c5,17.2,6.9,42.3,4.8,65.6c-0.7,9-2,17.5-3.8,25.2c0,0,0.8,0,1.3,0C643.6,562.4,644.9,553.9,645.6,545z"/>
+ <path class="st1" d="M631.7,545.6c2-23.4,0.3-47.6-4.7-64.6c-1.2-3.9-2.4-7.7-3.9-11.5c-8-21-21-40.6-38.7-58.3
+ c-27.1-27.3-65.6-50.5-114.4-68.9c-44.7-16.9-96.8-29.3-154.8-36.9C210,291.4,92.2,294.9,0,314.8v1.2
+ c92.1-19.8,209.9-23.4,314.9-9.6c57.9,7.6,109.9,20,154.6,36.9c48.6,18.3,87,41.4,114,68.7c17.6,17.6,30.5,37.1,38.5,57.9
+ c1.5,3.8,2.7,7.6,3.9,11.4c4.9,16.9,6.7,40.9,4.7,64.2c-0.7,8.6-2,16.9-3.7,24.6c0,0,0.8,0,1.2,0
+ C629.7,562.4,631,554.1,631.7,545.6z"/>
+ <path class="st1" d="M617.9,546c1.9-23.2,0.2-46.9-4.6-63.3c-1-3.4-2.2-6.9-3.8-11.2c-7.9-20.5-20.7-39.7-37.9-57.1
+ c-26.8-26.8-64.4-49.5-111.9-67.3c-44-16.6-94.9-28.8-151.4-36.1c-102.6-13.5-217.8-10-308.2,9.4v1.2
+ c90.3-19.4,205.5-22.9,308-9.4c56.4,7.4,107.3,19.5,151.2,36.1c47.3,17.8,84.8,40.4,111.5,67.1c17.1,17.2,29.8,36.3,37.7,56.7
+ c1.6,4.3,2.8,7.8,3.8,11.2c4.8,16.3,6.5,39.8,4.6,62.9c-0.6,8.2-1.8,16.4-3.6,24.1c0,0,0.8,0,1.2,0
+ C616.1,562.2,617.3,554.1,617.9,546z"/>
+ <path class="st1" d="M604,546.4c2-22.4,0.4-45.6-4.4-61.9c-1.3-4.3-2.5-7.8-3.7-11c-7.7-20.2-20.2-39-37.1-55.9
+ c-26.2-26.2-63-48.3-109.4-65.8c-42.6-16.1-92.4-27.9-148-35.2C200.6,303.3,88,306.7,0,325.6v1.2c87.9-18.9,200.5-22.3,301.1-9.2
+ c55.5,7.3,105.3,19.1,147.8,35.2c46.3,17.4,82.9,39.5,109,65.6c16.8,16.8,29.2,35.5,36.9,55.5c1.2,3.2,2.4,6.7,3.7,11
+ c4.7,16.2,6.4,39.2,4.4,61.5c-0.6,8.1-1.8,16.2-3.6,23.7c0,0,0.8,0,1.2,0C602.2,562.4,603.4,554.5,604,546.4z"/>
+ <path class="st1" d="M590.3,546.8c1.9-21.9,0.3-44.6-4.3-60.6c-1.2-3.8-2.4-7.4-3.6-10.7c-7.7-19.8-19.9-38.2-36.3-54.7
+ c-25.6-25.6-61.5-47.2-106.9-64.3c-42-15.8-90.7-27.3-144.7-34.4c-98.5-12.9-208.5-9.5-294.4,9v1.2c85.7-18.5,195.8-21.8,294.2-9
+ c54,7.1,102.6,18.6,144.5,34.4c45.2,17.1,81.1,38.6,106.5,64.1c16.3,16.3,28.5,34.6,36.1,54.3c1.2,3.3,2.4,6.9,3.6,10.7
+ c4.6,15.9,6.2,38.4,4.3,60.2c-0.6,7.7-1.8,15.8-3.5,23.2c0,0,0.8,0,1.2,0C588.5,562.6,589.7,554.6,590.3,546.8z"/>
+ <path class="st1" d="M576.3,547.4c1.8-21.6,0.2-43.7-4.2-59.2c-0.9-3.3-2.1-6.8-3.5-10.5c-7.3-19.2-19.2-37.2-35.4-53.4
+ c-25-25-60.1-46.1-104.4-62.8c-40.8-15.4-88.4-26.7-141.3-33.6c-95.9-12.5-203.3-9.2-287.4,8.8v1.1c84-18,191.4-21.3,287.2-8.8
+ c52.9,6.9,100.3,18.2,141.1,33.6c44.1,16.6,79.1,37.7,104,62.6c16.1,16.1,27.9,33.9,35.2,53c1.4,3.7,2.6,7.2,3.5,10.5
+ c4.4,15.3,6,37.3,4.2,58.8c-0.6,7.2-1.7,14.9-3.4,22.6c0,0,0.7,0,1.1,0C574.5,562.3,575.7,554.7,576.3,547.4z"/>
+ <path class="st1" d="M562.3,548c1.9-20.9,0.4-42.5-4-57.9c-1.1-3.8-2.2-7.2-3.4-10.2c-7.2-18.9-18.9-36.5-34.6-52.2
+ c-24.4-24.4-58.7-45-101.9-61.3c-39.7-15-86.1-26-137.9-32.8C186.8,321.2,82,324.4,0,342v1.1c81.9-17.6,186.8-20.8,280.3-8.6
+ c51.7,6.8,98,17.8,137.7,32.8c43.1,16.3,77.2,36.8,101.5,61.1c15.6,15.6,27.2,33.1,34.4,51.8c1.2,3,2.3,6.4,3.4,10.2
+ c4.4,15.3,5.9,36.8,4,57.5c-0.6,7.8-1.8,15.3-3.4,22.1c0,0,0.7,0,1.1,0C560.6,563.1,561.7,555.7,562.3,548z"/>
+ <path class="st1" d="M548.5,548.4c1.8-20.6,0.3-41.7-3.9-56.5c-0.9-3.3-2-6.6-3.3-10.1c-7-18.4-18.4-35.5-33.8-51
+ c-23.8-23.8-57.2-43.9-99.4-59.8c-39-14.7-84.2-25.4-134.5-32c-91.4-11.9-193.7-8.8-273.6,8.4v1.1c79.8-17.2,182.1-20.3,273.4-8.4
+ c50.2,6.6,95.4,17.3,134.3,32c42,15.9,75.4,35.9,99,59.6c15.3,15.3,26.6,32.4,33.6,50.6c1.3,3.4,2.4,6.6,3.3,10
+ c4.2,14.7,5.7,35.6,3.9,56.1c-0.6,7.3-1.7,14.9-3.3,21.6c0,0,0.7,0,1.1,0C546.8,563.2,547.9,555.8,548.5,548.4z"/>
+ <path class="st1" d="M534.7,549.2c1.8-20.1,0.4-40.7-3.8-55.2c-1.1-3.5-2.2-6.8-3.3-9.8c-6.9-18-17.9-34.8-32.9-49.8
+ c-23.2-23.2-55.8-42.8-96.9-58.3c-37.8-14.3-82-24.8-131.1-31.2C177.9,333.2,78.2,336.2,0,353v1c78.1-16.8,177.8-19.8,266.5-8.1
+ c49.1,6.4,93.1,16.9,130.9,31.2c40.9,15.4,73.4,35,96.5,58.1c14.9,14.9,25.9,31.5,32.7,49.4c1.1,3,2.2,6.3,3.3,9.8
+ c4.2,14.4,5.6,34.9,3.8,54.8c-0.6,7-1.7,14.1-3.3,20.8c0,0,0.7,0,1,0C533,563.3,534.1,556.3,534.7,549.2z"/>
+ <path class="st1" d="M520.8,549.5c1.8-19.6,0.5-39.7-3.6-53.8c-1-3.2-2-6.4-3.2-9.5c-6.6-17.4-17.4-33.7-32.1-48.6
+ c-22.6-22.6-54.3-41.7-94.4-56.8c-36.7-13.9-79.7-24.1-127.7-30.4C173.2,339,76.1,341.9,0,358.3v1c76-16.4,173.1-19.3,259.6-7.9
+ c47.9,6.3,90.8,16.5,127.5,30.4c39.9,15.1,71.5,34.1,94,56.6c14.7,14.8,25.4,31,31.9,48.2c1.2,3.1,2.2,6.3,3.2,9.5
+ c4,14,5.4,34,3.6,53.4c-0.6,6.7-1.7,13.9-3.2,20.5c0,0,0.7,0,1,0C519.1,563.3,520.2,556.2,520.8,549.5z"/>
+ <path class="st1" d="M507,550c1.7-19,0.4-38.6-3.5-52.4c-0.9-3.1-2-6.3-3.1-9.3c-6.4-17-16.9-32.9-31.3-47.3
+ c-22-22-52.9-40.6-91.9-55.3c-36-13.6-77.8-23.5-124.3-29.6C168.2,345,73.7,347.9,0,363.8v1c73.6-15.9,168.2-18.8,252.7-7.7
+ c46.4,6.1,88.2,16,124.1,29.6c38.8,14.6,69.6,33.2,91.5,55.1c14.3,14.3,24.7,30.1,31.1,46.9c1.1,3,2.2,6.2,3.1,9.3
+ c3.9,13.7,5.2,33.2,3.5,52c-0.6,6.6-1.7,13.4-3.2,20c0,0,0.7,0,1,0C505.3,563.4,506.4,556.7,507,550z"/>
+ <path class="st1" d="M493.2,550.5c1.7-18.4,0.4-37.5-3.4-51.1c-0.9-3-1.9-6-3-9c-6.4-16.7-16.6-32.2-30.5-46.1
+ c-21.4-21.4-51.5-39.5-89.4-53.8c-35-13.2-75.7-22.9-121-28.8c-82-10.7-173.9-7.9-245.9,7.5v1c71.8-15.4,163.8-18.2,245.7-7.5
+ c45.3,5.9,85.9,15.6,120.8,28.8c37.8,14.2,67.7,32.3,89,53.6c13.8,13.8,24,29.2,30.3,45.7c1.1,3,2.1,6,3,9
+ c3.8,13.5,5.1,32.5,3.4,50.7c-0.6,6.8-1.7,13.5-3.1,19.5c0,0,0.6,0,1,0C491.5,563.9,492.5,557.4,493.2,550.5z"/>
+ <path class="st1" d="M479.3,551c1.7-18,0.5-36.6-3.2-49.7c-0.7-2.7-1.6-5.5-2.9-8.8c-6.3-16.4-16.3-31.5-29.7-44.9
+ c-20.8-20.8-50.1-38.4-86.9-52.3c-33.8-12.8-73.4-22.2-117.6-28c-79.8-10.4-169.1-7.7-239,7.3v0.9c69.8-15,159.1-17.7,238.8-7.3
+ c44.1,5.8,83.6,15.2,117.4,28c36.7,13.8,65.8,31.3,86.5,52.1c13.3,13.3,23.2,28.3,29.5,44.5c1.3,3.3,2.2,6.1,2.9,8.8
+ c3.7,13,4.9,31.5,3.2,49.3c-0.6,6.5-1.6,13-3,19c0,0,0.6,0,0.9,0C477.6,564,478.6,557.6,479.3,551z"/>
+ <path class="st1" d="M465.4,551.7c1.7-17.4,0.5-35.5-3.1-48.4c-0.9-3.4-1.8-6.1-2.8-8.6c-5.9-15.8-15.6-30.5-28.8-43.7
+ c-20.2-20.2-48.6-37.3-84.4-50.8c-33.1-12.5-71.5-21.6-114.2-27.2c-77.6-10.2-164.3-7.5-232.1,7.1v0.9
+ c67.7-14.6,154.5-17.2,231.9-7.1c42.6,5.6,80.9,14.7,114,27.2c35.6,13.4,63.9,30.5,84,50.6c13.1,13.1,22.7,27.7,28.6,43.3
+ c1,2.5,1.9,5.2,2.8,8.6c3.6,12.7,4.8,30.7,3.1,48c-0.6,6-1.6,12.2-2.9,18.3c0,0,0.6,0,0.9,0C463.8,563.8,464.8,557.7,465.4,551.7z
+ "/>
+ <path class="st1" d="M451.5,552.1c1.7-17,0.6-34.5-2.9-47c-0.8-2.9-1.8-5.7-2.7-8.3c-5.9-15.4-15.3-29.7-28-42.5
+ c-19.7-19.6-47.3-36.2-82-49.3c-32-12.1-69.3-21-110.8-26.4c-75.1-9.8-159.3-7.3-225.2,6.9v0.9c65.8-14.1,150-16.7,225-6.9
+ c41.5,5.4,78.7,14.3,110.6,26.4c34.5,13,62,29.6,81.6,49.1c12.6,12.6,22,26.8,27.8,42.1c0.9,2.6,1.9,5.3,2.7,8.3
+ c3.5,12.3,4.6,29.8,2.9,46.6c-0.7,6.7-1.6,12.7-2.9,17.9c0,0,0.6,0,0.9,0C449.9,564.7,450.9,558.9,451.5,552.1z"/>
+ <path class="st1" d="M437.7,552.6c1.7-16.4,0.6-33.4-2.8-45.6c-0.6-2.4-1.5-5.1-2.6-8.1c-5.6-14.8-14.7-28.7-27.2-41.3
+ c-19.1-19.1-45.8-35.1-79.4-47.8c-30.9-11.7-67.1-20.3-107.5-25.6c-72.8-9.5-154.4-7-218.3,6.7v0.9
+ c63.7-13.7,145.4-16.2,218.1-6.7c40.3,5.3,76.4,13.9,107.3,25.6c33.5,12.6,60.1,28.6,79,47.6c12.4,12.5,21.5,26.3,27,40.9
+ c1.1,3,2,5.6,2.6,8.1c3.4,12.1,4.5,29,2.8,45.2c-0.6,6.2-1.6,12.1-2.8,17.4c0,0,0.6,0,0.9,0C436.1,564.7,437.1,558.8,437.7,552.6z
+ "/>
+ <path class="st1" d="M423.9,553.1c1.6-16,0.6-32.6-2.7-44.3c-0.7-2.5-1.6-5.2-2.6-7.9c-5.3-14.2-14.1-27.7-26.3-40
+ c-18.6-18.5-44.4-34-76.9-46.3c-30-11.4-65-19.7-104.1-24.8c-70.6-9.2-149.5-6.8-211.3,6.5v0.8c61.6-13.3,140.6-15.7,211.1-6.5
+ c39,5.1,74,13.4,103.9,24.8c32.4,12.2,58.2,27.7,76.7,46.1c12.1,12.2,20.9,25.5,26.1,39.7c1,2.7,1.8,5.4,2.6,7.9
+ c3.3,11.6,4.3,28,2.7,43.9c-0.7,6.1-1.6,11.9-2.8,16.9c0,0,0.5,0,0.8,0C422.3,564.9,423.3,559.3,423.9,553.1z"/>
+ <path class="st1" d="M409.9,553.6c1.6-15.4,0.6-31.4-2.5-42.8c-0.7-2.6-1.6-5.2-2.5-7.6c-5.3-14-13.8-27.1-25.5-38.7
+ c-17.8-17.8-42.8-32.9-74.3-44.8c-29.3-11.1-63.1-19.1-100.7-24C136.4,386.7,60,389,0,401.9v0.8c59.9-12.9,136.3-15.2,204.2-6.3
+ c37.5,4.9,71.3,12.9,100.5,24c31.4,11.9,56.3,26.9,74.1,44.6c11.6,11.6,20.1,24.5,25.3,38.5c0.9,2.5,1.8,5,2.5,7.6
+ c3.1,11.4,4.1,27.3,2.5,42.6c-0.5,5.6-1.5,11.4-2.7,16.4c0,0,0.5,0,0.8,0C408.4,564.9,409.4,559.3,409.9,553.6z"/>
+ <path class="st1" d="M396.1,554.2c1.6-14.9,0.7-30.4-2.4-41.5c-0.7-2.6-1.5-5.1-2.4-7.4c-5-13.5-13.3-26.2-24.7-37.5
+ c-17.2-17.2-41.4-31.8-71.9-43.3c-28.2-10.6-61-18.3-97.3-23.1c-66-8.6-139.8-6.4-197.4,6v0.8c57.5-12.4,131.3-14.6,197.2-6
+ c36.3,4.8,68.9,12.5,97.1,23.1c30.4,11.5,54.5,26,71.7,43.1c11.3,11.3,19.5,23.8,24.5,37.3c0.9,2.3,1.7,4.8,2.4,7.4
+ c3.1,11.1,3.9,26.5,2.4,41.3c-0.5,5.2-1.4,10.7-2.6,15.8c0,0,0.5,0,0.8,0C394.7,564.9,395.6,559.4,396.1,554.2z"/>
+ <path class="st1" d="M382.3,554.6c1.5-14.7,0.7-29.3-2.3-40.1c-0.8-2.6-1.5-4.8-2.3-7.1c-5.2-13.7-13.5-26.2-23.9-36.3
+ c-16.5-16.5-39.9-30.6-69.3-41.8c-27.1-10.2-58.7-17.7-93.9-22.3C127,398.6,55.8,400.8,0,412.7v0.8c55.7-11.9,127-14.1,190.5-5.8
+ c35.1,4.6,66.6,12.1,93.7,22.3c29.4,11.1,52.6,25.2,69.1,41.6c10.3,10,18.5,22.5,23.7,36.1c0.9,2.3,1.5,4.5,2.3,7.1
+ c2.9,10.6,3.8,25.5,2.3,39.9c-0.5,4.8-1.3,10.1-2.5,15.4c0,0,0.5,0,0.8,0C380.9,564.7,381.8,559.4,382.3,554.6z"/>
+ <path class="st1" d="M368.3,555.2c1.6-14.1,0.8-28.2-2.1-38.8c-0.6-2.3-1.4-4.6-2.2-6.9c-4.6-12.6-12.3-24.5-23-35.1
+ c-16-16-38.6-29.6-66.9-40.3c-26.4-9.9-56.8-17.2-90.5-21.5c-62.1-8.1-129-6.1-183.5,5.6v0.7c54.4-11.7,121.4-13.7,183.5-5.6
+ c33.6,4.4,64,11.6,90.3,21.5c28.3,10.7,50.7,24.1,66.7,40.1c10.6,10.6,18.3,22.3,22.8,34.9c0.8,2.2,1.6,4.6,2.2,6.8
+ c2.9,10.5,3.7,24.6,2.1,38.6c-0.5,5.4-1.4,10.4-2.5,14.8c0,0,0.5,0,0.7,0C366.9,565.6,367.7,560.6,368.3,555.2z"/>
+ <path class="st1" d="M354.6,555.9c1.5-13.7,0.8-27.3-2-37.4c-0.6-2.2-1.3-4.4-2.1-6.7c-4.8-12.7-12.5-24.4-22.2-33.9
+ c-15.4-15.4-37.1-28.5-64.4-38.8c-25-9.4-54.3-16.4-87.2-20.7c-59.9-7.9-124.3-5.9-176.7,5.4v0.7c52.4-11.3,116.8-13.2,176.7-5.4
+ c32.8,4.3,62.1,11.3,87,20.7c27.2,10.3,48.8,23.3,64.2,38.6c9.6,9.5,17.3,21.1,22,33.7c0.8,2.3,1.5,4.5,2.1,6.7
+ c2.7,10,3.5,23.6,2,37.2c-0.5,4.8-1.3,9.7-2.4,14.1c0,0,0.5,0,0.7,0C353.3,565.5,354.1,560.8,354.6,555.9z"/>
+ <path class="st1" d="M340.6,556.3c1.5-13.1,0.8-26.3-1.9-36.1c-0.6-2.1-1.2-4.2-2-6.4c-4.6-12.2-11.8-23.2-21.4-32.6
+ c-14.8-14.8-35.6-27.3-61.9-37.3c-24.3-9.1-52.5-15.8-83.8-19.9c-57.2-7.5-119.1-5.6-169.6,5.2v0.7
+ c50.5-10.8,112.4-12.7,169.6-5.2c31.3,4.1,59.4,10.8,83.6,19.9c26.2,9.9,46.9,22.4,61.7,37.1c9.5,9.3,16.7,20.2,21.2,32.4
+ c0.8,2.2,1.4,4.3,2,6.4c2.7,9.7,3.4,22.8,1.9,35.9c-0.6,5.4-1.4,10-2.3,13.7c0,0,0.4,0,0.7,0C339.3,566.2,340,561.8,340.6,556.3z"
+ />
+ <path class="st1" d="M326.6,556.8c1.4-12.3,0.8-25.2-1.7-34.7c-0.5-1.8-1.2-3.8-2-6.2c-4.4-11.7-11.5-22.6-20.5-31.4
+ c-14.2-14.2-34.2-26.3-59.4-35.8c-23.5-8.8-50.6-15.3-80.4-19.1c-54.9-7.2-114.2-5.4-162.6,5v0.6c48.4-10.4,107.7-12.2,162.6-5
+ c29.7,3.9,56.7,10.3,80.2,19.1c25.1,9.5,45,21.4,59.2,35.6c9,8.8,16,19.5,20.3,31.2c0.8,2.4,1.5,4.4,2,6.2
+ c2.5,9.4,3.1,22.3,1.7,34.5c-0.5,4.3-1.3,8.8-2.3,13.2c0,0,0.4,0,0.6,0C325.4,565.6,326.1,561.1,326.6,556.8z"/>
+ <path class="st1" d="M312.9,557.2c1.4-12.1,0.8-24.2-1.6-33.3c-0.7-2.3-1.3-4.1-1.9-5.9c-4.2-11.4-11-21.8-19.7-30.2
+ c-13.6-13.6-32.8-25.2-56.9-34.3c-22-8.3-47.9-14.5-77-18.3C103.1,428.3,46.3,430,0,440v0.6c46.2-10,103.1-11.7,155.8-4.8
+ c29,3.8,54.9,10,76.8,18.3c24.1,9.1,43.1,20.6,56.7,34.1c8.6,8.3,15.4,18.7,19.5,30c0.6,1.8,1.2,3.6,1.9,5.9
+ c2.4,9,3,21.1,1.6,33.1c-0.6,4.9-1.3,9.3-2.2,12.8c0,0,0.4,0,0.6,0C311.6,566.4,312.3,562.1,312.9,557.2z"/>
+ <path class="st1" d="M299,557.9c1.4-11.5,0.8-23.2-1.5-32c-0.5-1.9-1.1-3.8-1.8-5.7c-4-10.8-10.5-20.8-18.9-29
+ c-13.1-13.1-31.4-24.1-54.4-32.8c-21.3-8-46-13.9-73.6-17.5C98.4,434.4,44.2,436,0,445.5v0.6c44.1-9.4,98.4-11.1,148.8-4.6
+ c27.5,3.6,52.2,9.5,73.4,17.5c23,8.6,41.2,19.6,54.2,32.6c8.3,8.1,14.8,18.1,18.7,28.8c0.7,1.9,1.3,3.8,1.8,5.7
+ c2.3,8.7,2.8,20.3,1.5,31.8c-0.5,4.4-1.3,8.5-2.2,12.1c0,0,0.4,0,0.6,0C297.7,566.4,298.5,562.3,299,557.9z"/>
+ <path class="st1" d="M285.2,558.4c1.4-11,0.9-22.2-1.3-30.6c-0.5-1.9-1.1-3.7-1.7-5.5c-3.9-10.6-10-20-18.1-27.8
+ c-12.4-12.5-29.9-23-51.9-31.3c-20.5-7.7-44.1-13.4-70.2-16.7c-48.2-6.2-100-4.6-142,4.4v0.6c42-9,93.8-10.6,142-4.4
+ c26,3.4,49.5,9,70,16.7c21.9,8.3,39.3,18.7,51.7,31.1c8,7.8,14,17.1,17.9,27.6c0.6,1.8,1.2,3.6,1.7,5.5c2.2,8.3,2.7,19.4,1.3,30.4
+ c-0.5,3.9-1.2,7.9-2.1,11.6c0,0,0.4,0,0.6,0C284,566.3,284.7,562.4,285.2,558.4z"/>
+ <path class="st1" d="M271.4,559c1.4-10.5,0.9-21.2-1.2-29.3l-0.3-0.9c-0.4-1.4-0.8-2.9-1.3-4.3c-3.7-10-9.7-19.2-17.3-26.6
+ c-11.8-11.8-28.5-21.8-49.4-29.8c-19.1-7.2-41.6-12.6-66.9-15.9c-45.8-6-95-4.5-135,4.1v0.5c40-8.6,89.2-10.1,135-4.1
+ c25.2,3.3,47.7,8.7,66.7,15.9c20.9,7.9,37.4,17.9,49.2,29.6c7.5,7.3,13.5,16.5,17.1,26.4c0.5,1.4,0.9,2.8,1.3,4.2l0.3,0.9
+ c2.1,8,2.5,18.6,1.2,29.1c-0.4,3-1,7.1-2,11c0,0,0.4,0,0.5,0C270.4,566,271,562.1,271.4,559z"/>
+ <path class="st1" d="M257.5,559.4c1.3-9.8,1-20-1-27.9c-0.4-1.8-0.9-3.5-1.5-5c-3.6-9.7-9.1-18.2-16.4-25.3
+ c-11.3-11.3-27-20.8-46.9-28.3c-18.4-6.9-39.8-12-63.5-15.1c-43.3-5.7-90-4.3-128.2,3.9v0.5c38.1-8.2,84.9-9.6,128.2-3.9
+ c23.7,3.1,45,8.2,63.3,15.1c19.8,7.5,35.5,16.9,46.7,28.1c7.2,7,12.7,15.5,16.2,25.1c0.6,1.5,1.1,3.2,1.5,5c2,7.8,2.3,17.9,1,27.7
+ c-0.5,3.3-1.1,7.1-2,10.6c0,0,0.3,0,0.5,0C256.4,566.5,257,562.7,257.5,559.4z"/>
+ <path class="st1" d="M243.4,559.9c1.3-9.7,0.9-19.1-0.9-26.6c-0.4-1.6-0.8-3.2-1.4-4.7c-3.3-9.1-8.7-17.4-15.6-24.1
+ c-10.7-10.6-25.6-19.6-44.4-26.7c-17.7-6.6-37.9-11.5-60.1-14.3c-40.9-5.4-85.1-4.1-121.1,3.7v0.5c36-7.7,80.2-9.1,121.1-3.7
+ c22.1,2.9,42.3,7.7,59.9,14.3c18.7,7.1,33.6,16,44.2,26.5c6.8,6.6,12.2,14.9,15.4,23.9c0.6,1.5,1,3.1,1.4,4.7
+ c1.8,7.4,2.1,16.8,0.9,26.4c-0.4,2.9-1.1,6.8-1.9,10.1c0,0,0.3,0,0.5,0C242.4,566.7,243,562.9,243.4,559.9z"/>
+ <path class="st1" d="M229.6,560.4c1.2-8.9,0.9-18.1-0.8-25.2c-0.3-1.5-0.8-3-1.3-4.5c-3.1-8.6-8.2-16.5-14.7-22.9
+ c-10.2-10.1-24.2-18.5-41.9-25.2c-16.1-6.1-35.2-10.7-56.7-13.5C75.6,464.1,34,465.3,0,472.7v0.5c33.9-7.3,75.6-8.6,114.3-3.5
+ c21.4,2.8,40.5,7.4,56.5,13.5c17.6,6.6,31.6,15,41.7,25c6.4,6.3,11.5,14.2,14.5,22.7c0.5,1.5,1,3,1.3,4.5c1.7,7,2,16.1,0.8,25
+ c-0.4,3.2-1.1,6.5-1.8,9.6c0,0,0.3,0,0.5,0C228.5,566.9,229.2,563.6,229.6,560.4z"/>
+ <path class="st1" d="M215.8,560.4c1.3-7.9,1.1-15.9-0.6-23.8c-0.4-1.4-0.8-2.8-1.3-4.2c-2.9-8.1-7.7-15.6-14-21.7
+ c-9.5-9.4-22.8-17.4-39.4-23.6c-15.5-5.8-33.4-10.1-53.2-12.7c-36.1-4.8-75.2-3.6-107.3,3.3v0.4c32.1-6.9,71.2-8.1,107.3-3.3
+ c19.8,2.6,37.7,6.9,53.2,12.7c16.5,6.2,29.7,14.2,39.2,23.6c6.2,6,11,13.4,13.8,21.5c0.5,1.4,0.9,2.8,1.3,4.2
+ c1.7,7.8,1.9,15.7,0.6,23.6c-0.3,2.4-0.9,6-1.8,9.6c0,0,0.3,0,0.4,0C214.9,566.4,215.5,562.8,215.8,560.4z"/>
+ <path class="st1" d="M202,561c1.2-7.5,1-15-0.6-22.5c-0.1-0.5-0.3-0.9-0.4-1.4c-0.2-0.9-0.5-1.7-0.8-2.6
+ c-2.7-7.6-7.2-14.7-13-20.5c-8.9-8.8-21.3-16.3-36.9-22.1c-14-5.4-30.8-9.4-49.8-11.9C66.6,475.5,30,476.7,0,483.1v0.4
+ c29.9-6.4,66.6-7.6,100.4-3.1c19,2.5,35.8,6.5,49.8,11.9c15.5,5.9,27.9,13.3,36.7,22.1c5.8,5.7,10.3,12.7,13,20.3
+ c0.3,0.8,0.6,1.7,0.8,2.5c0.1,0.5,0.3,1,0.4,1.4c1.6,7.4,1.8,14.9,0.6,22.3c-0.4,2.9-1.1,6.9-1.7,9c0,0,0.3,0,0.4,0
+ C200.9,567.8,201.6,563.9,202,561z"/>
+ <path class="st1" d="M188.1,561.6c1.2-7.1,1.1-14.2-0.4-21.1c-0.3-1.1-0.6-2.3-1.1-3.8c-2.5-7.2-6.6-13.7-12.2-19.3
+ c-8.2-8.2-19.8-15.1-34.4-20.6c-13.3-5.1-28.9-8.8-46.4-11.1c-31.9-4.1-66-3.1-93.5,2.9v0.4c27.5-6,61.6-7,93.5-2.9
+ c17.5,2.3,33.1,6,46.4,11.1c14.6,5.5,26.1,12.4,34.2,20.6c5.6,5.5,9.7,11.9,12.2,19.1c0.5,1.5,0.8,2.7,1.1,3.8
+ c1.5,6.8,1.6,13.8,0.4,20.9c-0.3,2.1-1.1,6.2-1.6,8.4c0,0,0.3,0,0.4,0C187,567.8,187.7,563.7,188.1,561.6z"/>
+ <path class="st1" d="M173.9,563.5c0.1-0.5,0.2-1,0.3-1.5c1.2-6.5,1.1-13.1-0.2-19.8c-0.2-1-0.6-2.2-1-3.5c-2.4-6.8-6.3-13-11.4-18
+ c-7.6-7.6-18.3-14-31.9-19.1c-13.6-5-28.2-8.5-43.1-10.3c-29.3-3.8-60.8-2.8-86.5,2.7v0.4c25.7-5.5,57.2-6.5,86.5-2.7
+ c15,1.8,29.4,5.3,43.1,10.3c13.5,5.1,24.2,11.5,31.7,19.1c5.1,5,9,11.1,11.4,17.8c0.4,1.3,0.8,2.5,1,3.5
+ c1.3,6.6,1.4,13.2,0.2,19.6c-0.1,0.4-0.2,1-0.3,1.5c-0.4,2.2-0.9,5-1.3,6.5c0,0,0.2,0,0.4,0C173.1,568.4,173.5,565.7,173.9,563.5z
+ "/>
+ <path class="st1" d="M160.3,562.5c1.2-5.8,1.1-12.2-0.1-18.4c-0.2-1.1-0.5-2.2-0.9-3.3c-2.1-6.3-5.7-12.1-10.5-16.8
+ c-7-7-16.8-12.9-29.4-17.6c-12.9-4.7-26.2-7.9-39.7-9.5c-27-3.5-56-2.6-79.6,2.5v0.3c23.6-5.1,52.6-6,79.6-2.5
+ c13.5,1.6,26.8,4.8,39.7,9.5c12.5,4.7,22.3,10.6,29.2,17.6c4.8,4.7,8.4,10.4,10.5,16.6c0.4,1.1,0.7,2.2,0.9,3.3
+ c1.2,6.1,1.3,12.4,0.1,18.2c-0.5,3-1,5.5-1.5,7.5c0,0,0.2,0,0.3,0C159.3,568,159.8,565.5,160.3,562.5z"/>
+ <path class="st1" d="M146.4,563c1.1-5.6,1.1-11.5,0-17.1c-0.2-0.9-0.4-1.9-0.8-3.1c-1.9-5.8-5.2-11.2-9.7-15.6
+ c-6.4-6.4-15.4-11.8-26.9-16.1c-11.7-4.2-24-7.1-36.3-8.6c-24.7-3.2-51.2-2.4-72.7,2.3v0.3c21.5-4.6,48-5.5,72.7-2.3
+ c12.3,1.5,24.5,4.4,36.3,8.6c11.4,4.3,20.4,9.7,26.7,16.1c4.5,4.4,7.8,9.7,9.7,15.4c0.4,1.2,0.6,2.2,0.8,3.1
+ c1.1,5.5,1.1,11.4,0,16.9c-0.3,1.9-1,5.5-1.4,7c0,0,0.2,0,0.3,0C145.4,568.4,146.1,564.9,146.4,563z"/>
+ <path class="st1" d="M132.4,563.5c1.1-5.2,1.2-10.5,0.2-15.7l-0.1-0.4c-0.2-0.8-0.4-1.6-0.6-2.4c-1.8-5.5-4.9-10.4-8.9-14.4
+ c-5.8-5.8-14-10.7-24.4-14.6c-10.5-3.8-21.6-6.4-32.9-7.8c-22.3-3-45.7-2.3-65.7,2v0.3c20-4.3,43.4-5,65.7-2
+ c11.3,1.4,22.4,4,32.9,7.8c10.3,3.9,18.5,8.8,24.3,14.6c4,4,7,8.7,8.9,14.2c0.3,0.8,0.4,1.6,0.6,2.4l0.1,0.4
+ c1,5.1,0.9,10.4-0.2,15.5c-0.3,1.8-0.9,4.4-1.5,6.5c0,0,0.2,0,0.3,0C131.6,567.9,132.1,565.3,132.4,563.5z"/>
+ <path class="st1" d="M118.4,547.3c-1.6-5-4.4-9.5-8.1-13.2c-5.2-5.2-12.6-9.6-21.8-13.1c-9.3-3.4-19.2-5.8-29.5-7
+ c-20.1-2.7-41.1-2.1-58.9,1.8v0.2c17.8-3.8,38.8-4.5,58.9-1.8c10.3,1.2,20.2,3.6,29.5,7c9.2,3.5,16.6,7.9,21.8,13.1
+ c3.7,3.6,6.5,8.1,8.1,13c1.5,5,1.6,16.6,0.3,22.7c0,0,0.2,0,0.2,0C120,563.9,119.9,552.2,118.4,547.3z"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Airy/07.svg b/addons/web_editor/static/shapes/Airy/07.svg
new file mode 100644
index 00000000..e457cec6
--- /dev/null
+++ b/addons/web_editor/static/shapes/Airy/07.svg
@@ -0,0 +1,278 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+<style type="text/css">
+ .st0{opacity:0.14;}
+ .st1{fill:#7C6576;}
+ @media only screen and (max-width: 300px) {
+ .st0{opacity:.7;}
+ }
+</style>
+<g class="st0">
+ <path class="st1" d="M1026.2,0c-33.6,96.1-55.9,135.9-81.1,169c-25.8,33.7-56.5,63-91.4,87.2c-33.5,23.4-72.3,43.6-115.5,59.9
+ c-40.1,15.2-85.3,27.5-134.3,36.7c-86.5,16-184.8,22.4-300.3,19.5C199.1,369.6,17.6,351,0,349.2v2c17.6,1.8,199,20.4,303.6,23.1
+ c18,0.5,35.6,0.7,52.8,0.7c93.2,0,174.8-6.7,247.9-20.2c49.1-9.1,94.4-21.5,134.7-36.7c43.3-16.4,82.3-36.6,115.9-60.1
+ c35-24.3,65.9-53.7,91.8-87.6C972,136.9,994.5,96.5,1028.2,0H1026.2z"/>
+ <path class="st1" d="M1010.7,0c-33.2,94.8-55.2,134.1-80,166.7c-25.2,33-55.5,62-90.1,86c-33.2,23.2-71.5,43.1-113.9,59.1
+ c-39.9,15.1-84.4,27.3-132.4,36.2C509.2,363.8,412.2,370,297.7,367C195.2,364.3,19.6,346.1,0,344.1v2
+ c19.6,2.1,195.1,20.2,297.7,23c18.6,0.5,36.7,0.7,54.3,0.7c91.4,0,171.2-6.5,242.7-19.8c48.1-8.9,92.8-21.1,132.8-36.2
+ c42.5-16.1,81-36,114.3-59.3c34.7-24.1,65.2-53.2,90.5-86.4c24.5-32.2,46.9-72.2,80.4-167.9H1010.7z"/>
+ <path class="st1" d="M997,0c-32.7,93.4-54.4,132.2-78.9,164.4c-25.1,32.9-55,61.4-88.8,84.8c-32.5,22.8-70.3,42.4-112.3,58.3
+ c-39.1,14.8-83,26.8-130.5,35.7c-83.6,15.6-179.4,21.7-292.8,18.7C194.1,359.2,19.5,341.2,0,339.1v1.9c19.5,2,194,20,293.6,22.7
+ c18.7,0.5,36.9,0.7,54.7,0.7c90.1,0,168.6-6.4,238.5-19.5c47.7-8.8,91.7-20.9,130.9-35.7c42.1-15.9,80-35.6,112.7-58.5
+ c33.9-23.5,64-52.2,89.2-85.2c24.7-32.4,46.5-71.7,79.3-165.6H997z"/>
+ <path class="st1" d="M982.8,0C950.4,92.3,929,130.5,905,162c-24.7,32.4-54.2,60.5-87.6,83.6c-32.2,22.5-69.4,41.8-110.6,57.5
+ c-38.5,14.6-81.8,26.4-128.7,35.2c-82.7,15.4-177.2,21.4-289,18.3C190,353.8,21.6,336.4,0,334.1v1.9
+ c21.6,2.3,189.9,19.7,289.1,22.5c19.1,0.5,37.7,0.8,55.8,0.8c88.1,0,164.9-6.3,233.6-19.1c47-8.7,90.5-20.6,129.1-35.2
+ c41.4-15.7,78.7-35.1,111-57.7c33.5-23.2,63.2-51.5,88-84c24.1-31.8,45.6-70.4,78.1-163.2H982.8z"/>
+ <path class="st1" d="M968.7,0c-31.8,90.8-52.9,128.5-76.7,159.7c-24.5,32-53.6,59.7-86.3,82.4c-31.5,22.1-68.2,41.2-109,56.7
+ c-38,14.5-80.7,26.2-126.8,34.8c-81.1,15.2-174.4,21-285.2,17.9C189.8,348.8,22.8,331.6,0,329.2v1.9
+ c22.8,2.4,189.7,19.5,284.7,22.2c19.2,0.5,37.9,0.8,56.2,0.8c86.8,0,162.3-6.2,229.4-18.7c46.2-8.6,89-20.3,127.2-34.8
+ c40.9-15.6,77.7-34.7,109.4-56.9c32.9-22.8,62.1-50.7,86.7-82.8c23.9-31.5,45.1-69.6,77-160.9H968.7z"/>
+ <path class="st1" d="M955.8,0c-31.3,89.5-52.1,126.6-75.5,157.4c-23.9,31.2-52.5,58.5-85,81.1c-31.2,21.8-67.3,40.6-107.4,55.8
+ c-37.2,14.2-79.3,25.7-124.9,34.3c-79.6,15-171.7,20.7-281.5,17.6C188.9,343.5,18.8,326,0,324.1v1.9c18.8,2,188.8,19.5,281.5,22.1
+ c19.4,0.6,38.1,0.8,56.4,0.8c85.6,0,159.8-6.1,225.5-18.4c45.8-8.6,87.9-20.1,125.3-34.3c40.2-15.2,76.5-34.1,107.8-56
+ c32.7-22.7,61.4-50.1,85.4-81.5c23.5-31,44.5-68.6,75.9-158.6H955.8z"/>
+ <path class="st1" d="M942.8,0c-30.8,88.1-51.3,124.6-74.4,155.1c-23.7,30.9-51.9,57.8-83.8,79.9c-30.6,21.4-66.2,39.9-105.8,55
+ c-36.5,13.9-77.9,25.3-123,33.8c-78.7,14.8-169.5,20.4-277.7,17.2C185.4,338.1,14.6,320.3,0,318.8v1.8
+ c14.6,1.5,185.3,19.3,278,22.1c19.7,0.6,38.9,0.9,57.5,0.9c83.6,0,156.2-6,220.6-18.1c45.2-8.5,86.7-19.9,123.4-33.8
+ c39.8-15.1,75.5-33.7,106.2-55.2c32.1-22.3,60.4-49.3,84.2-80.3c23.2-30.6,43.8-67.7,74.7-156.2H942.8z"/>
+ <path class="st1" d="M927.7,0c-30.5,87.1-50.7,123.1-73.3,152.8c-23.2,30.3-51,56.8-82.5,78.7c-30.2,21.2-65.3,39.4-104.2,54.2
+ c-36.3,13.9-77.1,25.1-121.1,33.3c-77.2,14.5-166.8,20-273.9,16.8C183.5,333,21.6,316.2,0,314v1.8c21.6,2.3,183.4,19,272.6,21.8
+ c19.9,0.6,39.2,0.9,57.9,0.9c82.2,0,153.5-5.9,216.4-17.7c44.2-8.2,85-19.4,121.5-33.3c39-14.9,74.2-33.2,104.6-54.4
+ c31.7-22,59.5-48.6,82.9-79.1c22.7-29.9,43-66.3,73.6-153.9H927.7z"/>
+ <path class="st1" d="M914.9,0c-29.9,85.5-49.8,121-72.2,150.5c-22.8,29.9-50.1,56-81.2,77.5c-29.9,20.9-64.4,38.9-102.5,53.4
+ c-36.3,13.7-76.4,24.8-119.2,32.8c-76.2,14.4-164.6,19.7-270.2,16.4C178,327.7,16.7,310.7,0,308.9v1.8
+ c16.7,1.8,177.8,18.8,269.5,21.7c20.3,0.6,39.9,1,58.9,1c80.3,0,150-5.7,211.7-17.4c43-8,83.2-19.1,119.6-32.8
+ c38.3-14.6,72.9-32.6,102.9-53.6c31.2-21.7,58.7-47.9,81.6-77.9c22.5-29.7,42.5-65.6,72.5-151.6H914.9z"/>
+ <path class="st1" d="M900.9,0c-29.4,84.2-49,119.1-71.1,148.1c-22.6,29.5-49.5,55.2-79.9,76.3c-29.3,20.5-63.2,38.2-100.9,52.6
+ c-34.8,13.3-74.3,24.2-117.4,32.4c-74.7,14.2-161.8,19.5-266.4,16.1c-89.5-3-246.9-19.6-265.2-21.6v1.8
+ c18.3,2,175.5,18.6,265.1,21.6c20.4,0.7,40.1,1,59.1,1c79.1,0,147.4-5.6,207.7-17.1c43.2-8.2,82.9-19.1,117.8-32.4
+ c37.8-14.5,71.9-32.2,101.3-52.8c30.6-21.3,57.6-47.1,80.3-76.7C853.4,120,873.1,84.6,902.7,0H900.9z"/>
+ <path class="st1" d="M886.8,0c-28.9,82.8-48.2,117.2-70,145.8c-22.2,29-48.7,54.2-78.6,75c-28.6,20.1-62,37.5-99.3,51.8
+ c-34.9,13.3-73.8,24-115.6,31.9c-73.2,13.9-159.1,19.1-262.6,15.7C168.2,317.1,19,301,0,298.9v1.7c19,2.1,168.1,18.2,260.7,21.3
+ c20.5,0.7,40.3,1,59.5,1c77.7,0,144.7-5.5,203.5-16.7c41.8-7.9,80.8-18.6,115.9-31.9c37.4-14.3,71-31.8,99.7-52
+ c30.1-20.9,56.7-46.2,79-75.4c21.9-28.7,41.3-63.6,70.3-146.8H886.8z"/>
+ <path class="st1" d="M875.9,0c-28.6,81.6-47.5,115.4-68.9,143.5c-22,28.6-48,53.4-77.3,73.8c-28.6,20-61.5,37.1-97.7,50.9
+ c-34.3,13-72.6,23.6-113.7,31.4c-72.3,13.8-156.9,18.8-258.8,15.3C162.1,311.6,0,293.6,0,293.6v1.7c0,0,162,18,259.4,21.3
+ c20.9,0.7,41,1.1,60.5,1.1c75.7,0,141.1-5.4,198.7-16.4c41.2-7.8,79.5-18.4,113.9-31.4c36.4-13.8,69.4-31,98.1-51.1
+ c29.5-20.5,55.6-45.4,77.7-74.2C829.8,116.3,848.9,82,877.6,0H875.9z"/>
+ <path class="st1" d="M862.2,0c-28.1,80.3-46.7,113.5-67.8,141.1c-21.4,28-47,52.4-76.1,72.6c-28,19.6-60.3,36.4-96.1,50.1
+ c-33.5,12.8-71.1,23.2-111.8,30.9c-70.9,13.6-154.3,18.5-255.1,14.9C159.2,306.2,0,288.4,0,288.4v1.7c0,0,159,17.8,255.2,21.2
+ c21.4,0.8,41.9,1.1,61.8,1.1c73.9,0,137.7-5.3,193.7-16.1c40.7-7.7,78.4-18.1,112-30.9c35.9-13.7,68.4-30.6,96.5-50.3
+ c29.2-20.3,55-44.8,76.5-73c21.2-27.7,39.9-61.4,68.1-142.1H862.2z"/>
+ <path class="st1" d="M847.1,0c-27.6,78.9-45.9,111.6-66.6,138.8c-21.3,27.7-46.4,51.7-74.8,71.4c-27.3,19.2-59.1,35.7-94.4,49.3
+ c-33.2,12.7-70.2,22.9-109.9,30.4C432.2,303.2,350,308,250.1,304.5C160.6,301.2,10.8,284.8,0,283.6v1.6
+ c10.8,1.2,160.5,17.6,250,20.9c21.1,0.7,41.5,1.1,61.1,1.1c73.1,0,135.9-5.2,190.6-15.7c39.8-7.5,76.8-17.7,110.1-30.4
+ c35.5-13.6,67.4-30.3,94.8-49.5c28.5-19.8,53.8-44,75.2-71.8c20.8-27.4,39.2-60.6,66.9-139.8H847.1z"/>
+ <path class="st1" d="M768,136.4c-20.8,27.1-45.6,50.8-73.5,70.2c-27,18.9-58.2,35.2-92.8,48.5c-32.5,12.4-68.8,22.5-108,30
+ c-68.3,13.2-149.3,17.8-247.5,14.2c-87-3.2-236.3-19.7-246.2-20.8v1.6c9.9,1.1,159,17.6,246.1,20.8c21.5,0.8,42.1,1.2,61.9,1.2
+ c71.2,0,132.4-5.1,185.9-15.4c39.3-7.5,75.7-17.6,108.2-30c34.7-13.3,66.1-29.7,93.2-48.7c28.1-19.6,53-43.3,73.9-70.6
+ c27.5-36.1,45.6-79.5,65.8-137.4h-1.6C806.3,77.7,788.3,109.8,768,136.4z"/>
+ <path class="st1" d="M755.3,134.1c-20.2,26.5-44.5,49.7-72.2,69c-26.4,18.5-57,34.6-91.2,47.7c-32.2,12.3-67.9,22.2-106.2,29.5
+ c-66.9,12.9-146.7,17.4-243.8,13.8C156.1,290.9,9.7,274.6,0,273.5v1.6c9.7,1.1,156,17.4,241.8,20.6c21.6,0.8,42.3,1.2,62.3,1.2
+ c69.8,0,129.8-5,181.9-15c38.3-7.2,74.1-17.2,106.4-29.5c34.3-13.2,65.1-29.3,91.6-47.9c27.8-19.4,52.3-42.8,72.6-69.4
+ c27-35.5,44.8-78.2,64.7-135.1h-1.6C799.9,56.6,782.2,98.8,755.3,134.1z"/>
+ <path class="st1" d="M742.7,131.7c-20,26.1-43.9,48.9-71,67.8c-26,18.2-56.2,34-89.6,46.9c-31.6,12.1-66.7,21.8-104.3,29
+ c-65.9,12.7-144.4,17.1-240,13.4C155.1,285.6,9.7,269.5,0,268.4v1.6c9.7,1.1,155,17.2,237.7,20.4c21.9,0.9,43,1.3,63.2,1.3
+ c67.9,0,126.3-4.9,177.2-14.7c37.7-7.2,72.8-16.9,104.5-29c33.6-12.9,63.8-28.8,90-47.1c27.2-19,51.2-41.9,71.4-68.2
+ C770.6,97.8,788.1,55.9,807.6,0H806C779.8,75,762.4,106.1,742.7,131.7z"/>
+ <path class="st1" d="M730.1,129.4c-19.6,25.6-43.1,48-69.7,66.6c-25.7,18-55.3,33.5-88,46c-30.8,11.8-65.2,21.4-102.4,28.5
+ c-64.4,12.6-141.7,16.8-236.2,13.1C153.7,280.4,9.6,264.4,0,263.3v1.5c9.6,1.1,153.6,17.1,233.7,20.3c22,0.9,43.1,1.3,63.3,1.3
+ c66.6,0,123.7-4.8,173.3-14.4c37.2-7.1,71.7-16.7,102.6-28.5c32.8-12.6,62.6-28.1,88.4-46.2c26.8-18.7,50.4-41.3,70.1-67
+ C757.5,96,774.7,54.8,793.8,0h-1.5C773.2,54.6,756.1,95.3,730.1,129.4z"/>
+ <path class="st1" d="M778.3,0c-18.8,53.7-35.6,93.7-61,127.2c-19.4,25.2-42.5,47.2-68.4,65.3c-25,17.6-54.1,32.8-86.3,45.2
+ c-30,11.5-63.8,20.9-100.5,28c-63.4,12.3-139.5,16.4-232.5,12.7C151.3,275.2,11.9,259.6,0,258.3v1.5
+ c11.9,1.3,151.2,16.9,229.4,20.1c22,0.9,43.1,1.3,63.3,1.3c65.2,0,121.1-4.6,169.6-14c36.8-7,70.6-16.5,100.7-28
+ c32.4-12.5,61.5-27.7,86.7-45.4c26.1-18.2,49.2-40.3,68.8-65.7C744.1,94.4,760.9,53.9,779.8,0H778.3z"/>
+ <path class="st1" d="M764.5,0c-18.4,52.7-34.8,91.9-59.9,124.8c-19,24.8-41.6,46.4-67.1,64.1c-24.4,17.2-52.9,32.1-84.7,44.4
+ c-29.4,11.3-62.6,20.5-98.6,27.5c-62.4,12.1-137.3,16.1-228.7,12.3C152.3,270,11.9,254.6,0,253.3v1.5
+ c11.9,1.3,152.2,16.8,225.3,19.8c22.3,0.9,43.7,1.4,64.1,1.4c63.3,0,117.7-4.5,165-13.7c36.1-7,69.4-16.2,98.8-27.5
+ c32-12.3,60.6-27.3,85.1-44.6c25.7-17.9,48.4-39.5,67.5-64.5C731,92.6,747.5,52.9,766,0H764.5z"/>
+ <path class="st1" d="M751,0c-18,51.7-34.2,90.2-58.8,122.5c-18.6,24.2-40.7,45.4-65.8,62.9c-24.1,16.9-52,31.6-83.1,43.6
+ c-28.4,10.9-60.9,20-96.7,27.1C385.4,268,311.9,271.9,221.6,268C149.5,264.8,10.3,249.3,0,248.2v1.5
+ c10.3,1.2,149.3,16.6,221.5,19.8c22.5,1,43.9,1.4,64.3,1.4c61.9,0,114.9-4.4,160.9-13.4c35.8-7,68.4-16.2,96.9-27.1
+ c31.2-12.1,59.3-26.8,83.5-43.8c25.2-17.6,47.5-38.9,66.2-63.3c24.8-32.4,41-71.4,59.1-123.4H751z"/>
+ <path class="st1" d="M737,0c-17.7,50.7-33.6,88.4-57.7,120.1c-18.4,23.9-40.1,44.7-64.6,61.7c-23.8,16.7-51.2,31.1-81.5,42.8
+ c-28.2,10.9-60.1,19.8-94.9,26.6c-60.1,11.7-132.5,15.5-221.1,11.5C146.9,259.5,11.7,244.4,0,243.1v1.4
+ c11.7,1.3,146.8,16.4,217,19.6c22.7,1,44.4,1.5,65,1.5c60,0,111.6-4.3,156.4-13c34.9-6.8,66.9-15.7,95.1-26.6
+ c30.5-11.8,58-26.2,81.9-43c24.6-17.2,46.5-38,65-62.1c24.3-31.8,40.2-70,58-121H737z"/>
+ <path class="st1" d="M723.3,0c-17.4,49.9-33,87-56.6,117.8c-17.8,23.2-39.1,43.6-63.3,60.6c-23.5,16.5-50.4,30.6-79.8,41.9
+ c-27.6,10.6-58.8,19.4-93,26.1c-59,11.5-130.1,15.2-217.4,11.2C146.4,254.4,11.8,239.5,0,238.1v1.4c11.8,1.3,146.2,16.3,213,19.4
+ c22.7,1,44.3,1.6,64.9,1.6c58.7,0,109.2-4.2,152.9-12.8c34.2-6.7,65.6-15.5,93.2-26.1c29.6-11.3,56.6-25.5,80.2-42.1
+ c24.4-17,45.8-37.5,63.7-60.8c23.8-31,39.4-68.5,56.9-118.6H723.3z"/>
+ <path class="st1" d="M709.4,0c-17,48.8-32.3,85.1-55.4,115.4c-17.4,22.7-38.3,42.7-62,59.4c-22.5,15.9-48.9,29.7-78.2,41.1
+ c-26.8,10.3-57.4,19-91.1,25.6c-57.6,11.3-127.5,14.9-213.7,10.9C141.7,249.1,12.2,234.4,0,233.1v1.4
+ c12.2,1.4,141.6,16,208.8,19.3c22.8,1.1,44.4,1.6,64.9,1.6c57.5,0,106.6-4.1,149.1-12.5c33.8-6.6,64.5-15.3,91.3-25.6
+ c29.5-11.4,55.9-25.3,78.5-41.3c23.8-16.8,44.8-36.8,62.4-59.6C678.4,85.8,693.7,49,710.8,0H709.4z"/>
+ <path class="st1" d="M695.8,0c-16.6,47.6-31.5,83.1-54.3,113.1c-17.2,22.4-37.7,42-60.8,58.1c-22.4,15.8-48.2,29.4-76.7,40.3
+ c-26.7,10.3-56.7,18.7-89.2,25.1c-56.3,11.1-124.9,14.5-209.8,10.4c-64-3.1-193.6-17.8-205.1-19.1v1.4c11.5,1.3,141,15.9,205,19.1
+ c23.2,1.1,45.2,1.7,66,1.7c55.6,0,103.1-4,144.1-12.1c32.6-6.3,62.7-14.8,89.4-25.1c28.5-11,54.4-24.6,76.9-40.5
+ c23.2-16.2,43.7-35.8,61.1-58.3c23-30.2,37.9-66.1,54.6-113.9H695.8z"/>
+ <path class="st1" d="M681.7,0c-16.4,46.8-31,81.6-53.2,110.7c-17,22.1-37.1,41.2-59.6,56.9c-22.1,15.5-47.4,28.8-75.1,39.5
+ c-25.7,10-55.1,18.3-87.3,24.7c-55.3,10.9-122.7,14.2-206.1,10c-62-3.2-187.4-17.4-200.5-18.9v1.3c13.1,1.5,138.4,15.8,200.4,18.9
+ c23.5,1.2,45.6,1.8,66.7,1.8c53.8,0,100-3.9,139.8-11.8c32.3-6.4,61.8-14.7,87.5-24.7c27.8-10.7,53.2-24.1,75.3-39.7
+ c22.6-15.7,42.7-34.9,59.8-57.1C652,82.3,666.7,47,683.1,0H681.7z"/>
+ <path class="st1" d="M668.2,0c-16,45.7-30.2,79.8-52.1,108.4c-16.4,21.4-36,40.2-58.3,55.7c-21.5,15.1-46.2,28.2-73.5,38.7
+ c-24.9,9.7-53.6,17.8-85.4,24.2c-53.8,10.7-120,13.8-202.3,9.7C137.5,233.6,12.5,219.3,0,217.9v1.3
+ c12.5,1.4,137.3,15.7,196.5,18.8c23.3,1.2,45.3,1.8,66,1.8c52.9,0,97.9-3.8,136.6-11.5c31.9-6.4,60.7-14.5,85.6-24.2
+ c27.4-10.6,52.2-23.7,73.7-38.9c22.3-15.6,42-34.4,58.5-55.9C639.1,80.4,653.4,46,669.5,0H668.2z"/>
+ <path class="st1" d="M654.4,0c-18.5,53.1-32.4,81.8-51,106c-16,20.9-35.2,39.2-57,54.5c-21.1,14.9-45.3,27.6-71.8,37.9
+ C450,208,421.9,216,391,222.1c-52.6,10.4-117.5,13.5-198.5,9.2c-56.2-3-180-17.1-192.5-18.5v1.3c12.5,1.4,136.2,15.6,192.4,18.6
+ c23.7,1.2,46,1.9,67.1,1.9c51,0,94.5-3.7,131.8-11.1c30.9-6.1,59.1-14.1,83.8-23.7c26.6-10.3,50.8-23.1,72-38.1
+ c21.9-15.3,41.2-33.8,57.2-54.7C623.2,82.5,637.1,53.4,655.7,0H654.4z"/>
+ <path class="st1" d="M640.6,0c-20.4,58.6-34.7,83.9-49.8,103.7c-15.8,20.5-34.5,38.4-55.7,53.3c-22,15.3-45.7,27.7-70.3,37
+ c-24,9.4-51.5,17.2-81.7,23.2c-51.5,10.4-115.2,13.3-194.8,8.9C133.1,223,12.9,209.2,0,207.8v1.2c12.9,1.5,133,15.3,188.2,18.3
+ c23.9,1.3,46.3,2,67.4,2c49.4,0,91.6-3.6,127.8-10.9c30.2-6,57.8-13.8,81.9-23.2c24.7-9.3,48.4-21.8,70.5-37.2
+ c21.3-14.9,40.1-32.9,55.9-53.5C607,84.5,621.3,58.9,641.8,0H640.6z"/>
+ <path class="st1" d="M626.8,0c-17.6,50.6-30.9,78-48.7,101.3c-15.4,20-33.6,37.6-54.4,52.1c-20.2,14.3-43.3,26.5-68.6,36.2
+ c-23,9-49.9,16.7-79.8,22.8c-50.2,10.1-112.7,12.9-191,8.5c-53.5-3-171.6-16.7-184.3-18.2v1.2c12.7,1.5,130.7,15.2,184.2,18.2
+ c24,1.3,46.4,2,67.5,2c48,0,88.9-3.5,123.8-10.5c30-6.1,56.9-13.8,80-22.8c25.3-9.8,48.5-22,68.8-36.4
+ c20.8-14.6,39.2-32.2,54.6-52.3C597,78.6,610.3,50.8,628,0H626.8z"/>
+ <path class="st1" d="M613.1,0c-19.5,55.9-33.2,80.1-47.6,99c-15.2,19.7-33.1,36.8-53.2,50.9c-19.6,13.9-42.1,25.8-67,35.4
+ c-23,9-49.2,16.5-77.9,22.3c-49.3,9.9-110.5,12.6-187.2,8.1c-50.8-3-167.5-16.5-180.2-18v1.2c12.7,1.5,129.3,15.1,180.1,18
+ c24.1,1.4,46.7,2.1,67.9,2.1c46.3,0,85.8-3.4,119.7-10.2c28.8-5.8,55.1-13.3,78.1-22.3c24.9-9.7,47.5-21.6,67.2-35.6
+ c20.2-14.1,38.2-31.3,53.4-51.1C581,80.7,594.7,56.2,614.3,0H613.1z"/>
+ <path class="st1" d="M599.2,0c-16.8,48.2-29.5,74.4-46.5,96.7c-14.6,19-32.1,35.7-51.9,49.7c-20.1,14-42.1,25.7-65.4,34.6
+ c-22.2,8.7-47.7,16-76,21.8c-48.2,9.8-108.2,12.3-183.4,7.8c-49.2-3-162.4-16.3-176-17.9v1.2c13.5,1.6,126.7,14.9,175.9,17.9
+ c24,1.4,46.4,2.2,67.4,2.2c45,0,83.4-3.3,116.3-10c28.3-5.7,54-13.1,76.2-21.8c23.4-9,45.5-20.7,65.6-34.8
+ c19.9-14,37.4-30.8,52.1-49.9C570.8,75,583.5,48.4,600.4,0H599.2z"/>
+ <path class="st1" d="M585.6,0c-16.4,46.9-28.7,72.5-45.4,94.4c-14.5,18.7-31.5,34.9-50.6,48.4c-19.7,13.7-41.1,25.1-63.7,33.8
+ c-21.4,8.4-46.3,15.6-74.1,21.3c-46.8,9.5-105.6,11.9-179.7,7.4C124,202.3,12.4,189,0,187.5v1.1c12.4,1.5,123.9,14.7,172.1,17.7
+ c24.1,1.5,46.6,2.2,67.6,2.2c43.6,0,80.8-3.2,112.4-9.6c27.9-5.7,52.9-12.9,74.3-21.3c22.6-8.8,44.1-20.2,63.9-34
+ c19.2-13.5,36.3-29.9,50.8-48.6c16.8-22,29.1-47.9,45.6-95.1H585.6z"/>
+ <path class="st1" d="M571.8,0c-16,45.8-28,70.7-44.3,92c-13.8,18-30.4,33.9-49.3,47.2c-19.2,13.4-40.1,24.5-62.1,33
+ c-21.3,8.4-45.7,15.4-72.3,20.8c-45.5,9.4-103.1,11.7-176,7C122.2,197,13.4,184.1,0,182.5v1.1c13.4,1.6,122.1,14.5,167.8,17.5
+ c24.4,1.6,47.1,2.3,68.2,2.3c42,0,77.7-3.1,108.1-9.4c26.7-5.4,51.1-12.4,72.5-20.8c22.1-8.5,43.1-19.7,62.3-33.2
+ c18.9-13.4,35.6-29.3,49.5-47.4C544.8,71.3,556.9,46,572.9,0H571.8z"/>
+ <path class="st1" d="M557.9,0c-15.3,43.8-27.4,69-43.2,89.6c-13.5,17.5-29.6,33-48.1,46c-18.6,13.1-39,23.9-60.5,32.1
+ c-20.3,8.1-44,14.9-70.4,20.4c-44.2,9.1-100.5,11.3-172.2,6.6c-44-2.9-150.1-15.7-163.6-17.4v1.1c13.5,1.6,119.5,14.5,163.5,17.4
+ c24.5,1.6,47.2,2.4,68.2,2.4c40.6,0,75.1-3,104.2-9c26.5-5.5,50.2-12.3,70.6-20.4c21.6-8.3,42-19.1,60.7-32.3
+ c18.5-13.1,34.8-28.6,48.3-46.2c15.9-20.7,28-46.2,43.4-90.2H557.9z"/>
+ <path class="st1" d="M544.2,0c-14.8,42.6-26.6,67.1-42.1,87.3c-13.4,17.3-29.1,32.4-46.8,44.8c-18.4,12.9-38.2,23.4-58.9,31.3
+ c-19.5,7.8-42.6,14.5-68.5,19.9c-43.2,9-98.3,11-168.5,6.2C117.6,186.6,13.4,173.9,0,172.3v1.1c13.4,1.6,117.5,14.3,159.4,17.2
+ c24.6,1.7,47.4,2.5,68.4,2.5c39,0,72.2-2.9,100.3-8.7c26-5.4,49.1-12.1,68.7-19.9c20.7-7.9,40.6-18.5,59.1-31.5
+ c17.7-12.5,33.5-27.6,47-45c15.6-20.3,27.4-45.1,42.3-87.9H544.2z"/>
+ <path class="st1" d="M530.4,0c-14.5,41.7-26,65.5-41,84.9c-12.8,16.7-28.1,31.3-45.5,43.6c-17.4,12.2-36.7,22.5-57.2,30.5
+ c-19.5,7.7-41.9,14.3-66.6,19.4c-42.2,8.8-96.1,10.7-164.7,5.9C115,181.4,13.2,168.9,0,167.2v1c13.2,1.6,114.9,14.2,155.4,17.1
+ c24.4,1.7,47,2.6,67.9,2.6c37.8,0,69.8-2.8,97.1-8.5c24.8-5.1,47.3-11.7,66.8-19.4c20.6-8.1,39.9-18.4,57.4-30.7
+ c17.5-12.3,32.9-27.1,45.7-43.8c15-19.5,26.6-43.6,41.2-85.5H530.4z"/>
+ <path class="st1" d="M516.6,0c-14.1,40.6-25.3,63.7-39.9,82.5c-12.6,16.3-27.4,30.5-44.2,42.4c-17.2,12.1-35.9,22.1-55.6,29.7
+ c-18.5,7.4-40.3,13.8-64.7,18.9c-40.8,8.5-93.4,10.3-160.9,5.5C112.2,176.2,13.5,163.8,0,162.1v1c13.5,1.7,112.1,14,151.3,16.9
+ c24.5,1.8,47.1,2.6,67.9,2.6c36.4,0,67.2-2.7,93.3-8.2c24.5-5.1,46.3-11.5,64.9-18.9c19.7-7.7,38.5-17.8,55.8-29.9
+ c16.8-11.9,31.8-26.3,44.4-42.6c14.6-18.9,25.9-42.3,40.1-83.1H516.6z"/>
+ <path class="st1" d="M502.8,0c-13.5,39-24.4,61.5-38.7,80.2c-12.2,15.8-26.7,29.7-43,41.1c-16.6,11.6-34.8,21.4-54,28.9
+ c-18.2,7.3-38.8,13.3-62.9,18.4c-39.6,8.4-91,10.1-157.2,5.1c-37.2-2.8-133.8-15-147.1-16.7v1c13.3,1.7,109.8,13.8,147,16.7
+ c24.7,1.9,47.4,2.8,68.2,2.8c34.9,0,64.4-2.6,89.3-7.9c24.2-5.1,44.8-11.1,63.1-18.4c19.3-7.6,37.6-17.4,54.2-29.1
+ c16.4-11.5,30.9-25.4,43.2-41.3C479.3,62,490.2,39.2,503.8,0H502.8z"/>
+ <path class="st1" d="M489.1,0c-13.2,37.9-23.7,59.8-37.6,77.8c-12,15.5-26,28.9-41.7,39.9c-16.1,11.3-33.7,20.8-52.4,28.1
+ c-17.2,6.9-37.1,12.8-61,18c-38.2,8.1-88.3,9.7-153.4,4.8C107,165.8,13.1,153.8,0,152.1v1c13.1,1.7,107,13.7,143,16.6
+ c24.6,1.9,47.1,2.8,67.6,2.8c33.8,0,62.3-2.5,86.1-7.6c23.9-5.2,43.9-11.1,61.2-18c18.8-7.4,36.5-16.9,52.6-28.3
+ c15.7-11.1,29.8-24.6,41.9-40.1c14-18.2,24.6-40.3,37.8-78.4H489.1z"/>
+ <path class="st1" d="M475.3,0c-12.9,37-23.1,58.2-36.5,75.4c-11.6,15-25.2,28-40.4,38.7c-15.7,11.1-32.8,20.3-50.8,27.3
+ c-17.2,6.9-37.1,12.7-59.1,17.5c-37.3,8-86.2,9.4-149.6,4.4c-34-2.8-126-14.7-139-16.3v0.9c13,1.7,104.8,13.6,138.9,16.4
+ c24.6,1.9,47,2.9,67.4,2.9c32.3,0,59.5-2.4,82.4-7.3c22.1-4.7,42-10.6,59.3-17.5c18-7.1,35.2-16.4,51-27.5
+ c15.3-10.7,29-23.8,40.6-38.9c13.5-17.4,23.7-38.8,36.7-76H475.3z"/>
+ <path class="st1" d="M461.6,0c-12.4,35.6-22.3,56.1-35.4,73.1c-11,14.3-24.2,26.9-39.1,37.5c-15.3,10.8-31.8,19.7-49.1,26.4
+ c-16.3,6.6-35,12.2-57.2,17c-36.4,7.8-84.1,9.1-145.9,4C102.2,155.3,13.2,143.7,0,141.9v0.9c13.2,1.7,102.1,13.4,134.8,16.1
+ c24.6,2,47,3.1,67.3,3.1c30.8,0,56.9-2.4,78.9-7.1c22.3-4.8,41-10.4,57.4-17c17.4-6.8,34-15.7,49.3-26.6
+ c15-10.6,28.2-23.3,39.3-37.7C440.1,56.6,450,35.8,462.5,0H461.6z"/>
+ <path class="st1" d="M447.8,0c-11.9,34.4-21.5,54.3-34.3,70.7c-10.8,14-23.6,26.2-37.9,36.3c-15,10.5-30.9,19.1-47.5,25.7
+ c-15.5,6.3-33.6,11.7-55.3,16.5c-35.1,7.6-81.5,8.7-142.1,3.6C99.1,150,13,138.5,0,136.7v0.9c13,1.7,99,13.2,130.6,16
+ c24.6,2.1,46.9,3.1,67.1,3.1c29.5,0,54.4-2.2,75.3-6.8c21.8-4.8,39.9-10.2,55.5-16.5c16.6-6.5,32.7-15.2,47.7-25.8
+ c14.4-10.2,27.2-22.5,38.1-36.5C427,54.7,436.7,34.6,448.7,0H447.8z"/>
+ <path class="st1" d="M434.1,0c-11.5,33.3-20.8,52.4-33.1,68.4c-10.2,13.3-22.6,25.1-36.6,35.1c-14.1,10-29.6,18.3-45.9,24.9
+ c-15.2,6.2-32.7,11.5-53.4,16c-33.8,7.4-79,8.5-138.3,3.3c-29.9-2.7-114-14.1-126.8-15.8v0.9c12.8,1.7,96.8,13.1,126.7,15.8
+ c24.5,2.2,46.6,3.2,66.6,3.2c28.3,0,52.1-2.2,72-6.5c20.8-4.5,38.3-9.8,53.6-16c16.4-6.5,31.9-14.9,46.1-24.9
+ c14.1-10.1,26.5-21.9,36.8-35.3c12.4-16,21.7-35.4,33.3-68.9H434.1z"/>
+ <path class="st1" d="M420.3,0c-11.1,32.1-20.1,50.6-32,66c-10.2,13.1-22.1,24.5-35.3,33.9c-13.8,9.7-28.7,17.8-44.3,24.1
+ c-15,6-31.8,11.1-51.6,15.6c-32.8,7.3-76.8,8.2-134.5,2.9c-28.5-2.7-110-14-122.6-15.7v0.8c12.6,1.7,94,13,122.5,15.7
+ c24.5,2.3,46.5,3.4,66.2,3.4c26.9,0,49.6-2.1,68.5-6.3c19.8-4.5,36.8-9.6,51.8-15.6c15.7-6.3,30.6-14.4,44.5-24.1
+ c13.3-9.4,25.2-20.9,35.5-34.1c12-15.5,21-34.2,32.2-66.5H420.3z"/>
+ <path class="st1" d="M406.4,0c-10.7,30.9-19.3,48.7-30.9,63.7c-9.8,12.6-21.2,23.6-34,32.7c-13.3,9.4-27.6,17.2-42.7,23.3
+ c-16.2,6.5-33,11.5-49.7,15.1c-31.6,7-74.4,7.8-130.8,2.5C90.7,134.5,13,123.6,0,121.7v0.8c13,1.8,90.6,12.8,118.2,15.5
+ c24.5,2.3,46.4,3.5,65.9,3.5c25.6,0,47.2-2,65.1-6c16.8-3.6,33.6-8.6,49.9-15.1c15.1-6.1,29.6-13.9,42.9-23.3
+ c12.9-9.2,24.4-20.2,34.2-32.9C387.8,49.1,396.5,31,407.2,0H406.4z"/>
+ <path class="st1" d="M392.7,0c-10.3,29.8-18.6,47-29.8,61.3c-9.6,12.3-20.7,22.9-32.8,31.4c-12.7,9-26.5,16.5-41,22.4
+ c-15.5,6.2-31.6,11.1-47.8,14.6c-30.7,6.9-72.2,7.6-127,2.1C88.1,129.1,12.5,118.3,0,116.5v0.8c12.5,1.8,88,12.6,114.2,15.2
+ c24.3,2.4,46,3.6,65.4,3.6c24.3,0,44.7-1.9,61.9-5.7c16.2-3.5,32.4-8.4,48-14.6c14.6-5.8,28.4-13.4,41.2-22.4
+ c12.2-8.6,23.3-19.2,33-31.6c11.2-14.4,19.6-31.8,30-61.8H392.7z"/>
+ <path class="st1" d="M379,0c-9.9,28.6-18,45.1-28.7,58.9c-9,11.6-19.6,21.8-31.5,30.2c-12.5,8.8-25.8,16.1-39.4,21.6
+ c-14.7,5.9-30.2,10.7-45.9,14.1c-29.4,6.7-69.7,7.3-123.2,1.8C85.6,124,12.3,113.3,0,111.5v0.7c12.2,1.8,85.5,12.5,110.2,15.1
+ c24.2,2.5,45.7,3.7,64.7,3.7c23.1,0,42.6-1.8,58.8-5.6c15.8-3.4,31.3-8.2,46.1-14.1c13.7-5.5,27-12.8,39.6-21.6
+ c12-8.5,22.6-18.7,31.7-30.4c10.8-13.9,18.9-30.6,28.9-59.3H379z"/>
+ <path class="st1" d="M365.1,0c-9.4,27.2-17.1,43.1-27.5,56.5c-8.6,11.1-18.8,20.9-30.2,29c-11.8,8.4-24.6,15.4-37.8,20.8
+ c-13.9,5.6-28.7,10.2-44,13.6c-28.2,6.5-67.3,7-119.5,1.4c-23.9-2.6-93.4-13.1-106-14.9v0.7c12.5,1.9,82,12.3,105.9,14.9
+ c24.1,2.6,45.5,3.9,64.2,3.9c21.9,0,40.3-1.8,55.5-5.3c15.3-3.4,30.2-8,44.2-13.6c13.3-5.4,26.1-12.4,38-20.8
+ c11.5-8.2,21.7-18,30.4-29.2c10.4-13.5,18.2-29.6,27.7-56.9H365.1z"/>
+ <path class="st1" d="M351.6,0c-9,26-16.4,41.2-26.4,54.1c-8.4,10.7-18.1,20.1-28.9,27.8c-11.7,8.3-23.8,15-36.2,20
+ c-13.8,5.5-27.9,9.9-42.1,13.1c-27.3,6.3-65.2,6.7-115.7,1C79.9,113.4,11.8,103,0,101.2v0.7c11.8,1.8,79.8,12.2,102.1,14.8
+ c23.9,2.7,45,4,63.4,4c20.6,0,38-1.7,52.5-5c14.2-3.2,28.5-7.6,42.3-13.1c12.4-5,24.7-11.8,36.4-20c10.9-7.8,20.7-17.2,29.1-28
+ c10.1-13,17.5-28.3,26.6-54.5H351.6z"/>
+ <path class="st1" d="M337.7,0c-8.7,25-15.8,39.6-25.3,51.8c-7.6,9.8-16.9,18.8-27.6,26.6c-10.7,7.7-22.4,14.1-34.5,19.1
+ c-13.3,5.4-26.9,9.7-40.4,12.7c-26.4,6.2-63,6.4-111.9,0.6C76.4,108.2,12,98.1,0,96.2v0.7c12,1.9,76.3,12,97.9,14.6
+ c23.7,2.8,44.4,4.2,62.6,4.2c19.4,0,35.8-1.6,49.5-4.8c13.5-3,27.1-7.3,40.5-12.7c12.2-5,23.9-11.4,34.7-19.1
+ c10.8-7.9,20.1-16.9,27.8-26.8c9.6-12.3,16.7-27.1,25.5-52.2H337.7z"/>
+ <path class="st1" d="M323.7,0c-8.1,23.5-15.1,37.7-24.2,49.3c-7.6,9.7-16.5,18.3-26.4,25.4c-10.1,7.3-21.2,13.5-32.9,18.4
+ c-12.7,5.2-25.6,9.3-38.5,12.2c-25,6-60.3,6.1-108.1,0.2C73.4,103,12.2,93.1,0,91.1v0.6c12.2,2,73.3,11.9,93.5,14.4
+ c23.6,2.9,44.2,4.4,62.1,4.4c18.2,0,33.6-1.5,46.2-4.6c12.9-2.9,25.9-7,38.6-12.2c11.8-4.9,22.9-11.1,33.1-18.4
+ c10-7.2,18.9-15.8,26.6-25.6c9.1-11.6,16.2-26.1,24.4-49.7H323.7z"/>
+ <path class="st1" d="M310.1,0c-7.6,22-14.2,35.6-23,47c-7.3,9.3-15.7,17.4-25.1,24.1c-9.9,7.1-20.5,13-31.3,17.5
+ c-11.8,4.9-24.1,8.8-36.6,11.7c-24,5.8-58.1,5.8-104.4-0.1C70.6,97.8,11.5,87.9,0,86v0.6c11.5,1.9,70.5,11.8,89.6,14.2
+ c23.3,3,43.5,4.5,60.9,4.5c17.2,0,31.7-1.5,43.7-4.4c12.5-2.9,24.8-6.8,36.6-11.7c10.9-4.5,21.5-10.4,31.5-17.5
+ c9.5-6.8,18-14.9,25.3-24.3c8.9-11.5,15.6-25.3,23.1-47.4H310.1z"/>
+ <path class="st1" d="M296.2,0c-7.1,20.7-13.4,33.6-21.9,44.6c-7.1,8.9-15.1,16.6-23.8,22.9c-9.1,6.6-19.1,12.2-29.7,16.8
+ c-10.8,4.5-22.4,8.3-34.7,11.2c-22.8,5.6-55.7,5.4-100.6-0.5C67.4,92.6,11.7,83,0,81v0.6c11.7,2,67.3,11.6,85.4,14.1
+ c23.1,3.1,43.1,4.6,60.1,4.6c16.1,0,29.6-1.4,40.7-4.1c12.2-2.9,23.9-6.7,34.7-11.2c10.6-4.6,20.7-10.2,29.9-16.8
+ c8.8-6.4,16.9-14.1,24-23.1c8.5-11.1,14.9-24.2,22-45H296.2z"/>
+ <path class="st1" d="M282.5,0c-6.7,19.6-12.7,31.8-20.8,42.1c-6.4,8.2-14,15.5-22.5,21.7c-9.1,6.5-18.5,11.8-28.1,15.9
+ c-10.5,4.4-21.5,8.1-32.8,10.8c-21.9,5.5-52.7,5.3-96.9-0.8C64.3,87.3,11.4,77.8,0,75.8v0.6c11.4,2,64.2,11.5,81.3,13.9
+ c23.1,3.2,42.6,4.8,59.1,4.8c15,0,27.6-1.3,38-4c11.3-2.7,22.4-6.4,32.8-10.8c9.6-4,19.1-9.4,28.3-15.9
+ c8.6-6.3,16.2-13.6,22.7-21.9c8.2-10.4,14.3-22.8,20.9-42.5H282.5z"/>
+ <path class="st1" d="M268.8,0c-6.4,18.5-12.1,30-19.7,39.8c-6,7.6-13.1,14.6-21.2,20.5c-8.4,6.1-17.3,11.1-26.4,15.1
+ c-9.8,4.1-20.2,7.5-30.9,10.2c-20.8,5.3-51.2,4.9-93.1-1.2C61.3,82,10.8,72.8,0,70.8v0.5c10.8,2,61.2,11.2,77.4,13.6
+ c22.4,3.3,41.6,4.9,57.7,4.9c14.1,0,25.9-1.2,35.6-3.7c10.7-2.7,21.1-6.1,30.9-10.2c9.2-4,18.1-9.1,26.6-15.1
+ c8.2-6,15.3-13,21.4-20.7c7.7-9.9,13.5-21.6,19.8-40.2H268.8z"/>
+ <path class="st1" d="M255,0c-5.9,17.4-11.3,28.2-18.6,37.4c-6,7.6-12.8,14.1-20.1,19.4c-7.9,5.7-16.2,10.5-24.8,14.3
+ c-9.5,4.1-19.3,7.3-29.1,9.8c-19.6,5.1-48.8,4.6-89.3-1.6C57.9,76.7,10.9,67.8,0,65.7v0.5c10.9,2.1,57.8,11.1,73,13.5
+ c22.2,3.4,41,5.1,56.7,5.1c13,0,23.9-1.2,32.8-3.5c9.8-2.4,19.6-5.7,29.1-9.8c8.7-3.7,17.1-8.6,24.9-14.3
+ c7.3-5.3,14.1-11.9,20.2-19.4c7.4-9.2,12.8-20.2,18.7-37.7H255z"/>
+ <path class="st1" d="M241.1,0c-5.3,15.7-10.5,26.2-17.4,35c-5.4,6.9-11.7,13-18.8,18.2c-6.9,5.1-14.8,9.6-23.3,13.5
+ c-8.7,3.8-17.8,6.9-27.2,9.3c-18.8,5-46.8,4.3-85.5-2C54.2,71.4,10.7,62.7,0,60.5V61c10.6,2.1,54.2,10.9,68.9,13.3
+ c21.7,3.5,40,5.3,55.2,5.3c12,0,22.2-1.1,30.5-3.3c9.4-2.4,18.5-5.5,27.2-9.3c8.5-3.8,16.4-8.4,23.3-13.5
+ c7.1-5.2,13.4-11.3,18.8-18.2c7-8.9,12.2-19.5,17.5-35.3H241.1z"/>
+ <path class="st1" d="M227.4,0c-5,14.7-9.9,24.4-16.3,32.6c-5,6.3-10.9,12-17.5,17c-6.6,4.9-13.8,9.1-21.7,12.6
+ c-8.7,3.8-16.9,6.7-25.3,8.8c-17.7,4.8-44.4,4.1-81.8-2.3C51.3,66.2,10.3,57.6,0,55.5v0.5C10.3,58.1,51.2,66.6,64.8,69
+ c21.3,3.6,39.1,5.4,53.8,5.4c11.2,0,20.6-1.1,28.2-3.2c8.4-2.1,16.6-5,25.3-8.8c7.8-3.5,15.2-7.7,21.7-12.6
+ c6.6-5,12.5-10.7,17.5-17c6.5-8.2,11.4-18.1,16.4-32.9H227.4z"/>
+ <path class="st1" d="M213.6,0c-4.6,13.5-9.1,22.5-15.2,30.2c-4.6,5.7-10,11.1-16.2,15.8c-6.3,4.7-13.1,8.7-20,11.8
+ c-7.6,3.4-15.5,6.2-23.5,8.4c-16.7,4.7-42.2,3.8-78-2.8C47.9,60.9,9.9,52.6,0,50.4v0.4C9.9,53,47.8,61.3,60.7,63.7
+ c20.9,3.8,38.3,5.7,52.5,5.7c10.2,0,18.7-1,25.7-2.9c8-2.2,15.9-5,23.5-8.4c6.9-3.1,13.7-7.1,20-11.8c6.2-4.7,11.6-10.1,16.2-15.8
+ c6.2-7.7,10.7-16.9,15.3-30.5H213.6z"/>
+ <path class="st1" d="M199.9,0c-4,12-8.2,20.3-14.1,27.8c-4.3,5.4-9.3,10.2-15,14.5c-5.7,4.3-11.8,8-18.4,11
+ c-6.7,3.1-13.9,5.7-21.5,7.9c-15.6,4.6-39.9,3.5-74.2-3.1C44.5,55.6,9.4,47.5,0,45.3v0.4C9.4,47.9,44.4,56,56.7,58.4
+ c20.4,3.9,37.2,5.9,50.8,5.9c9.4,0,17.2-0.9,23.6-2.8c7.6-2.2,14.8-4.8,21.5-7.9c6.5-3,12.7-6.7,18.4-11c5.7-4.2,10.7-9.1,15-14.5
+ c6-7.6,10.2-16,14.2-28H199.9z"/>
+ <path class="st1" d="M186.1,0c-3.7,11-7.7,18.8-13.1,25.4c-4.1,5.1-8.7,9.5-13.7,13.3c-5.2,4-10.8,7.4-16.8,10.2
+ c-5.9,2.8-12.3,5.2-19.6,7.4c-14.5,4.3-37.6,3.2-70.5-3.5C40.8,50.2,8.9,42.4,0,40.2v0.4c8.9,2.2,40.7,10,52.5,12.5
+ c19.9,4.1,36.3,6.1,49.3,6.1c8.5,0,15.6-0.9,21.4-2.6c7.3-2.2,13.8-4.6,19.6-7.4c6-2.8,11.6-6.2,16.8-10.2
+ c5-3.8,9.7-8.2,13.7-13.3C178.7,19,182.8,11,186.5,0H186.1z"/>
+ <path class="st1" d="M172.2,0c-3.2,9.6-6.9,16.7-11.9,22.9c-3.6,4.5-7.8,8.6-12.4,12.1c-4.8,3.7-9.9,6.9-15.2,9.4
+ c-5.6,2.7-11.5,5.1-17.8,6.9c-13.5,4.2-36,2.9-66.7-3.9C36.9,44.8,8.7,37.4,0,35.1v0.4c8.6,2.3,36.8,9.7,48.3,12.2
+ c19,4.2,34.9,6.3,47.5,6.3c7.7,0,14.2-0.8,19.4-2.4c6.2-1.8,12.2-4.2,17.8-6.9c5.2-2.5,10.4-5.7,15.2-9.4
+ c4.6-3.5,8.8-7.6,12.4-12.1c5-6.2,8.7-13.4,11.9-23.1H172.2z"/>
+ <path class="st1" d="M158.4,0c-2.3,7.4-5.9,14.3-10.8,20.5c-3.4,4.2-7.2,7.8-11.1,10.9c-3.9,3.1-8.5,5.9-13.6,8.5
+ c-5.5,2.7-10.7,4.8-15.9,6.4c-12.6,4.1-33.8,2.7-62.9-4.3C33,39.3,8.1,32.3,0,30v0.3c8,2.3,33,9.3,44.2,11.9
+ c18.4,4.4,33.6,6.6,45.5,6.6c7,0,12.9-0.8,17.6-2.3c5.2-1.6,10.4-3.7,15.9-6.4c5.1-2.5,9.7-5.4,13.6-8.5
+ c3.9-3.1,7.7-6.7,11.1-10.9c4.9-6.2,8.5-13.2,10.8-20.7H158.4z"/>
+ <path class="st1" d="M144.8,0c-2,6.6-5.3,12.7-9.7,18.1c-2.8,3.5-6,6.6-9.8,9.7c-3.9,3-7.9,5.6-12,7.7c-4.6,2.3-9.3,4.3-14,5.9
+ c-11.4,3.9-31.3,2.4-59.1-4.6C29.2,33.9,7.4,27.2,0,24.9v0.3c7.4,2.3,29.1,9,40.2,11.9c17.9,4.5,32.4,6.7,43.6,6.7
+ c6.3,0,11.5-0.7,15.6-2.1c4.7-1.6,9.4-3.6,14-5.9c4-2.1,8.1-4.7,12-7.7c3.8-3.1,7-6.3,9.8-9.7c4.4-5.4,7.7-11.6,9.7-18.3H144.8z"
+ />
+ <path class="st1" d="M130.7,0c-1.8,5.8-4.8,11.2-8.6,15.6c-2.7,3.2-5.5,6-8.6,8.5c-3,2.5-6.5,4.8-10.3,6.9
+ c-4.3,2.3-8.2,4.1-12,5.5c-10.5,3.9-29.2,2.2-55.4-5C24.2,28.3,6.8,22.3,0,19.9v0.3c6.8,2.4,24.1,8.4,35.8,11.6
+ c17.1,4.7,31,7,41.5,7c5.7,0,10.3-0.7,14-2c3.8-1.4,7.7-3.2,12-5.5c3.8-2.1,7.3-4.4,10.3-6.9c3.1-2.5,5.9-5.3,8.6-8.5
+ c3.8-4.5,6.8-10,8.6-15.8H130.7z"/>
+ <path class="st1" d="M118.5,0c-1.9,6.1-6,12.3-11.7,17.8c-6,5.7-13.5,10.4-21.9,13.7c-6.6,2.6-21,3.8-51.6-5.4
+ C26.8,24.2,5.6,16.4,0,14.3v0.3c5.6,2.1,26.8,9.8,33.2,11.8c18.3,5.5,30.8,7.3,39.3,7.3c5.8,0,9.8-0.8,12.5-1.9
+ c8.4-3.3,15.9-8,22-13.8c5.8-5.6,9.9-11.8,11.8-18H118.5z"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Airy/08.svg b/addons/web_editor/static/shapes/Airy/08.svg
new file mode 100644
index 00000000..df544b4d
--- /dev/null
+++ b/addons/web_editor/static/shapes/Airy/08.svg
@@ -0,0 +1,28 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+<style type="text/css">
+ .st0{opacity:0.15;}
+ .st1{fill:#3AADAA;}
+ @media only screen and (max-width: 300px) {
+ .st0{opacity:.7;}
+ }
+</style>
+<g class="st0">
+ <polygon class="st1" points="1400,188.7 1400,183.7 453.7,299.4 452.9,299.5 138.3,569.6 138.6,570 144.4,570 455.1,304.3"/>
+ <polygon class="st1" points="504.1,315.1 503.4,315.1 208.3,569.5 208.5,570 214.3,570 505.4,319.9 1400,209.9 1400,204.9"/>
+ <polygon class="st1" points="553.7,330.9 279.8,568.2 278.3,569.5 278.5,570 284.1,570 555.7,335.5 1400,231 1400,226.1"/>
+ <polygon class="st1" points="604.8,346.4 604.1,346.5 348.3,569.5 348.6,570 354.1,570 606.1,351.1 1400,252 1400,247.2"/>
+ <polygon class="st1" points="655.2,362.1 654.5,362.2 418.2,569.6 418.5,570 423.9,570 656.5,366.6 1400,273.2 1400,268.4"/>
+ <polygon class="st1" points="705.6,377.8 704.9,377.9 488.2,569.6 488.5,570 493.8,570 706.9,382.3 1400,294.2 1400,289.5"/>
+ <polygon class="st1" points="755.9,393.4 755.2,393.5 558.3,569.5 558.6,570 563.7,570 757.2,397.9 1400,315.3 1400,310.7"/>
+ <polygon class="st1" points="806.3,409.1 805.6,409.2 628.2,569.5 628.5,570 633.5,570 807.6,413.4 1400,336.3 1400,331.8"/>
+ <polygon class="st1" points="856.7,424.8 856,424.9 698.2,569.5 698.5,570 703.4,570 858,429.1 1400,357.5 1400,353"/>
+ <polygon class="st1" points="907.1,440.4 906.4,440.5 768.3,569.6 768.5,570 773.3,570 908.4,444.7 1400,378.5 1400,374.1"/>
+ <polygon class="st1" points="957.5,456.1 956.8,456.2 838.3,569.5 838.5,570 843.2,570 958.8,460.2 1400,399.6 1400,395.3"/>
+ <polygon class="st1" points="1007.8,471.8 1007.1,471.9 908.3,569.6 908.4,570 913,570 1009.1,475.9 1400,420.8 1400,416.5"/>
+ <polygon class="st1" points="1058.2,487.5 1057.5,487.6 978.1,569.1 978.5,570 983,570 1059.5,491.4 1400,441.8 1400,437.6"/>
+ <polygon class="st1" points="1108.6,503.1 1107.9,503.2 1048.1,569.1 1048.5,570 1052.8,570 1109.9,507 1400,462.9 1400,458.8"/>
+ <polygon class="st1" points="1158.9,518.7 1158.1,518.9 1118.3,569.7 1118.4,570 1122.7,570 1160.3,522.5 1400,484 1400,479.9"/>
+ <polygon class="st1" points="1208.3,534.6 1188.4,569.9 1188.5,570 1192.6,570 1210.9,538.2 1400,505.1 1400,501.1"/>
+ <polygon class="st1" points="1255.6,553.3 1258.5,570 1262.5,570 1260.2,555.4 1400,526.4 1400,522.3"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Airy/09.svg b/addons/web_editor/static/shapes/Airy/09.svg
new file mode 100644
index 00000000..d651c4cf
--- /dev/null
+++ b/addons/web_editor/static/shapes/Airy/09.svg
@@ -0,0 +1,28 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+<style type="text/css">
+ .st0{opacity:0.15;}
+ .st1{fill:#3AADAA;}
+ @media only screen and (max-width: 300px) {
+ .st0{opacity:.7;}
+ }
+</style>
+<g class="st0">
+ <polygon class="st1" points="144.4,0 138.6,0 449.8,515.9 0,567.5 0,572.5 458.2,519.9"/>
+ <polygon class="st1" points="363,250.8 214.3,0 208.5,0 358.8,253.3 498.6,499.1 0,540.6 0,545.6 506.8,503.3"/>
+ <polygon class="st1" points="555.3,486.7 425.4,242.9 284.1,0 278.5,0 421.2,245.2 547.5,482.3 0,513.9 0,518.7 551.5,486.9"/>
+ <polygon class="st1" points="603.9,470.2 487.8,235 354.1,0 348.6,0 483.6,237.2 596.3,465.6 0,487 0,487 0,491.8 600.2,470.3"/>
+ <polygon class="st1" points="652.4,453.5 550.2,227 423.9,0 418.5,0 546,229.1 645.2,448.9 0,460.1 0,464.9 648.8,453.6"/>
+ <polygon class="st1" points="612.6,219.1 493.8,0 488.5,0 608.4,221 694.1,432.2 0,433.4 0,438 700.9,436.8"/>
+ <polygon class="st1" points="675.2,211.2 563.7,0 558.5,0 670.9,212.9 742.9,415.6 0,404.7 0,404.7 0,409.3 749.5,420.2"/>
+ <polygon class="st1" points="797.9,403.5 737.6,203.3 633.5,0 628.5,0 733.3,204.7 791.9,398.9 0,379.8 0,379.7 0,384.2 794.8,403.4"/>
+ <polygon class="st1" points="799.9,195.4 799.9,195.1 703.4,0 698.5,0 795.7,196.7 840.8,382.2 0,352.9 0,357.3 846.4,386.8"/>
+ <polygon class="st1" points="894.9,370.1 862.3,187.5 862.3,187.2 773.3,0 768.5,0 858.1,188.6 889.7,365.5 0,326 0,326 0,330.4 892.2,370"/>
+ <polygon class="st1" points="924.7,179.6 924.7,179.2 843.2,0 838.5,0 920.5,180.4 938.6,348.9 0,299.3 0,299.3 0,303.5 943.4,353.5"/>
+ <polygon class="st1" points="987.1,171.3 913,0 908.4,0 982.9,172.3 987.5,332.3 0,272.4 0,276.6 989.6,336.6 991.9,336.7"/>
+ <polygon class="st1" points="983,0 978.5,0 1045.3,164.1 1036.5,315.6 0,245.5 0,249.7 1038.3,319.9 1040.3,320 1049.5,163.5"/>
+ <polygon class="st1" points="1052.8,0 1048.5,0 1107.7,156 1085.4,298.9 0,218.8 0,222.8 1087,303.1 1088.8,303.3 1111.9,155.6"/>
+ <polygon class="st1" points="1122.7,0 1118.4,0 1170.1,147.8 1134.3,282.4 0,191.9 0,195.9 1135.6,286.5 1137.3,286.6 1174.1,148.2 1174.3,147.6"/>
+ <polygon class="st1" points="1192.6,0 1188.5,0 1232.6,139.6 1183.2,265.7 0,165.1 0,169.1 1184.3,269.8 1185.8,269.9 1236.8,139.8"/>
+ <polygon class="st1" points="1262.5,0 1258.5,0 1295,131.5 1232.1,249.1 0,138.3 0,142.1 1233,253 1234.3,253.1 1298.8,132.6 1299.2,131.9"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Airy/10.svg b/addons/web_editor/static/shapes/Airy/10.svg
new file mode 100644
index 00000000..ebe1d99c
--- /dev/null
+++ b/addons/web_editor/static/shapes/Airy/10.svg
@@ -0,0 +1,46 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+<style type="text/css">
+ .st0{opacity:0.20;}
+ .st1{fill:#383E45;}
+ @media only screen and (max-width: 300px) {
+ .st0{opacity:.7;}
+ }
+</style>
+<g class="st0">
+ <polygon class="st1" points="1394.8,0 0,570 0,570 1400,0"/>
+ <polygon class="st1" points="0,570 0,570 1400,21.2 1400,19"/>
+ <polygon class="st1" points="0,570 0,570 1400,43.1 1400,41"/>
+ <polygon class="st1" points="0,570 0,570 1400,65.8 1400,63.7"/>
+ <polygon class="st1" points="0,570 0,570 1400,89.2 1400,87.1"/>
+ <polygon class="st1" points="1400,5 1378.1,6.8 1378.2,8.8 1400,7"/>
+ <polygon class="st1" points="0,570 0,570 1400,113.4 1400,111.3"/>
+ <rect x="1352.9" y="17.1" class="st1" width="47.1" height="2"/>
+ <polygon class="st1" points="1399.8,137.8 0,570 0,570 1400,139.9 1400,138.6"/>
+ <polygon class="st1" points="1400,33.1 1322.5,29.5 1322.4,31.5 1400,35.1"/>
+ <polygon class="st1" points="0,570 0,570 1400,164.5 1400,162.4"/>
+ <polygon class="st1" points="1400,55 1294.4,42.9 1294.6,40.9 1400,53"/>
+ <polygon class="st1" points="0,570 0,570 1400,191.5 1400,189.4"/>
+ <polygon class="st1" points="1400,79.1 1266.3,54.3 1266.7,52.3 1400,77"/>
+ <polygon class="st1" points="0,570 0,570 1400,219.5 1400,217.4"/>
+ <polygon class="st1" points="1238.6,63.8 1238.1,65.7 1400,107.3 1400,105.3"/>
+ <polygon class="st1" points="0,570 0,570 1400,248.5 1400,246.4"/>
+ <polygon class="st1" points="1209.8,75.1 1209.2,77 1399.8,139.7 1400,139 1400,137.7"/>
+ <polygon class="st1" points="0,570 0,570 1400,278.6 1400,276.6"/>
+ <polygon class="st1" points="1181.5,86.7 1180.7,88.6 1400,176.7 1400,174.6"/>
+ <polygon class="st1" points="0,570 0,570 1400,310 1400,308"/>
+ <polygon class="st1" points="1153.1,98.4 1152.3,100.2 1400,218.3 1400,216"/>
+ <polygon class="st1" points="0,570 0,570 1400,342.6 1400,340.5"/>
+ <polygon class="st1" points="1126.1,109.8 1125.1,111.6 1400,264.3 1400,262"/>
+ <polygon class="st1" points="0,570 0,570 1400,376.5 1400,374.5"/>
+ <polygon class="st1" points="1097.6,121.4 1096.6,123.1 1400,315.1 1400,312.8"/>
+ <polygon class="st1" points="0,570 0,570 1400,412 1400,410"/>
+ <polygon class="st1" points="1069.3,133.1 1068.1,134.7 1400,371 1400,368.5"/>
+ <polygon class="st1" points="0,570 0,570 1400,448.9 1400,446.9"/>
+ <polygon class="st1" points="1041,144.4 1039.7,146 1400,431.6 1400,429.1"/>
+ <polygon class="st1" points="0,570 0,570 1400,487.4 1400,485.4"/>
+ <polygon class="st1" points="1012.5,156 1011.2,157.6 1400,497.6 1400,494.9"/>
+ <polygon class="st1" points="0,570 0,570 1400,527.7 1400,525.7"/>
+ <polygon class="st1" points="983.1,169.1 1400,570 1400,567.2 984.5,167.7"/>
+ <polygon class="st1" points="0,570 1400,569 1400,570"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Airy/11.svg b/addons/web_editor/static/shapes/Airy/11.svg
new file mode 100644
index 00000000..84eb78af
--- /dev/null
+++ b/addons/web_editor/static/shapes/Airy/11.svg
@@ -0,0 +1,65 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+<style type="text/css">
+ .st0{opacity:0.20;}
+ .st1{fill:#383E45;}
+ @media only screen and (max-width: 300px) {
+ .st0{opacity:.7;}
+ }
+</style>
+<g class="st0">
+ <polygon class="st1" points="1400,1 0,0 0,0 1400,0"/>
+ <polygon class="st1" points="1400,0 1397.4,0 0,570 1400,1.1"/>
+ <polygon class="st1" points="1400,62.8 0,0 0,0 1400,60.8"/>
+ <polygon class="st1" points="1399.7,60.9 0,570 1400,62.9 1400,61.8"/>
+ <polygon class="st1" points="0,0 1399.9,126.1 1400,124.9 1400,124.1"/>
+ <polygon class="st1" points="1399.7,124.1 0,570 1400,126.1 1400,125.1"/>
+ <polygon class="st1" points="0,0 1399.9,189.5 1400,188.6 1400,187.5"/>
+ <polygon class="st1" points="1399.7,187.5 0,570 1400,189.5 1400,188.5"/>
+ <polygon class="st1" points="0,0 1399.8,252.8 1400,251.8 1400,250.8"/>
+ <polygon class="st1" points="1399.8,250.8 0,570 1400,252.8 1400,251.8"/>
+ <polygon class="st1" points="0,0 1399.8,316.1 1400,315.1 1400,314.1"/>
+ <polygon class="st1" points="1399.8,314.1 0,570 1400,316.1 1400,315.1"/>
+ <polygon class="st1" points="0,0 1399.7,379.4 1400,378.4 1400,377.4"/>
+ <polygon class="st1" points="1399.9,377.4 0,570 1400,379.4 1400,378.3"/>
+ <polygon class="st1" points="1399.7,443 0,0 0,0 1400.3,441"/>
+ <polygon class="st1" points="1399.9,441 0,570 1400.1,443"/>
+ <polygon class="st1" points="0,0 1399.7,505.9 1400,505 1400,503.9"/>
+ <polygon class="st1" points="1400,504 0,570 1400,506 1400,504.8"/>
+ <polygon class="st1" points="0,0 1399.6,569.9 1400,569.1 1400,567.9"/>
+ <polygon class="st1" points="0,570 1400,570 1400,569"/>
+ <polygon class="st1" points="1394.8,-1421.9 0,-851.9 0,-851.9 1400,-1421.9"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-1400.7 1400,-1402.9"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-1378.8 1400,-1380.9"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-1356.1 1400,-1358.2"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-1332.7 1400,-1334.8"/>
+ <polygon class="st1" points="1400,-1416.9 1378.1,-1415.1 1378.2,-1413.1 1400,-1414.9"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-1308.5 1400,-1310.6"/>
+ <polygon class="st1" points="1399.8,-1284.1 0,-851.9 0,-851.9 1400,-1282 1400,-1283.3"/>
+ <polygon class="st1" points="1400,-1388.8 1322.5,-1392.4 1322.4,-1390.4 1400,-1386.8"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-1257.4 1400,-1259.5"/>
+ <polygon class="st1" points="1400,-1366.9 1294.4,-1379 1294.6,-1381 1400,-1368.9"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-1230.4 1400,-1232.5"/>
+ <polygon class="st1" points="1400,-1342.8 1266.3,-1367.6 1266.7,-1369.6 1400,-1344.9"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-1202.4 1400,-1204.5"/>
+ <polygon class="st1" points="1238.6,-1358.1 1238.1,-1356.2 1400,-1314.6 1400,-1316.6"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-1173.4 1400,-1175.5"/>
+ <polygon class="st1" points="1209.8,-1346.8 1209.2,-1344.9 1399.8,-1282.2 1400,-1282.9 1400,-1284.2"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-1143.3 1400,-1145.3"/>
+ <polygon class="st1" points="1181.5,-1335.2 1180.7,-1333.3 1400,-1245.2 1400,-1247.3"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-1111.9 1400,-1113.9"/>
+ <polygon class="st1" points="1153.1,-1323.5 1152.3,-1321.7 1400,-1203.6 1400,-1205.9"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-1079.3 1400,-1081.4"/>
+ <polygon class="st1" points="1126.1,-1312.1 1125.1,-1310.3 1400,-1157.6 1400,-1159.9"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-1045.4 1400,-1047.4"/>
+ <polygon class="st1" points="1097.6,-1300.5 1096.6,-1298.8 1400,-1106.8 1400,-1109.1"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-1009.9 1400,-1011.9"/>
+ <polygon class="st1" points="1069.3,-1288.8 1068.1,-1287.2 1400,-1050.9 1400,-1053.4"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-973 1400,-975"/>
+ <polygon class="st1" points="1041,-1277.5 1039.7,-1275.9 1400,-990.3 1400,-992.8"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-934.5 1400,-936.5"/>
+ <polygon class="st1" points="1012.5,-1265.9 1011.2,-1264.3 1400,-924.3 1400,-927"/>
+ <polygon class="st1" points="0,-851.9 0,-851.9 1400,-894.2 1400,-896.2"/>
+ <polygon class="st1" points="983.1,-1252.8 1400,-851.9 1400,-854.7 984.5,-1254.2"/>
+ <polygon class="st1" points="0,-851.9 1400,-852.9 1400,-851.9"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Airy/12.svg b/addons/web_editor/static/shapes/Airy/12.svg
new file mode 100644
index 00000000..b2b2430d
--- /dev/null
+++ b/addons/web_editor/static/shapes/Airy/12.svg
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1400 1400" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#F6F6F6;}
+ .st1{opacity:0.24;fill:none;stroke:#3AADAA;stroke-width:1.9819;enable-background:new ;}
+ .st2{opacity:0.24;fill:none;stroke:#3AADAA;stroke-width:1.982;enable-background:new ;}
+ .st3{fill:none;stroke:#3AADAA;stroke-width:1.982;}
+</style>
+<g>
+ <path class="st0" d="M350,51.4c152.8,0,350,44.3,350,44.3S897.2,140,1050,140c148.9,0,339.8-42,349.6-44.2V0H0.4v95.6
+ C10.2,93.4,201.1,51.4,350,51.4z"/>
+ <path class="st1" d="M0,95.2c0,0,197.2-44.5,350-44.5s350,44.5,350,44.5s197.2,44.5,350,44.5s350-44.5,350-44.5"/>
+ <path class="st2" d="M0,95.2c0,0,197.2-22.3,350-22.3s350,22.3,350,22.3s197.2,22.3,350,22.3s350-22.3,350-22.3"/>
+ <path class="st2" d="M0,95.2c0,0,197.2-89.1,350-89.1s350,89.1,350,89.1s197.2,89.1,350,89.1s350-89.1,350-89.1"/>
+ <path class="st3" d="M0,95.2c0,0,197.2-66.8,350-66.8s350,66.8,350,66.8S897.2,162,1050,162s350-66.8,350-66.8"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Airy/13.svg b/addons/web_editor/static/shapes/Airy/13.svg
new file mode 100644
index 00000000..113f0196
--- /dev/null
+++ b/addons/web_editor/static/shapes/Airy/13.svg
@@ -0,0 +1,38 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 -1022.8 1400 1400">
+<style type="text/css">
+ .st0{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
+ .st1{opacity:0.23;fill:none;stroke:#3AADAA;stroke-width:0.75;enable-background:new ;}
+ .st2{fill:none;stroke:#3AADAA;stroke-width:0.8749;}
+ .st3{opacity:0.23;fill:none;stroke:#3AADAA;stroke-width:0.75;stroke-dasharray:8.7492;enable-background:new ;}
+</style>
+<g>
+ <path class="st0" d="M0,339.9c28.6,19.4,62.4,34.8,115.8,34.8c145.8,0,145.8-51.2,291.6-51.2s145.8,52.4,291.6,52.4
+ s145.8-92.3,291.6-92.3s145.8,89,291.6,89c54.9,0,88.4-4.6,117.5-10.3l0.4,15.6L0,377.8V339.9z"/>
+ <path class="st1" d="M0,329c28.6,25.3,62.4,45.3,115.8,45.3c145.8,0,145.8-66.8,291.6-66.8s145.8,68.2,291.6,68.2
+ s145.8-120.2,291.7-120.2s145.8,116,291.6,116c54.8,0,88.4-6,117.5-13.4"/>
+ <path class="st2" d="M0,328.2c28.6,25.1,62.4,44.9,115.8,44.9c145.8,0,145.8-74.2,291.6-74.2S553.2,375,699.1,375
+ s145.8-129.4,291.7-129.4s145.8,120.6,291.6,120.6c54.6,0,88.5-8.3,117.5-18.8"/>
+ <path class="st1" d="M0,327.2c28.6,24.9,62.4,44.5,115.8,44.5c145.8,0,145.8-81.5,291.6-81.5s145.8,83.9,291.6,83.9
+ s145.8-138.5,291.7-138.5s145.8,125,291.6,125c54.9,0,88.4-10.9,117.5-24.4"/>
+ <path class="st3" d="M0,326.9c28.6,24.8,62.4,44.4,115.8,44.4c145.8,0,145.8-89.4,291.6-89.4s145.8,92.2,291.6,92.2
+ s145.8-148.4,291.7-148.4s145.8,130.2,291.6,130.2c54.6,0,88.5-13.2,117.5-29.8"/>
+ <path class="st1" d="M0,325.9c28.6,24.6,62.4,44,115.8,44c145.8,0,145.8-96.8,291.6-96.8s145.9,100,291.7,100
+ s145.8-157.5,291.6-157.5s145.8,134.8,291.6,134.8c54.7,0,88.4-15.7,117.5-35.4"/>
+ <path class="st1" d="M0,325.7c28.6,24.5,62.4,43.8,115.8,43.8c145.8,0,145.8-104.5,291.6-104.5s145.9,108.2,291.7,108.2
+ s145.8-167.1,291.6-167.1s145.8,139.7,291.6,139.7c54.4,0,88.6-18,117.5-40.5"/>
+ <path class="st1" d="M0,324.7c28.6,24.2,62.5,43.5,115.8,43.5c145.8,0,145.8-112,291.6-112s145.8,116.2,291.6,116.2
+ s145.8-176.4,291.6-176.4s145.8,144.4,291.6,144.4c54.8,0,88.4-20.7,117.5-46.5"/>
+ <path class="st1" d="M0,324.6c28.6,24.1,62.4,43.1,115.8,43.1c145.8,0,145.8-119.5,291.6-119.5s145.9,124.2,291.7,124.2
+ s145.8-185.7,291.6-185.7s145.8,149.1,291.6,149.1c54.5,0,88.5-22.9,117.5-51.5"/>
+ <path class="st1" d="M0,323.6c28.6,23.9,62.4,42.8,115.8,42.8c145.8,0,145.8-127,291.6-127s145.8,132,291.6,132
+ s145.8-195,291.6-195s145.8,153.8,291.6,153.8c54.7,0,88.5-25.5,117.5-57.3"/>
+ <path class="st1" d="M0,322.6c28.6,23.7,62.4,42.5,115.8,42.5c145.8,0,145.8-134.5,291.6-134.5s145.8,140,291.6,140
+ s145.8-204.3,291.7-204.3s145.8,158.5,291.6,158.5c54.6,0,88.5-27.8,117.5-62.5"/>
+ <path class="st1" d="M0,321.6c28.6,23.6,62.4,42.1,115.8,42.1c145.8,0,145.8-142,291.6-142s145.9,148,291.7,148
+ s145.8-213.6,291.6-213.6s145.8,163.2,291.6,163.2c54.4,0,88.6-31.5,117.5-71"/>
+ <path class="st1" d="M0,321.7c28.6,23.4,62.4,42,115.8,42c145.8,0,145.7-164.8,291.5-164.8s146,171.3,291.8,171.3
+ s146-250.2,291.8-250.2S1136.6,315,1282.4,315c54.4,0,88.6-40,117.5-90.2"/>
+ <path class="st1" d="M0,291.8c28.6,38.1,62.4,68.3,115.8,68.3c145.9,0,145.9-243.9,291.7-243.9s145.8,254.4,291.6,254.4
+ S844.9,6.8,990.7,6.8s145.8,274,291.6,274c54.6,0,88.5-53.2,117.5-119.8"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Airy/14.svg b/addons/web_editor/static/shapes/Airy/14.svg
new file mode 100644
index 00000000..0878f18b
--- /dev/null
+++ b/addons/web_editor/static/shapes/Airy/14.svg
@@ -0,0 +1,58 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1400 570">
+<style type="text/css">
+ .st0{fill:url(#SVGID_1_);}
+ .st1{fill:url(#SVGID_2_);}
+ .st2{fill:url(#SVGID_3_);}
+ .st3{fill:url(#SVGID_4_);}
+ .st4{opacity:0.15;}
+ .st5{fill:url(#SVGID_5_);}
+ .st6{fill:#3AADAA;}
+</style>
+<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="1169.5483" y1="357.8093" x2="1238.7761" y2="517.4799">
+ <stop offset="0.3242" style="stop-color:#3AADAA;stop-opacity:0.1"/>
+ <stop offset="0.9693" style="stop-color:#FFFFFF;stop-opacity:0"/>
+</linearGradient>
+<path class="st0" d="M1216,510.2c80.5-14.2,182-70.1,184-71.2V319c-59.6,55-214.8,138.3-300.1,173c-24.2,8.1-58.9,20.2-106.6,29.7
+ C1055.2,526.8,1127.1,525.9,1216,510.2z"/>
+<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="412.4055" y1="490.2253" x2="419.1031" y2="625.8529">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1"/>
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
+</linearGradient>
+<path class="st1" d="M299.9,561.1c92.4,0,211.5-37.8,309.7-59.9c-47.9-10.4-91.6-17.2-135.3-9.8c-61.6,10.5-109.8,31.6-174.4,49.3
+ c-24.6,6.8-52.2,12.8-80.8,17.6C245.7,560,273,561.1,299.9,561.1z"/>
+<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="1194.8689" y1="171.3124" x2="1138.776" y2="484.428">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1"/>
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
+</linearGradient>
+<path class="st2" d="M1216,510.2c78.7-13.9,177.7-67.8,184-71.2V242.6c-80.5-24-217.1-23.9-300.1,29.2
+ c-99.1,63.5-144,152.1-218.1,234.2C967.4,521.6,1068.2,536.3,1216,510.2z"/>
+<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="1195.4164" y1="270.6626" x2="1069.0134" y2="521.7943">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1"/>
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
+</linearGradient>
+<path class="st3" d="M1216,510.2c80.8-14.3,182.7-70.5,184-71.2V383c-80.3-11.2-171.5-58-300.1-36c-110.7,19.8-203.6,81-304.7,143.9
+ C902,506.2,1016,545.5,1216,510.2z"/>
+<path class="st4 st6" d="M1216,510.2c-88.9,15.7-160.8,16.6-222.7,11.5c-39.7,7.9-88.5,14-147.7,14.5c-94.6,0.7-169.7-20.5-236.6-34.9
+ c-98.1,22.2-216.8,59.8-309.1,59.8C155.5,561.1,0,530.8,0,530.8V570h1400V439C1400,439,1297.3,495.9,1216,510.2z"/>
+<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="804.8035" y1="458.3308" x2="798.1058" y2="556.5632">
+ <stop offset="0.3242" style="stop-color:#3AADAA;stop-opacity:0.1"/>
+ <stop offset="0.9693" style="stop-color:#FFFFFF;stop-opacity:0"/>
+</linearGradient>
+<path class="st5" d="M845.6,536.2c59.3-0.5,108-6.6,147.7-14.5c-118.6-9.9-200.1-42.1-293.4-35.5c-26.7,1.9-57.6,7.6-90.9,15.1
+ C675.9,515.7,751,536.9,845.6,536.2z"/>
+<path class="st6" d="M1250.9,228c-76.7,5.1-124.5,26.5-151.1,43.6c-67.6,43.3-109.4,97.6-153.7,155c-37.7,48.9-76,98.8-132,143.4
+ h0.8c55.8-44.5,94.1-94.3,131.6-143.1c44.2-57.4,86-111.6,153.5-154.9c26.6-17,74.3-38.4,150.8-43.5c52.4-3.5,106.8,1.7,149.1,14.3
+ v-0.5C1357.6,229.7,1303.3,224.5,1250.9,228z"/>
+<path class="st6" d="M1325.6,366.3c-63.3-16.4-135-35-225.7-19.5C996,365.4,907.5,420.6,813.8,479c-49.7,31-100.7,62.9-156.5,91h1.1
+ c55.5-28,106.2-59.7,155.6-90.6C907.7,421,996.1,365.8,1100,347.2c90.6-15.5,162.3,3.1,225.5,19.5c26.1,6.8,50.9,13.2,74.5,16.5
+ v-0.5C1376.4,379.4,1351.7,373,1325.6,366.3z"/>
+<path class="st6" d="M299.9,561.3C157.1,561.3,1.6,531.5,0,531.2v-0.5c1.6,0.3,157.2,30.1,299.9,30.1c69.3,0,152.3-20.9,232.7-41.2
+ c62.2-15.7,121-30.5,167.3-33.8c54.5-3.8,103.8,5.4,161,16c91.4,17,195.1,36.3,355,8.1c80.4-14.2,183-70.6,184-71.2v0.6
+ c-1,0.6-103.5,57-184,71.1c-71.3,12.6-138,16.8-203.8,12.8c-57.5-3.5-105.3-12.3-151.4-20.9c-57.1-10.6-106.5-19.8-160.9-16
+ c-46.2,3.3-105,18.1-167.2,33.7C452.3,540.4,369.2,561.3,299.9,561.3z"/>
+<path class="st6" d="M1400,318.8c-27,24.9-76.9,58.4-140.5,94.1c-55.8,31.4-115.5,60.9-159.7,78.9c-1.7,0.6-3.5,1.2-5.3,1.8
+ C1048.3,509,971,535,845.6,535.9c-1.5,0-3,0-4.5,0c-77.7,0-142.5-14.7-199.9-27.7c-61.7-14-114.9-26.1-168.3-16.9
+ c-33.7,5.8-63.1,15.3-97.1,26.3c-27.9,9-59.6,19.3-99.4,29.2c-69.8,17.4-155.7,21.9-218.1,23.2h19.3c60.1-1.9,136-7,198.9-22.8
+ c39.8-9.9,71.4-20.2,99.4-29.2c34-11,63.4-20.5,97-26.2c53.2-9.1,106.5,3,168.1,16.9c57.4,13,122.2,27.7,200,27.7c1.5,0,3,0,4.5,0
+ c125.5-1,202.9-26.9,249.1-42.4c1.8-0.6,3.6-1.2,5.3-1.8c44.2-18,103.9-47.5,159.7-78.9c63.6-35.8,113.2-69,140.2-93.9V318.8z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blobs/01.svg b/addons/web_editor/static/shapes/Blobs/01.svg
new file mode 100644
index 00000000..aed679ca
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blobs/01.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <path d="M0,0V570l378.64-32.77a1236.91,1236.91,0,0,0,644-249.06L1400,0Z" style="fill: #7c6576"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blobs/02.svg b/addons/web_editor/static/shapes/Blobs/02.svg
new file mode 100644
index 00000000..c1eb92fd
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blobs/02.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <path d="M369.43,124.68C336.43,27.2,97.76-8.32,0,50.19V460.86L233.22,338c76.51-39,129.15-86.89,139.11-158.09C375.06,160.37,371.82,131.73,369.43,124.68Z" style="fill: #3aadaa"/>
+ <path d="M700,570c-26.9-67.52-45.18-140.6-37.8-206.5,4.38-39.07,49.34-104.23,104.22-141.5A507.52,507.52,0,0,1,891.2,160.73C1053,106.87,1225.58,79,1400,78.86h0V570Z" style="fill: #7c6576"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blobs/03.svg b/addons/web_editor/static/shapes/Blobs/03.svg
new file mode 100644
index 00000000..30cbd261
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blobs/03.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <path d="M1400,0H700c25.13,63.93,57.24,122.07,81.46,162C802.08,196,898.24,327.49,982,391.43,1154,522.79,1400,570,1400,570Z" style="fill: #7c6576"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blobs/04.svg b/addons/web_editor/static/shapes/Blobs/04.svg
new file mode 100644
index 00000000..abfce69e
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blobs/04.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+ <path d="M840,285C840,128.33,710.82,1.19,560,0H0L0,570H560C710.82,568.81,840,441.67,840,285Z" style="fill: #383e45"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blobs/05.svg b/addons/web_editor/static/shapes/Blobs/05.svg
new file mode 100644
index 00000000..049633a0
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blobs/05.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <path d="M700.13,570c53.17-51,40.37-88.09,136-126.87C985,382.55,1147,458,1185.46,481c48.47,29,214.54-34.58,214.54,70.19V570" style="fill: #3aadaa"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blobs/06.svg b/addons/web_editor/static/shapes/Blobs/06.svg
new file mode 100644
index 00000000..30a5002a
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blobs/06.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <path d="M1400,0V425.44c0,34.49-20.73,66.55-54.6,83-105.06,51-187.09-118.9-269.4-192.54-24.79-22.17-59.66-24.87-94.68-26C489.37,273.71,622.77,42.88,700,0" style="fill: #3aadaa"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blobs/07.svg b/addons/web_editor/static/shapes/Blobs/07.svg
new file mode 100644
index 00000000..482f074f
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blobs/07.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <path d="M979.33,481l180.13.08c132.85,0,239.87-108.23,239.87-241.08L1400,0H979.33c-132.85,0-240,107.69-240,240.54h0C739.33,373.39,846.48,481,979.33,481Z" style="fill: #383e45"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blobs/08.svg b/addons/web_editor/static/shapes/Blobs/08.svg
new file mode 100644
index 00000000..a171cd6c
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blobs/08.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+ <path d="M1400,50.57c-135.47-31.72-284.73-22.09-422.47-16.11C808.58,41.8,638.57,51.73,476,101.81c-99.51,30.65-203.36,70.79-289.29,130.61C132.84,269.94,71.21,328.18,86.46,400.88,105,489.29,231.9,541.73,420.13,541.73c419.49,0,696.8-68,979.87-153.64Z" style="fill: #3aadaa"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blobs/09.svg b/addons/web_editor/static/shapes/Blobs/09.svg
new file mode 100644
index 00000000..8eefa84f
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blobs/09.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+<style type="text/css">
+ .st0{fill:#F6F6F6;}
+</style>
+<path class="st0" d="M980,570C870.5,330.8,698.6,118.9,330.1,118.9C211,118.9,98.7,139.6,0,176.1V570H980z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blobs/10.svg b/addons/web_editor/static/shapes/Blobs/10.svg
new file mode 100644
index 00000000..70536c58
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blobs/10.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <rect width="1400" height="1140" style="fill: #3aadaa"/>
+ <rect width="1400" height="1140" style="fill: #383e45;opacity: 0.4"/>
+ <path d="M1047.94,285c0-71-13.29-133.66-67.94-285H0V963c98.67,36.51,211,57.17,330.14,57.17C698.6,1020.13,1047.94,657.57,1047.94,285Z" style="fill: #3aadaa"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blobs/11.svg b/addons/web_editor/static/shapes/Blobs/11.svg
new file mode 100644
index 00000000..e9637b90
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blobs/11.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+ <path d="M0,111.49C61,77.1,140.11,56.42,225.45,56.42c194.57,0,352.3,107.46,352.3,240s-199.54,316-362.48,155.41C148.46,386,66.92,378.37,0,373.23Z" style="fill: #3aadaa"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blobs/12.svg b/addons/web_editor/static/shapes/Blobs/12.svg
new file mode 100644
index 00000000..1a216ab9
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blobs/12.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1140">
+ <path d="M1400,50.63C1319.52,20.85,1227.91,4,1130.72,4,589.11,4,321.2,780,826.67,1052.71c176.62,95.3,377.54,99.32,573.33,55.21C1398.26,1006,1400,50.63,1400,50.63Z" style="fill: #3aadaa;opacity: 0.15"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blocks/01.svg b/addons/web_editor/static/shapes/Blocks/01.svg
new file mode 100644
index 00000000..f7a2faea
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blocks/01.svg
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1400 570" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:url(#SVGID_1_);}
+ .st1{opacity:0.44;fill:#383E45;enable-background:new ;}
+ .st2{opacity:0.17;fill:#383E45;enable-background:new ;}
+ .st3{opacity:0.13;fill:#383E45;enable-background:new ;}
+ .st4{opacity:.07;fill:#F6F6F6;enable-background:new ;}
+ .st5{opacity:0.13;fill:#3AADAA;enable-background:new ;}
+ .st6{opacity:0.44;fill:#3AADAA;enable-background:new ;}
+</style>
+<radialGradient id="SVGID_1_" cx="1516.38" cy="335.54" r="1778.8101" gradientTransform="matrix(1 0 0 -1 0 572)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" style="stop-color:#F6F6F6"/>
+ <stop offset="0.35" style="stop-color:#3AADAA"/>
+ <stop offset="0.99" style="stop-color:#383E45"/>
+</radialGradient>
+<polygon class="st0" points="1400,323 0,570 0,0 1400,0 "/>
+<polygon class="st1" points="0,0 0.2,90.6 281.9,42.1 281.9,0 "/>
+<polygon class="st2" points="364.6,88.5 0,144.1 0,233.6 364.6,177.9 "/>
+<polygon class="st3" points="518.4,0 281.9,0 281.9,42.1 "/>
+<polygon class="st4" points="599.5,291.9 0,397.6 0,487 599.5,381.3 "/>
+<polygon class="st5" points="871.1,154.5 427.1,232.8 427.1,322.3 871.1,244 "/>
+<polygon class="st6" points="1098.1,0 1079,3.4 1079,92.8 1400,36.6 1400,0 "/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blocks/01_001.svg b/addons/web_editor/static/shapes/Blocks/01_001.svg
new file mode 100644
index 00000000..a5c6fbb5
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blocks/01_001.svg
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1400 570" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:url(#SVGID_1_);}
+ .st1{opacity:0.44;fill:#383E45;enable-background:new;}
+ .st2{opacity:0.17;fill:#383E45;enable-background:new;}
+ .st3{opacity:0.13;fill:#383E45;enable-background:new;}
+ .st4{opacity:.07;fill:#F6F6F6;enable-background:new;}
+ .st5{opacity:0.13;fill:#3AADAA;enable-background:new;}
+ .st6{opacity:0.44;fill:#3AADAA;enable-background:new;}
+</style>
+<radialGradient id="SVGID_1_" cx="1516.38" cy="335.54" r="1778.8101" gradientTransform="matrix(1 0 0 -1 0 572)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" style="stop-color:#F6F6F6"/>
+ <stop offset="0.35" style="stop-color:#3AADAA"/>
+ <stop offset="0.99" style="stop-color:#383E45"/>
+</radialGradient>
+<polygon class="st0" points="1400,323 0,570 0,0 1400,0 "/>
+<polygon class="st1" points="0,0 0.2,90.6 281.9,42.1 281.9,0 "/>
+<polygon class="st2" points="364.6,88.5 0,144.1 0,233.6 364.6,177.9 "/>
+<polygon class="st3" points="518.4,0 281.9,0 281.9,42.1 "/>
+<polygon class="st4" points="599.5,291.9 0,397.6 0,487 599.5,381.3 "/>
+<polygon class="st5" points="871.1,154.5 427.1,232.8 427.1,322.3 871.1,244 "/>
+<polygon class="st6" points="1098.1,0 1079,3.4 1079,92.8 1400,36.6 1400,0 "/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blocks/02.svg b/addons/web_editor/static/shapes/Blocks/02.svg
new file mode 100644
index 00000000..571f8827
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blocks/02.svg
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1400 570" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:url(#SVGID_1_);}
+ .st1{opacity:0.13;fill:#3AADAA;enable-background:new ;}
+ .st2{opacity:0.1;fill:#383E45;enable-background:new ;}
+ .st3{opacity:0.44;fill:#383E45;enable-background:new ;}
+ .st4{opacity:0.44;fill:#3AADAA;enable-background:new ;}
+ .st5{opacity:0.13;fill:#383E45;enable-background:new ;}
+</style>
+<radialGradient id="SVGID_1_" cx="1517.8101" cy="1258.9148" r="1750.687" gradientTransform="matrix(1 0 0 1 0 -452)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" style="stop-color:#F6F6F6"/>
+ <stop offset="0.35" style="stop-color:#3AADAA"/>
+ <stop offset="0.99" style="stop-color:#383E45"/>
+</radialGradient>
+<polygon class="st0" points="0,246.6 1400,0 1400,570 0,570 "/>
+<polygon class="st1" points="753.4,570 1118,505.7 1118,416.2 753.4,480.5 "/>
+<polygon class="st2" points="306.9,282.1 906.4,176.3 906.4,87 306.9,192.6 "/>
+<polygon class="st1" points="712.6,300 1156.6,221.7 1156.6,132.3 712.6,210.5 "/>
+<polygon class="st3" points="0,570 0,568 281.9,518.3 281.9,570 "/>
+<polygon class="st4" points="1098.2,570 1400,516.8 1400,570 "/>
+<polygon class="st5" points="518.4,476.6 518.4,570 281.9,570 281.9,518.3 "/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blocks/02_001.svg b/addons/web_editor/static/shapes/Blocks/02_001.svg
new file mode 100644
index 00000000..95dd7270
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blocks/02_001.svg
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1400 570" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:url(#SVGID_1_);}
+ .st1{opacity:0.13;fill:#3AADAA;enable-background:new;}
+ .st2{opacity:0.1;fill:#383E45;enable-background:new;}
+ .st3{opacity:0.44;fill:#383E45;enable-background:new;}
+ .st4{opacity:0.44;fill:#3AADAA;enable-background:new;}
+ .st5{opacity:0.13;fill:#383E45;enable-background:new;}
+</style>
+<radialGradient id="SVGID_1_" cx="1517.8101" cy="1258.9148" r="1750.687" gradientTransform="matrix(1 0 0 1 0 -452)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" style="stop-color:#F6F6F6"/>
+ <stop offset="0.35" style="stop-color:#3AADAA"/>
+ <stop offset="0.99" style="stop-color:#383E45"/>
+</radialGradient>
+<polygon class="st0" points="0,246.6 1400,0 1400,570 0,570 "/>
+<polygon class="st1" points="753.4,570 1118,505.7 1118,416.2 753.4,480.5 "/>
+<polygon class="st2" points="306.9,282.1 906.4,176.3 906.4,87 306.9,192.6 "/>
+<polygon class="st1" points="712.6,300 1156.6,221.7 1156.6,132.3 712.6,210.5 "/>
+<polygon class="st3" points="0,570 0,568 281.9,518.3 281.9,570 "/>
+<polygon class="st4" points="1098.2,570 1400,516.8 1400,570 "/>
+<polygon class="st5" points="518.4,476.6 518.4,570 281.9,570 281.9,518.3 "/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blocks/03.svg b/addons/web_editor/static/shapes/Blocks/03.svg
new file mode 100644
index 00000000..cc344de7
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blocks/03.svg
@@ -0,0 +1,16 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+<style type="text/css">
+.st0{opacity:0.73;fill:#3AADAA}
+.st1{opacity:0.42;fill:#3AADAA}
+</style>
+<polygon class="st0" points="198.8,474 34.9,502.9 34.9,527.2 198.8,498.3"/>
+<polygon class="st1" points="87.4,570 251.4,541.1 251.4,524.4 87.4,553.3"/>
+<polygon style="fill:#3AADAA" points="280.8,483.8 198.8,498.3 198.8,509.6 34.9,538.5 34.9,527.2 0,533.3 0,568.7 87.4,553.3 87.4,545.7 251.4,516.8 251.4,524.4 280.8,519.2"/>
+<polygon style="opacity:0.81;fill:#3AADAA" points="87.4,545.7 87.4,553.3 251.4,524.4 251.4,516.8"/>
+<polygon class="st1" points="198.8,509.6 198.8,498.3 34.9,527.2 34.9,538.5"/>
+<polygon class="st0" points="1159.2,345.9 1314.4,318.6 1314.4,289.2 1159.2,316.5"/>
+<polygon class="st1" points="1314.4,232.1 1229,247.1 1229,261.4 1314.4,246.4"/>
+<polygon class="st1" points="1400,231.3 1314.4,246.4 1314.4,261.6 1229,276.6 1229,261.4 1059.8,291.3 1059.8,334.1 1159.2,316.5 1159.2,295.4 1314.4,268.1 1314.4,289.2 1400,274.1"/>
+<polygon style="opacity:0.84;fill:#3AADAA" points="1314.4,261.6 1314.4,246.4 1229,261.4 1229,276.6"/>
+<polygon style="fill:#FFFFFF" points="1159.2,295.4 1159.2,316.5 1314.4,289.2 1314.4,268.1"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Blocks/04.svg b/addons/web_editor/static/shapes/Blocks/04.svg
new file mode 100644
index 00000000..95c6cdde
--- /dev/null
+++ b/addons/web_editor/static/shapes/Blocks/04.svg
@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+<polygon style="fill:#F6F6F6" points="0,569.7 1400,570 1400,323.1 0,570"/>
+<polygon style="fill:#7C6576" points="288.3,477.8 172.6,498.2 172.6,539.6 288.3,519.2"/>
+<polygon style="fill:#3AADAA" points="347.9,426 232.2,446.4 232.2,487.8 347.9,467.4"/>
+<polygon style="fill:#7C6576" points="1180.3,403.9 1353.1,373.4 1353.1,331.4 1180.3,361.9"/>
+<polygon style="fill:#3AADAA" points="1400,323.1 1244.9,350.5 1244.9,303.8 1400,276.5"/>
+<polygon style="opacity:0.29;fill:#383E45" points="1244.9,350.5 1072.2,380.9 1072.2,334.3 1244.9,303.8"/>
+<polygon style="opacity:0.4;fill:#3AADAA" points="1400,365.1 1353.1,373.4 1353.1,331.4 1400,323.1"/>
+<path style="opacity: 0.08;fill:#383E45" d="M1351.1,375.8V414l-83,14.5v-38.2L1351.1,375.8 M1353.1,373.4l-87,15.2v42.2l87-15.2V373.4L1353.1,373.4z"/>
+<polygon style="opacity:0.29;fill:#383E45" points="0,528.7 172.6,498.2 172.6,539.6 0,570"/>
+<path style="opacity: 0.0;fill:fill:#383E45" d="M230.2,448.8v37.2l-187.4,33v-37.2L230.2,448.8 M232.2,446.4L40.7,480.2v41.3l191.4-33.7V446.4L232.2,446.4z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/01.svg b/addons/web_editor/static/shapes/Bold/01.svg
new file mode 100644
index 00000000..ad47805e
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/01.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <path d="M0,0c0,62,66.6,103.1,153.2,130.4c253.1,79.8,767,213.3,1023.2,282.3c105.8,28.5,223.5-19.9,223.5-92.9V0 C1400,0,0,0,0,0z" style="fill: #7c6576"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/02.svg b/addons/web_editor/static/shapes/Bold/02.svg
new file mode 100644
index 00000000..a3f8ecdc
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/02.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <path d="M1310.09,31.48l-286.4,478.78C1001.93,538.94,973.85,564.45,906,570h494V0h-33.39C1343.29,0,1321.74,12,1310.09,31.48Z" style="fill: #3aadaa"/>
+ <path d="M0,0V570H667.39c67.93-5.55,96-31.06,117.83-59.74L1046,74.83C1065.91,41.56,1041,0,1001.11,0Z" style="fill: #f6f6f6"/>
+ <path d="M0,0V570H420c68-5.55,96.12-31.06,117.93-59.74L800.11,72.83C819.51,40.45,795.23,0,756.39,0Z" style="fill: #7c6576"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/03.svg b/addons/web_editor/static/shapes/Bold/03.svg
new file mode 100644
index 00000000..c6dc7dbe
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/03.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <path d="M841,0c-77.8,0-111.6,49.4-91.1,99.4c0.2,0.6,0.4,1.1,0.7,1.6L979,570h421V0.2L841,0z" style="fill: #f6f6f6"/>
+ <path d="M463.6,0c80.7,0,152,40,173.6,98.3c0.3,0.8,0.6,1.7,0.9,2.5L841,570H0V0H463.6z" style="fill: #3AADAA"/>
+ <path d="M0,78.7h369c87.8,0,151.6,40.3,175.3,103.2c0.3,0.9,155.5,388.1,155.5,388.1H0V78.7z" style="fill: #383E45"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/04.svg b/addons/web_editor/static/shapes/Bold/04.svg
new file mode 100644
index 00000000..d2adaaab
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/04.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <path d="M979,0l421,.1v526c0,24.3-19.7,43.9-43.2,43.9h-657c-37.9,0-51.3-39.6-36.1-65.5L979,.1,1400,0" style="fill: #f6f6f6"/>
+ <path d="M3.3,0H841S529.6,507.6,529.6,507.7C502.8,542.5,471,564.1,421,570H0V0C0-1.2,1.7,0,3.3,0Z" style="fill: #7c6576"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/05.svg b/addons/web_editor/static/shapes/Bold/05.svg
new file mode 100644
index 00000000..4731a367
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/05.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+ <path d="M279.4,252c-59.3-2.1-107.6-18.7-138.1-58.6L0,0V291.3H500.6c59.3,2.1,107.6,18.7,138.1,58.6L743.4,493.2C778.2,540.8,787.6,570,841,570h559V252Z" style="fill: #383e45"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/05_001.svg b/addons/web_editor/static/shapes/Bold/05_001.svg
new file mode 100644
index 00000000..f475f7b6
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/05_001.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+ <path d="M279.4,252c-59.3-2.1-107.6-18.7-138.1-58.6L0,0V291.3H500.6c59.3,2.1,107.6,18.7,138.1,58.6L743.4,493.2C778.2,540.8,787.6,570,841,570h559V252Z" style="fill: #fafafa"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/06.svg b/addons/web_editor/static/shapes/Bold/06.svg
new file mode 100644
index 00000000..4fd9551f
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/06.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+ <path d="M1271.4,369c-70.8,0-83.2-38.6-129.3-101.6L1003.5,77.6C963,24.8,899.1,2.8,820.5,0H0V202.5c67.9,6.1,123.3,28.6,159.6,76.2L298.2,468.4c46.1,63,58.5,101.6,129.3,101.6H1400V369Z" style="fill: #383e45"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/06_001.svg b/addons/web_editor/static/shapes/Bold/06_001.svg
new file mode 100644
index 00000000..ed244e44
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/06_001.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+ <path d="M1271.4,369c-70.8,0-83.2-38.6-129.3-101.6L1003.5,77.6C963,24.8,899.1,2.8,820.5,0H0V202.5c67.9,6.1,123.3,28.6,159.6,76.2L298.2,468.4c46.1,63,58.5,101.6,129.3,101.6H1400V369Z" style="fill: #f6f6f6"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/07.svg b/addons/web_editor/static/shapes/Bold/07.svg
new file mode 100644
index 00000000..9e9646d5
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/07.svg
@@ -0,0 +1,4 @@
+<svg id="Waves" xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <path d="M1400,0H0V43.2c0,58.7,27,93.2,153.2,129.9L1163.3,451.8c.5.2.9.3,1.4.5,55.3,15.3,96.2,50,96.2,93V570h138.8Z" style="fill: #7c6576"/>
+ <path d="M141.2,570V535.4c0-40.6-33.5-82.1-72-92.6L0,423.6V570Z" style="fill: #3aadaa"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/08.svg b/addons/web_editor/static/shapes/Bold/08.svg
new file mode 100644
index 00000000..349b947a
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/08.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <path d="M1260.6,0V279.8c0,102-139.5,128.2-261.1,94.4L268.6,174l-.5-.1C191.7,150.2,141.2,120.5,140.9,73c0-.2.2-72.9.2-72.9Z" style="fill: #3aadaa"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/09.svg b/addons/web_editor/static/shapes/Bold/09.svg
new file mode 100644
index 00000000..aa472710
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/09.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <path d="M0,0H513.26a100,100,0,0,1,78.53,38.09L820.3,328a100,100,0,0,1,21.46,61.91V570H0Z" style="fill: #f6f6f6"/>
+ <path d="M0,0H372.67A100,100,0,0,1,451.2,38.09L679.71,328a100,100,0,0,1,21.46,61.91V570H0Z" style="fill: #7c6576"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/10.svg b/addons/web_editor/static/shapes/Bold/10.svg
new file mode 100644
index 00000000..ee58cdc2
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/10.svg
@@ -0,0 +1,6 @@
+<svg id="Waves" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <path d="M641.77,570a94.43,94.43,0,0,0,84.49-52.81L953.68,55.79A100,100,0,0,1,1043.37,0H841.76L588.65,514.71C571.83,548.91,537,570,498.92,570Z" style="fill: #ffffff"/>
+ <path d="M841.76,0,588.65,514.71a100,100,0,0,1-89.74,55.87H0V0Z" style="fill: #3aadaa"/>
+ <path d="M701.17,0,490.34,428.38a83.3,83.3,0,0,1-74.75,46.54H0V0Z" style="fill: #383e45"/>
+ <path d="M643,570h0a94.19,94.19,0,0,0,84.5-52.58L954.9,55.82A100,100,0,0,1,1044.6,0H1400V570Z" style="fill: #f6f6f6"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/10_001.svg b/addons/web_editor/static/shapes/Bold/10_001.svg
new file mode 100644
index 00000000..4054a1fc
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/10_001.svg
@@ -0,0 +1,5 @@
+<svg id="Waves" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <path d="M641.77,570a94.43,94.43,0,0,0,84.49-52.81L953.68,55.79A100,100,0,0,1,1043.37,0H841.76L588.65,514.71C571.83,548.91,537,570,498.92,570Z" style="fill: #ffffff"/>
+ <path d="M841.76,0,588.65,514.71a100,100,0,0,1-89.74,55.87H0V0Z" style="fill: #3aadaa"/>
+ <path d="M701.17,0,490.34,428.38a83.3,83.3,0,0,1-74.75,46.54H0V0Z" style="fill: #383e45"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/11.svg b/addons/web_editor/static/shapes/Bold/11.svg
new file mode 100644
index 00000000..e877082a
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/11.svg
@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+<style type="text/css">
+ .st0{fill:none;}
+ .st1{clip-path:url(#SVGID_2_);}
+ .st2{fill:url(#path-6_1_);}
+ .st3{fill:url(#path-3_1_);}
+ .st4{fill:url(#path-9_1_);}
+ .st5{fill:url(#path-13_1_);}
+ .st6{fill:none;stroke:url(#SVGID_3_);stroke-width:0.5509;}
+ .st7{fill:#F6F6F6;}
+ .st8{fill:#7C6576;}
+ .st9{fill:#3AADAA;}
+</style>
+ <path class="st7" d="M641.8,0h759.4v550.8h-332.7c-36.2,0-69.6-19.6-87.3-51.2L729.6,51.1C711.9,19.5,678.6,0,642.4,0H641.8z"/>
+ <path class="st8" d="M841.8,0H1400v452.7h-247.8c-36.2,0-69.6-19.6-87.3-51.2L841.8,0z"/>
+ <path class="st9" d="M641.8,0H0v570h1400v-19.2h-332.4c-36.2,0-69.6-19.6-87.2-51.2L729,51.1C711.3,19.5,678,0,641.8,0z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/11_001.svg b/addons/web_editor/static/shapes/Bold/11_001.svg
new file mode 100644
index 00000000..da0d7264
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/11_001.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <path d="M841.8,0H1400v452.7h-247.8c-36.2,0-69.6-19.6-87.3-51.2L841.8,0z" style="fill: #7C6576"/>
+ <path d="M641.8,0H0v570h1400v-19.2h-332.4c-36.2,0-69.6-19.6-87.2-51.2L729,51.1C711.3,19.5,678,0,641.8,0z" style="fill: #3AADAA"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Bold/12.svg b/addons/web_editor/static/shapes/Bold/12.svg
new file mode 100644
index 00000000..330d52b1
--- /dev/null
+++ b/addons/web_editor/static/shapes/Bold/12.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1710">
+ <polygon points="285.92 0.56 1128.06 1710 1400 1710 1400 0 285.92 0.56" style="fill: #383e45"/>
+ <polygon points="980 1710 140.77 0 0 0 850 1710 980 1710" style="fill: #3aadaa"/>
+ <polygon points="285.92 0 140.77 0 980 1710 1128.97 1710 285.92 0" style="fill: #7c6576"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/01.svg b/addons/web_editor/static/shapes/Origins/01.svg
new file mode 100644
index 00000000..4d308fe3
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/01.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1140">
+<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="666.0453" y1="181.2743" x2="869.2076" y2="2447.3162">
+ <stop offset="0" style="stop-color:#7C6576"/>
+ <stop offset="1" style="stop-color:#383E45"/>
+</linearGradient>
+<polygon style="fill:url(#SVGID_1_)" points="0,0 0,1140 1400,893.1 1400,0"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/02.svg b/addons/web_editor/static/shapes/Origins/02.svg
new file mode 100644
index 00000000..c0f2874f
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/02.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <polygon points="0,570 1400,323.1 1400,570" style="fill: #f6f6f6"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/03.svg b/addons/web_editor/static/shapes/Origins/03.svg
new file mode 100644
index 00000000..58ed0439
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/03.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <polygon points="1400 0 0 246.9 0 0 1400 0" style="fill: #f6f6f6"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/04.svg b/addons/web_editor/static/shapes/Origins/04.svg
new file mode 100644
index 00000000..3c61514c
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/04.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <rect y="190" width="1400" height="380" style="fill: #f6f6f6"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/05.svg b/addons/web_editor/static/shapes/Origins/05.svg
new file mode 100644
index 00000000..bc444fd8
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/05.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1400 1400" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#F6F6F6;}
+</style>
+<path class="st0" d="M0,0l641,309c36.5,14.8,76.7,15.4,118,0L1400,0L0,0z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/06.svg b/addons/web_editor/static/shapes/Origins/06.svg
new file mode 100644
index 00000000..a3b22d7f
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/06.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1400 450" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#F6F6F6;}
+</style>
+<polygon class="st0" points="1400,370.6 0,450 0,0 1400,80.7 "/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/07.svg b/addons/web_editor/static/shapes/Origins/07.svg
new file mode 100644
index 00000000..65038fa7
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/07.svg
@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="none" width="100%" height="100%">
+ <style type="text/css">
+ .st0{fill:#F6F6F6;}
+ </style>
+ <defs>
+ <svg id="sub-svg-1" viewBox="0 0 1400 500" preserveAspectRatio="xMinYMin meet" width="100%">
+ <polygon class="st0" points="0,0 0,95 1400,0 "/>
+ </svg>
+ <svg id="sub-svg-2" viewBox="0 0 1400 500" preserveAspectRatio="xMinYMax meet" width="100%">
+ <polygon class="st0" points="0,500 1400,500 0,456.9 "/>
+ </svg>
+ </defs>
+ <svg>
+ <use xlink:href="#sub-svg-1"/>
+ <use xlink:href="#sub-svg-2"/>
+ </svg>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/08.svg b/addons/web_editor/static/shapes/Origins/08.svg
new file mode 100644
index 00000000..74d2842d
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/08.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <path d="M0,570V81.05H522.11c237.81,0,283-9.51,339.89,165L980,570Z" style="fill: #f6f6f6"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/09.svg b/addons/web_editor/static/shapes/Origins/09.svg
new file mode 100644
index 00000000..e8474d82
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/09.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <rect width="1400" height="1140" style="fill: #3aadaa"/>
+ <rect width="1400" height="1140" style="fill: #383e45;opacity: 0.4"/>
+ <polyline points="980.5,0 1400,1140 0,1140 0,0 " style="fill:#3AADAA"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/10.svg b/addons/web_editor/static/shapes/Origins/10.svg
new file mode 100644
index 00000000..59440bd1
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/10.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+<radialGradient id="SVGID_1_" cx="700" cy="643.1875" r="2495.4861" fx="700" fy="327.5137" gradientTransform="matrix(1 0 0 -1.12 0 1075.37)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" style="stop-color:#7C6576"/>
+ <stop offset="1" style="stop-color:#383E45"/>
+</radialGradient>
+<rect y="140" width="1400" height="430" style="fill:url(#SVGID_1_);"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/11.svg b/addons/web_editor/static/shapes/Origins/11.svg
new file mode 100644
index 00000000..501d2126
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/11.svg
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1400 2800" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:url(#SVGID_1_);}
+</style>
+<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="1348.89" y1="-46.3471" x2="-2562.79" y2="-46.3471" gradientTransform="matrix(1 0 0 -1 0 132.1258)">
+ <stop offset="0" style="stop-color:#F6F6F6"/>
+ <stop offset="1" style="stop-color:#383E45"/>
+</linearGradient>
+<path class="st0" d="M1400,0C913.6,36.9,445.1,40.3,0,0v2800h1400V0z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/12.svg b/addons/web_editor/static/shapes/Origins/12.svg
new file mode 100644
index 00000000..81951478
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/12.svg
@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <defs>
+ <linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="1348.89" y1="7310.7725" x2="-2562.79" y2="7310.7725" gradientTransform="matrix(1 0 0 1.11 0 -8100.4702)">
+ <stop offset="0" style="stop-color:#F6F6F6"/>
+ <stop offset="1" style="stop-color:#383E45"/>
+ </linearGradient>
+ </defs>
+ <path style="fill:url(#SVGID_1_)" d="M0,0c445.1,40.3,913.6,36.9,1400,0H0z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/13.svg b/addons/web_editor/static/shapes/Origins/13.svg
new file mode 100644
index 00000000..863b8bd5
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/13.svg
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1400 446.9" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:url(#SVGID_1_);}
+</style>
+<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="17003.3359" y1="370.531" x2="21521.6465" y2="-1139.6388" gradientTransform="matrix(-1 0 0 -1 18264.7891 450.64)">
+ <stop offset="0" style="stop-color:#F6F6F6"/>
+ <stop offset="1" style="stop-color:#383E45"/>
+</linearGradient>
+<polygon class="st0" points="0,381.5 1400,446.9 1400,0 0,88.6 "/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/14.svg b/addons/web_editor/static/shapes/Origins/14.svg
new file mode 100644
index 00000000..74ac000d
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/14.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 -1354.3 1400 1400" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#FFFFFF;}
+</style>
+<path class="st0" d="M0,0v45.7h1400V0C915.7,40.8,448,43.2,0,0z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Origins/15.svg b/addons/web_editor/static/shapes/Origins/15.svg
new file mode 100644
index 00000000..a9e0b39f
--- /dev/null
+++ b/addons/web_editor/static/shapes/Origins/15.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+<path d="M0,0v13.3c459.8,47.6,926.5,47.5,1400,0V0H0z" style="fill:#FFFFFF"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Rainy/01.svg b/addons/web_editor/static/shapes/Rainy/01.svg
new file mode 100644
index 00000000..4b74078e
--- /dev/null
+++ b/addons/web_editor/static/shapes/Rainy/01.svg
@@ -0,0 +1,58 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 -830 1400 1400">
+ <defs>
+ <linearGradient id="linear-gradient" x1="889.95" y1="141.2" x2="34.92" y2="687.04" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#3aadaa" stop-opacity="0"/>
+ <stop offset="1" stop-color="#3aadaa" stop-opacity="0.3"/>
+ </linearGradient>
+ <linearGradient id="linear-gradient-2" x1="862.72" y1="98.46" x2="7.65" y2="644.32" xlink:href="#linear-gradient"/>
+ <linearGradient id="linear-gradient-3" x1="3730.08" y1="-4548.43" x2="4240.01" y2="-4338.01" gradientTransform="translate(-3489.03 4640.18)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#383e45"/>
+ <stop offset="1" stop-color="#3aadaa"/>
+ </linearGradient>
+ <linearGradient id="linear-gradient-4" x1="304.04" y1="390.93" x2="763.65" y2="390.93" gradientTransform="translate(310.11 -231.77) rotate(34.02)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#3aadaa" stop-opacity="0"/>
+ <stop offset="1" stop-color="#3aadaa"/>
+ </linearGradient>
+ <linearGradient id="linear-gradient-5" x1="3994.17" y1="-4394.83" x2="3586.33" y2="-4239.6" gradientTransform="translate(-3388.18 4705.47)" xlink:href="#linear-gradient-3"/>
+ <linearGradient id="linear-gradient-6" x1="4290.76" y1="-4385.98" x2="4124.11" y2="-4322.55" gradientTransform="translate(-3477.64 4481.85)" xlink:href="#linear-gradient-4"/>
+ <linearGradient id="linear-gradient-7" x1="380" y1="186.18" x2="-40.27" y2="346.13" gradientTransform="matrix(1, 0, 0, 1, 0, 0)" xlink:href="#linear-gradient-4"/>
+ <linearGradient id="linear-gradient-8" x1="198.07" y1="305.7" x2="578.95" y2="305.7" gradientTransform="translate(237.55 -165.05) rotate(34.02)" xlink:href="#linear-gradient-4"/>
+ <linearGradient id="linear-gradient-9" x1="3535.48" y1="-4523.04" x2="3769.84" y2="-4422.6" gradientTransform="translate(-3447.71 4820.11)" xlink:href="#linear-gradient-3"/>
+ <linearGradient id="linear-gradient-10" x1="4220.36" y1="-4213.07" x2="3976.88" y2="-4391.12" gradientTransform="translate(-3424.65 4526.66)" xlink:href="#linear-gradient-3"/>
+ <linearGradient id="linear-gradient-11" x1="3408.89" y1="-4323.29" x2="3815.21" y2="-4486.12" gradientTransform="translate(-3388.56 4842.79)" xlink:href="#linear-gradient-3"/>
+ <linearGradient id="linear-gradient-12" x1="817" y1="131.22" x2="651.12" y2="307.75" gradientTransform="matrix(1, 0, 0, 1, 0, 0)" xlink:href="#linear-gradient-3"/>
+ <linearGradient id="linear-gradient-13" x1="9.16" y1="352.59" x2="140.03" y2="410.41" gradientTransform="matrix(1, 0, 0, 1, 0, 0)" xlink:href="#linear-gradient-3"/>
+ </defs>
+ <g>
+ <polygon points="424.92 566.63 -1.3 566.63 -1.3 853.69 424.92 566.63" style="fill: url(#linear-gradient)"/>
+ <g>
+ <path d="M728.32-4.39-1.3,488.16v78.43H424.92L960.5,205c68.75-46.41,87.32-140.36,41.68-209.42Z" style="fill: url(#linear-gradient-2)"/>
+ <g>
+ <image width="412" height="321" transform="translate(264.7 29.73)" xlink:href="data:image/png;base64,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" style="opacity: 0.30000000000000004;mix-blend-mode: multiply"/>
+ <rect x="246.54" y="130.7" width="447.3" height="111.2" rx="55.6" transform="translate(-23.75 294.97) rotate(-34.02)" style="fill: url(#linear-gradient-3)"/>
+ </g>
+ <rect x="268" y="335.33" width="531.69" height="111.2" rx="55.6" transform="translate(-127.35 365.61) rotate(-34.02)" style="fill: url(#linear-gradient-4)"/>
+ <g>
+ <image width="403" height="299" transform="translate(212.7 237.73)" xlink:href="data:image/png;base64,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" style="opacity: 0.30000000000000004;mix-blend-mode: multiply"/>
+ <rect x="190.25" y="353.94" width="447.3" height="59.62" rx="29.81" transform="translate(-143.86 297.27) rotate(-34.02)" style="fill: url(#linear-gradient-5)"/>
+ </g>
+ <rect x="643.25" y="113.57" width="182.77" height="24.36" rx="12.18" transform="translate(55.4 432.56) rotate(-34.02)" style="fill: url(#linear-gradient-6)"/>
+ <path d="M366.35,136.79h0a29.89,29.89,0,0,0-41.38-8L3.65,345.67a29.92,29.92,0,0,0-5,4.23v41A29.93,29.93,0,0,0,37,395.09L358.32,178.18A29.9,29.9,0,0,0,366.35,136.79Z" style="fill: url(#linear-gradient-7)"/>
+ <rect x="164.86" y="275.89" width="447.3" height="59.62" rx="29.81" transform="translate(-104.54 269.71) rotate(-34.02)" style="fill: url(#linear-gradient-8)"/>
+ <g>
+ <image width="126" height="102" transform="translate(133.7 296.73)" xlink:href="data:image/png;base64,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" style="opacity: 0.30000000000000004;mix-blend-mode: multiply"/>
+ <rect x="137.07" y="328.88" width="119.43" height="29.81" rx="14.9" transform="translate(-158.67 168.96) rotate(-34.02)" style="fill: url(#linear-gradient-9)"/>
+ </g>
+ <g>
+ <image width="141" height="113" transform="translate(617.7 181.73)" xlink:href="data:image/png;base64,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" style="opacity: 0.30000000000000004;mix-blend-mode: multiply"/>
+ <rect x="619.47" y="218.9" width="136.86" height="31.7" rx="15.85" transform="translate(-13.59 425.07) rotate(-34.02)" style="fill: url(#linear-gradient-10)"/>
+ </g>
+ <g>
+ <image width="241" height="180" transform="translate(68.7 365.73)" xlink:href="data:image/png;base64,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" style="opacity: 0.30000000000000004;mix-blend-mode: multiply"/>
+ <rect x="60.35" y="435.94" width="257.87" height="31.7" rx="15.85" transform="translate(-220.38 183.25) rotate(-34.02)" style="fill: url(#linear-gradient-11)"/>
+ </g>
+ <circle cx="774.91" cy="176.01" r="13.7" style="fill: url(#linear-gradient-12)"/>
+ <circle cx="114.69" cy="399.21" r="13.7" style="fill: url(#linear-gradient-13)"/>
+ </g>
+ </g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Rainy/02.svg b/addons/web_editor/static/shapes/Rainy/02.svg
new file mode 100644
index 00000000..1e0d5678
--- /dev/null
+++ b/addons/web_editor/static/shapes/Rainy/02.svg
@@ -0,0 +1,77 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1400 1400">
+ <defs>
+ <linearGradient id="linear-gradient" x1="889.95" y1="-428.8" x2="34.92" y2="117.04"
+ gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#3aadaa" stop-opacity="0" />
+ <stop offset="1" stop-color="#3aadaa" stop-opacity="0.3" />
+ </linearGradient>
+ <linearGradient id="linear-gradient-2" x1="-1.38" y1="158.55" x2="290.16" y2="278.85"
+ gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#3aadaa" stop-opacity="0" />
+ <stop offset="1" stop-color="#3aadaa" />
+ </linearGradient>
+ <linearGradient id="linear-gradient-3" x1="3360.57" y1="-4639.02" x2="3128.42" y2="-4550.67"
+ gradientTransform="translate(-3129.22 4963.3)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#383e45" />
+ <stop offset="1" stop-color="#3aadaa" />
+ </linearGradient>
+ <linearGradient id="linear-gradient-4" x1="179.45" y1="273.24" x2="610.33" y2="150.45"
+ gradientTransform="translate(186.2 -188.2) rotate(34.02)" xlink:href="#linear-gradient-2" />
+ <linearGradient id="linear-gradient-5" x1="3685.8" y1="-4747.57" x2="3277.96" y2="-4592.34"
+ gradientTransform="translate(-3213.84 4840.82)" xlink:href="#linear-gradient-2" />
+ <linearGradient id="linear-gradient-6" x1="139.88" y1="366.5" x2="520.75" y2="366.5"
+ gradientTransform="translate(261.6 -122.08) rotate(34.02)" xlink:href="#linear-gradient-2" />
+ <linearGradient id="linear-gradient-7" x1="3614.91" y1="-4654.78" x2="3849.27" y2="-4554.34"
+ gradientTransform="translate(-3216.09 4700.64)" xlink:href="#linear-gradient-3" />
+ <linearGradient id="linear-gradient-8" x1="3864.59" y1="-4257.5" x2="3488.99" y2="-4532.16"
+ gradientTransform="translate(-3083.61 4675.81)" xlink:href="#linear-gradient-3" />
+ <linearGradient id="linear-gradient-9" x1="3707.04" y1="-4424.5" x2="4113.36" y2="-4587.33"
+ gradientTransform="translate(-3177.31 4595.72)" gradientUnits="userSpaceOnUse">
+ <stop offset="0.01" stop-color="#ffffff" />
+ <stop offset="1" stop-color="#3aadaa" />
+ </linearGradient>
+ <linearGradient id="linear-gradient-10" x1="475.66" y1="94.68" x2="309.78" y2="271.21"
+ gradientTransform="matrix(1, 0, 0, 1, 0, 0)" xlink:href="#linear-gradient-3" />
+ <linearGradient id="linear-gradient-11" x1="137.82" y1="496.61" x2="248.33" y2="420.7"
+ gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#ffffff" />
+ <stop offset="0.44" stop-color="#3aadaa" stop-opacity="0" />
+ </linearGradient>
+ </defs>
+ <g>
+ <polygon points="424.92 -3.37 -1.3 -3.37 -1.3 283.69 424.92 -3.37" style="fill: url(#linear-gradient)" />
+ <path d="M283.08,91.79h0a55.76,55.76,0,0,0-77.19-15L-3,217.83V352l271.1-183A55.77,55.77,0,0,0,283.08,91.79Z"
+ style="fill: url(#linear-gradient-2)" />
+ <g style="opacity: 0.73">
+ <image width="230" height="170" transform="translate(7.11 283.13)"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAOYAAACqCAYAAAC58DOsAAAACXBIWXMAAAsSAAALEgHS3X78AAANqUlEQVR4Xu2da3PURhpGzxh7bczFAUMgQDZbu///L21VloRLCOZiG2PAM/vh1Wu1eiSNNDd3S8+pUg0GY0wVh9Pq7lFPZrMZQoh6JpPJZNHnxMzWINVkDV9DiEERyBhK2UXQUKYZLC/p7qJPEGIsRELuRK9+NTELrqm/+pfsK6jEFKOnRshbmBvhFQoaEwr5I7quKATtI6eGsmLUFFKGQu4B/wAOgNvF6z7NcsZSXgJfgS/F6yWBoNCtniqmGCVBJcNCuox3gfvAEXAPOMRk3aWUckJ5TznD5PuGCfkZ+Ah8As6Kn/tWfE6nekpMMTqiSu5i0h1iEv4EHAOPitefMFEPMIF3ar5kWMtT4APwrrj+xiQ9Ay6A78DVIjklphgNNZXcw4S7iwn4CHgCPAV+Bh5TirlPu5hXlGKeAG+BV5jsb4H3WEHPKerZJqfEFKOgpZJHwENMyGfF9Qsm5kNMrNuYxLdonvy5wmr4BRPQa3uEie1fYwerJ7TIKTHFoOlQyceYiM+BF8xLeYBJ7FK2zcr6fea94LrPvJjh76kd1kpMMVg6VvI5pZRPmR++hqVctI45LX7PPuVE0mHx6kNhKIe+vt55FX8xiSkGR49KvqAU8wkm632qs7Bt65cx/uf5LK8vvXhxvaqXxfWdhnVOiSkGxRKV/IVqJQ+Yr2QXKaFcQvE/30XdoZTyApsAOieYpSWqpsQUg2DFSh6xfCVj/Pf4sNU/DmdtP2JLKqeUclaqKTFF9qy5kj45s4yUTiznATYR5N/Le2yN8wST85KomhJTZEtClWwi/M/iALt/fYh9X4+Bv7BynlNUk2I3kcQUWZJgJZvw73EPm5317+8YeIDJ+ZmimpPJZDKbzWYSU2RFBpWM8a9/C/tz72Df5wPmdxVdfx8SU2RDRpWMCavp/4kcUW6SP6D8j2IKqJgiDwopfQnCh4WpVrIO//7DDfN3sYL628omYH9XiSmSJhq6xpU8Jt1KhkywSZ3w7+C7guq2/KmYIl1qhq7hveTP2IbzF5SlTK2SIXXDcL/C7xHQPaZIkJYJnntYJZ9iMv5aXF7JB6RTyTpCOX3bXu0OI4kpkqJhgucOtgb4CJPyV+A3SimPi1+/Q1qVXBqJKZJgwTLIA8oJHq+kvxvkmHJmM8VKhoTPB7qifBbQLLgAiSkSoMcyiN9PPsPuMcMJntQrGT+061tw/SDY9QMSU9wgK2wWOKZ+ggfSlRKqUl5QPknvG9VySkxxM/SoZNMySPhEgMqMZqJ4Lf1JeqfYI0bOKR9xOQN7vKXEFFtlhUqmugzSBZfyO1bIM+y5QJ8wQb8SDWclptgaa6hkDhM8MdfP9sFqeU75fkx/rKW/7Uv3mGJ7jLSSTljLC6ySJ9h7Mv0tX9di6o3SYiuMtJJOOAv7FXt71wnlw6A/YveblfdigsQUG0KVBMq1yq9UHwT9BntC+2eson5/eY3EFGtHlZyb7PmMifga+BMT8z0m6/VSiZ6SJzaCKgmUu3r8nvIUk/IP4HfgJSboB6rD2AoSU6wFVXJuV885ZSlfYVL+FxPzHcHjRIhqCRJTrIgqCVQr6euUHzABX2My/g78D5P0hJZagsQUK6BKzlXSDxTySZ4/sSHsH5iQb4tfO6Ph3tKRmKI3I68klEKG65Nn2PKHV/IPTMw/aTiGjwYpQWKKnqiSQH0l31NW0kv5mnK9snJwLS1SgsQUHVEl54aufi/5EXs27CvKYatX8gSTtnLUOwukBIkpOqBKAvMTPKdYJd9gMr6kXAp5h03+nBWf65WcAq1HvDsSUzSiSrYug7ynOuP6kuqOnvBeslMlQySmqEWVBLotg7zEihnu5lmqkiESU1RQJZdaBvmLcoKn8t7KvkI6ElNco0oC9ZXssgwST/D0rmSIxBTAtZQ7xbVHfkcQrELXSi5aBpkC01WEdCTmyImGrnElczmCYFmWreTSyyBdkZgjpmbomvMRBH1ZtZIrT/C0ITFHSMsET+5HEHQh2UqGSMyR0TDBM5YjCJKuZIjEHAkLlkGGcgRBE1lUMkRijoAeyyB+P5njEQRNZFPJEIk5YFbYLJDbEQR1ZFfJEIk5UHpUsmkZJLcjCEKyrGSIxBwYK1RyCMsgWVcyRGIOiDVUMvcJnqwrGSIxB4AqCQygkiESM3NUyeFUMkRiZooqCQyskiESM0NUyWFWMkRiZoQqCQy4kiESMxNUyeFXMkRiJo4qCYykkiESM2FUyXFVMkRiJsjIKwmlkFO6H0GQfSVDJGZiqJJAfSW7HEGQdSVDJGYiqJJzQ9eNHkGQOhIzAVRJYH6CZ6NHEKSOxLxBVMnaCZ6tHEGQOhLzhlAlgfplkK0cQZA6EnPLqJKdl0H8fnIjRxCkjsTcIqok0H+zwEaOIEgdibklCil3imsPHUHQZ7PA2o8gSB2JuWGioWtcSR1BMILNAssgMTdIzdBVRxB0r+TgJ3jakJgboGWCR0cQqJKdkJhrpmGCR0cQqJK9kJhrYsEyiI4gUCV7ITHXQI9lEL+f1BEEqmQrEnMFVtgsoCMIVMlWJOaS9Khk0zKIjiBQJRuRmD1ZoZJDWAZRJbeExOzBGiqZ+wSPKrklJGYHVElAldwqEnMBqqQqeRNIzAZUSUCVvDEkZg2qpCp500jMAFUSUCWTQGIWqJKqZEqMXkxVElAlk2PUYqqSqmSqjFLMkVcSSiGnjPQIgtQZnZiqJFBfyVEdQZA6oxFTlZwbuo76CILUGYWYqiQwP8Ez6iMIUmfQYqqStRM8OoIgAwYrpioJ1C+D6AiCDBicmKpk52UQv58c5REEqTMoMVVJoP9mgVEeQZA6gxGzkHKnuPbQEQR9NguM7giC1MlezGjoGldSRxBos0CWZC1mzdBVRxB0r6QmeBImSzFbJnh0BIEqOQiyE7NhgkdHEKiSgyIbMRcsg+gIAlVyUGQhZo9lEL+f1BEEqmTWJC3mCpsFdASBKpk1yYrZo5JNyyA6gkCVzJbkxFyhkkNYBlElBZCYmGuoZO4TPKqkABIRU5UEVEkRcONiqpKqpJjnxsRUJQFVUjRwI2KqkqqkaGerYqqSgCopOrA1MVVJVVJ0Z+NiqpKAKil6slExVUlVUizHRsQceSWhFHKKjiAQS7B2MVVJoL6SOoJAdGZtYqqSc0NXHUEglmYtYqqSwPwEj44gEEuzkpiqZO0Ej44gECuztJiqJFC/DKIjCMTK9BZTley8DOL3kzqCQPSml5iqJNB/s4COIBC96SxmIeVOce2hIwj6bBbQEQSiFwvFjIaucSV1BIE2C4gN0CpmzdD1AHto8gN0BMGiSmqCRyxNo5iBlD7BcxsdQaBKiq1QK2YkZTh0fYxV0o8geFF8rCMIVEmxRtqGsj7Jc0g54/oCE/KfmJw6gkCVFBtgTsxg9tXvKY8wKX8D/g38CxP0CdWhqyqpSoo1URGzZgh7FztB6zkm5X8wQf1ckHjoCvlIqUqKZKkbynotD7EiPsGGrj6EfVr8/CHVoWsuQoIqKRLnWsyolvvYfeMjyiWR51gpH2CldClzGrqqkiIL4mJ6LW9j95a+VvmMctOAH9aTo5SqpMiCUEyf9NmjuongKaWUvmkgJylVSZEdu1A7jHUxHxWXb6/LUUpVUmRHXEwX0zenP6SUcr/4/BykVCVF1riY8drlPUzIo+LHt6muU6aMKimyZzd498gEk8+HsncxKeP7ylRRJcVgCIsZbiw4wIS8TR4bCFRJMSjqZmV3MRn3i9c90t1qp0qKQRKL6XLeCq6UpVQlxSCJNxiE95uT6OOUcCGn6AgCMUBiMWfB6yz6OAXCoWtcSR1BIAZDKGY4NLwKrilVSW+KeOiqIwjEYInFDEt0Wbz6091cjG0Pa5smeHQEgRgsLqZLd4XJ+BWrzAXV0txiu3LWTfDoCAIxeHZns9ms2GQww/4RX2L/qM+wKvkw0J9SsA0pmyqpIwjEKAiLGd67nWL3bp+KH19QvjF600sndZXUEQRiVMT3mFdYMX1S5aR4PaZ8c3S8lLIu2irZtgyiIwjE4NgF+wc8mUxCMc8ph4zvsHeZuJi+4WCdG9q7VrJpGURHEIhBUTcr+51SzLdYLY+ovsMkfiresixbSS2DiEETbzDwYl1gYrzF3mFyh/L9mL6ZfVU5V62kJnjEYLkWsxjOQjmc/YyJcMC8lM4B80/Ja5N0Frz60FmVFCIiLiaUw9kv2HB2l/pHVE6LKy5n0zpnKGVcyc+okkJcUxEzquY3TIZ4FjYU6wfVQ4R2qH83SjhsdfF9kskr6Tt4VEkxeuaKWTOkhXopL7F7UT8mIX5TtctZdy9Zt1nApVQlxeipG8q6nC4S2FAzvC/02n2ifLSlP4ak7n40lPkLpZR/Y6V8jW0UUCWFoEFMqJUTuN62F24if0z1aXpeTn9GkMv8jer7Jk8wMf8qrr9RJYUAWsSEOTnjYewpJtcbTEoX8w7NYvoe3I/F9aG4fOufKikEMOny777mdOl9rIyHmIx+3WFxMV3Os+LH58XPX2JCqpJi9HQSEypy+jOBdjFJ/al6+8WrSxkun/jOHp/4uQyu71SFVCXF6OksJlzLCaWg4YO7dmle8wzl9GWW8AkJ0+JzVEkh6CmmEwkalnQSXc4suqbRxxJSiIClxAyJJA1f65iFr5JRiHpWFjMkkLQRySjEYv4POfpBD0B3bEMAAAAASUVORK5CYII="
+ style="opacity: 0.30000000000000004;mix-blend-mode: multiply" />
+ <rect x="-5.29" y="348.93" width="254.6" height="33.94" rx="16.97"
+ transform="matrix(0.83, -0.56, 0.56, 0.83, -183.84, 130.9)" style="fill: url(#linear-gradient-3)" />
+ </g>
+ <rect x="231.58" y="187.66" width="338.17" height="45.07" rx="22.54"
+ transform="matrix(0.83, -0.56, 0.56, 0.83, -49.02, 260.16)" style="fill: url(#linear-gradient-4)" />
+ <rect x="56.23" y="136.55" width="447.3" height="59.62" rx="29.81"
+ transform="translate(-45.17 185.07) rotate(-34.02)" style="fill: url(#linear-gradient-5)" />
+ <rect x="106.67" y="336.69" width="447.3" height="59.62" rx="29.81"
+ transform="translate(-148.52 247.55) rotate(-34.02)" style="fill: url(#linear-gradient-6)" />
+ <g style="opacity: 0.73">
+ <image width="126" height="102" transform="translate(444.7 45.73)"
+ xlink:href="data:image/png;base64,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"
+ style="opacity: 0.30000000000000004;mix-blend-mode: multiply" />
+ <rect x="448.13" y="77.68" width="119.43" height="29.81" rx="14.9"
+ transform="translate(35.13 299.99) rotate(-34.02)" style="fill: url(#linear-gradient-7)" />
+ </g>
+ <g style="opacity: 0.73">
+ <image width="216" height="172" transform="translate(507.15 216.18)"
+ xlink:href="data:image/png;base64,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"
+ style="opacity: 0.30000000000000004;mix-blend-mode: multiply" />
+ <rect x="509.11" y="272.23" width="211.12" height="48.9" rx="24.45"
+ transform="translate(-60.78 394.7) rotate(-34.02)" style="fill: url(#linear-gradient-8)" />
+ </g>
+ <rect x="569.76" y="87.66" width="257.87" height="31.7" rx="15.85"
+ transform="translate(61.69 408.65) rotate(-34.02)" style="opacity: 0.73;fill: url(#linear-gradient-9)" />
+ <circle cx="433.57" cy="139.47" r="13.7" style="opacity: 0.73;fill: url(#linear-gradient-10)" />
+ <circle cx="170.22" cy="474.35" r="30.34" style="opacity: 0.39;fill: url(#linear-gradient-11)" />
+ </g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Rainy/03.svg b/addons/web_editor/static/shapes/Rainy/03.svg
new file mode 100644
index 00000000..6dc48d90
--- /dev/null
+++ b/addons/web_editor/static/shapes/Rainy/03.svg
@@ -0,0 +1,32 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1140">
+
+<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-402.15" y1="570.325" x2="1693.6801" y2="570.325" gradientTransform="matrix(1 0 0 -1 0 1140)">
+ <stop offset="0" style="stop-color:#FFFFFF"/>
+ <stop offset="0.53" style="stop-color:#7C6576"/>
+ <stop offset="1" style="stop-color:#383E45"/>
+</linearGradient>
+<path d="M1400,0H604.2c6.8,0,12.3,5.5,12.3,12.3v0.3c0,6.8-5.5,12.3-12.3,12.3h-54.4c-6.8,0-12.3,5.5-12.3,12.3l0,0v0.2
+ l0,0c0,6.8,5.5,12.3,12.3,12.3h28.9c6.8,0,12.3,5.5,12.3,12.3v0.3c0,5.8-4.1,10.8-9.8,12l-44.9,0.3c-6.8,0-12.3,5.5-12.3,12.3V87
+ c0,6.8,5.5,12.3,12.3,12.3H573c5.7,1.2,9.8,6.2,9.8,12v0.3c0,6.8-5.5,12.3-12.3,12.3h-1.8c-6.8,0-12.3,5.5-12.3,12.3l0,0v0.3
+ c0,6.8,5.5,12.3,12.3,12.3h80.8c6.8,0,12.3,5.5,12.3,12.3v0.3c0,6.8-5.5,12.3-12.3,12.3H590c-6.8,0-12.3,5.5-12.3,12.3v0.3
+ c0,6.8,5.5,12.3,12.3,12.3h31.9c6.8,0,12.3,5.5,12.3,12.3v0.3c0,6.8-5.5,12.3-12.3,12.3h-10.2c-6.8,0-12.3,5.5-12.3,12.3l0,0v0.3
+ c0,6.8,5.5,12.3,12.3,12.3h28.9c6.8,0,12.3,5.5,12.3,12.3v0.3c0,5.8-4.1,10.8-9.8,12l-44.9,0.3c-6.8,0-12.3,5.5-12.3,12.3v0.3
+ c0,6.8,5.5,12.3,12.3,12.3h36.6c5.7,1.2,9.7,6.2,9.8,12v0.3c0,6.8-5.5,12.3-12.3,12.3h-1.8c-6.8,0-12.3,5.5-12.3,12.3v0.3
+ c0,6.8,5.5,12.3,12.3,12.3h26.2c6.8,0,12.3,5.5,12.3,12.3v0.3c0,6.8-5.5,12.3-12.3,12.3h-33.9c-6.8,0-12.3,5.5-12.3,12.3v0.3
+ c0,6.8,5.5,12.3,12.3,12.3H686c4.9,1.7,8.2,6.4,8.2,11.6v0.3c0,6.8-5.5,12.3-12.3,12.3h-22.6c-6.8,0-12.3,5.5-12.3,12.3v0.3
+ c0,6.8,5.5,12.3,12.3,12.3h10.3c6.8,0,12.3,5.5,12.3,12.3l0,0v0.2l0,0c0,6.8-5.5,12.3-12.3,12.3h-38.5c-6.8,0-12.3,5.5-12.3,12.3
+ v0.3c0,5.8,4.1,10.8,9.8,12h106.9c6.8,0,12.3,5.5,12.3,12.3v0.3c0,6.8-5.5,12.3-12.3,12.3l-40.1,0.3c-5.7,1.2-9.7,6.2-9.8,12v0.3
+ c0,6.8,5.5,12.3,12.3,12.3H722c6.8,0,12.3,5.5,12.3,12.3l0,0v0.3c0,6.8-5.5,12.3-12.3,12.3h-69.9c-6.8,0-12.3,5.5-12.3,12.3v0.3
+ c0,6.8,5.5,12.3,12.3,12.3H684c6.8,0,12.3,5.5,12.3,12.3v0.3c0,6.8-5.5,12.3-12.3,12.3h-4.9c-6.8,0-12.3,5.5-12.3,12.3v0.3
+ c0,6.8,5.5,12.2,12.3,12.3h26.2c6.8,0,12.3,5.5,12.3,12.3l0,0v0.3c0,6.8-5.5,12.3-12.3,12.3h-1.8c-6.8,0-12.3,5.5-12.3,12.3v0.3
+ c0,5.8,4.1,10.8,9.8,12h36.6c6.8,0,12.3,5.5,12.3,12.3v0.3c0,6.8-5.5,12.3-12.3,12.3l-44.9,0.3c-5.7,1.2-9.7,6.2-9.8,12v0.3
+ c0,6.8,5.5,12.3,12.3,12.3h28.9c6.8,0,12.3,5.5,12.3,12.3v0.3c0,6.8-5.5,12.3-12.3,12.3h-49.3c-6.8,0-12.3,5.5-12.3,12.3v0.3
+ c0,6.8,5.5,12.3,12.3,12.3h0.4c0.4,0,0.8,0.1,1.3,0.1h26.2c6.8,0,12.3,5.5,12.3,12.3v0.3c0,6.8-5.5,12.3-12.3,12.3h-1.8
+ c-6.8,0-12.3,5.5-12.3,12.3v0.3c0,5.8,4.1,10.8,9.8,12H735c6.8,0,12.3,5.5,12.3,12.3v0.3c0,6.8-5.5,12.3-12.3,12.3l-71.7,0.3
+ c-5.7,1.2-9.7,6.2-9.8,12v0.3c0,6.8,5.5,12.3,12.3,12.3h8.3c6.8,0,12.3,5.5,12.3,12.3v0.3l0,0c0,6.8-5.5,12.3-12.3,12.3h-69.9
+ c-6.8,0-12.3,5.5-12.3,12.3v0.3c0,6.8,5.5,12.3,12.3,12.3H661c6.8,0,12.3,5.5,12.3,12.3v0.3c0,6.8-5.5,12.3-12.3,12.3h-68.5
+ c-6.8,0-12.3,5.5-12.3,12.3v0.3c0,6.8,5.5,12.3,12.3,12.3h26.2c6.8,0,12.3,5.5,12.3,12.3v0.3l0,0c0,6.8-5.5,12.3-12.3,12.3h-1.8
+ c-6.8,0-12.3,5.5-12.3,12.3v0.3c0,5.8,4.1,10.8,9.8,12h54.8c6.8,0,12.3,5.5,12.3,12.3v0.3c0,6.8-5.5,12.3-12.3,12.3l-98.4,0.3
+ c-5.7,1.2-9.7,6.2-9.8,12v0.3c0,6.8,5.5,12.3,12.3,12.3h28.8c6.8,0,12.3,5.5,12.3,12.3l0,0v0.2l0,0c0,6.8-5.5,12.3-12.3,12.3H561
+ c-6.8,0-12.3,5.5-12.3,12.3v0.3c0,6.8,5.5,12.3,12.3,12.3h839L1400,0z" style="fill:url(#SVGID_1_)"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Rainy/04.svg b/addons/web_editor/static/shapes/Rainy/04.svg
new file mode 100644
index 00000000..6104a16d
--- /dev/null
+++ b/addons/web_editor/static/shapes/Rainy/04.svg
@@ -0,0 +1,26 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="700.3501" y1="557.15" x2="700.3501" y2="60.8" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#383E45"/>
+ <stop offset="1" style="stop-color:#3AADAA"/>
+</linearGradient>
+<path d="M1400.7,0H0v60.8c0,10.4,8.4,18.7,18.8,18.8h0.4c10.4,0,18.7-8.4,18.8-18.8v-7.5c0-10.4,8.4-18.7,18.8-18.8h0.4
+ c10.4,0,18.7,8.4,18.8,18.8v40c0,10.4,8.4,18.7,18.8,18.8H95c10.4,0,18.7-8.4,18.8-18.8v-2.8c0-10.4,8.4-18.8,18.8-18.8h0.4
+ c8.9,0,16.6,6.2,18.4,14.9v56c0,10.4,8.4,18.7,18.8,18.8h0.4c10.4,0,18.7-8.4,18.8-18.8l0.4-68.5c1.8-8.7,9.5-14.9,18.3-14.9h0.4
+ c10.4,0,18.7,8.4,18.8,18.8v44.1c0,10.4,8.4,18.8,18.8,18.8h0.4c10.4,0,18.7-8.4,18.7-18.8v-15.6c0-10.4,8.4-18.7,18.8-18.8h0.4
+ c10.4,0,18.7,8.4,18.8,18.8l0,0v48.8c0,10.4,8.4,18.8,18.8,18.8h0.4c10.4,0,18.7-8.4,18.8-18.8v-7.5c0-10.3,8.4-18.7,18.7-18.8h0.4
+ c10.4,0,18.7,8.4,18.8,18.8v40c0,10.4,8.4,18.7,18.8,18.8h0.4c10.4,0,18.7-8.4,18.8-18.8V185c0-10.4,8.4-18.7,18.8-18.8h0.4
+ c8.9,0,16.5,6.3,18.4,14.9v56c0,10.4,8.4,18.8,18.8,18.8h0.4c10.4,0,18.7-8.4,18.8-18.8l0.4-68.5c1.9-8.7,9.5-14.9,18.3-14.9h0.4
+ c10.4,0,18.7,8.4,18.8,18.8v44.1c0,10.4,8.4,18.8,18.8,18.8h0.4c10.4,0,18.7-8.4,18.8-18.8V201c0-10.4,8.4-18.8,18.8-18.8h0.4
+ c9.2,0,17,6.7,18.5,15.7l0.3,58c0,10.4,8.4,18.7,18.8,18.8h0.4c10.4,0,18.7-8.4,18.8-18.8v-56c1.8-8.7,9.5-14.9,18.3-14.9h0.4
+ c10.4,0,18.7,8.4,18.8,18.8v2.8c0,10.4,8.4,18.8,18.8,18.8h0.4c10.4,0,18.7-8.4,18.8-18.8v-40c0-10.4,8.4-18.8,18.8-18.8h0.4
+ c10.4,0,18.7,8.4,18.8,18.8v7.5c0,10.4,8.4,18.8,18.8,18.8h0.4c10.2,0,18.6-8.3,18.7-18.5v-2.9c0-10.4,8.4-18.8,18.8-18.8h0.4
+ c9.7,0,17.8,7.4,18.7,17.1c0.1,0.6,0.1,17.5,0.1,17.5c0,10.4,8.4,18.7,18.8,18.8h0.4c10.4,0,18.7-8.4,18.8-18.8v-44.1
+ c0-10.4,8.4-18.8,18.8-18.8h0.4c8.9,0,16.5,6.2,18.3,14.9l0.4,68.5c0,10.4,8.4,18.8,18.8,18.8h0.4c10.4,0,18.7-8.4,18.8-18.8v-56
+ c1.8-8.7,9.5-14.9,18.4-14.9h0.4c10.4,0,18.7,8.4,18.8,18.8v2.8c0,10.4,8.4,18.7,18.8,18.8h0.4c10.4,0,18.7-8.4,18.8-18.8v-40
+ c0-10.4,8.4-18.7,18.8-18.8h0.4c10.4,0,18.7,8.4,18.8,18.8v7.5c0,10.4,8.4,18.8,18.8,18.8h0.4c10.4,0,18.7-8.4,18.7-18.8V77.1l0,0
+ c0-10.4,8.4-18.7,18.8-18.8h0.4c10.4,0,18.7,8.4,18.8,18.8v15.6c0,10.4,8.4,18.8,18.8,18.8h0.4c10.4,0,18.7-8.4,18.8-18.8V48.6
+ c0-10.3,8.4-18.7,18.7-18.7h0.4c8.9,0,16.5,6.2,18.3,14.9l0.4,68.5c0,10.3,8.4,18.7,18.7,18.7h0.4c10.4,0,18.7-8.4,18.8-18.8v-56
+ c1.8-8.7,9.5-14.9,18.3-14.9h0.4c10.4,0,18.7,8.4,18.8,18.8v2.8c0,10.4,8.4,18.7,18.8,18.8h0.4c10.4,0,18.7-8.4,18.8-18.8v-40
+ c0-10.4,8.4-18.7,18.8-18.8h0.4c10.4,0,18.7,8.4,18.8,18.8v7.5c0,10.4,8.4,18.7,18.8,18.8h0.4c10.4,0,18.7-8.4,18.8-18.8L1400.7,0z" style="fill:url(#SVGID_1_)"
+ />
+</svg>
diff --git a/addons/web_editor/static/shapes/Rainy/05.svg b/addons/web_editor/static/shapes/Rainy/05.svg
new file mode 100644
index 00000000..4090b1b3
--- /dev/null
+++ b/addons/web_editor/static/shapes/Rainy/05.svg
@@ -0,0 +1,53 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+<style type="text/css">
+ .st0{fill:url(#SVGID_1_);}
+ .st1{opacity:0.25;fill:url(#SVGID_2_);enable-background:new ;}
+</style>
+<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="18645.6504" y1="-5838.8398" x2="18645.6504" y2="-6533.2598" gradientTransform="matrix(-1 0 0 1 19345.6504 6388.0703)">
+ <stop offset="0" style="stop-color:#383E45"/>
+ <stop offset="1" style="stop-color:#3AADAA"/>
+</linearGradient>
+<rect class="st0" width="1400" height="570"/>
+<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="18640.3203" y1="-6558.2705" x2="18626.9336" y2="-7232.4897" gradientTransform="matrix(-1 0 0 1 19345.6504 6388.0698)">
+ <stop offset="0" style="stop-color:#383E45"/>
+ <stop offset="1" style="stop-color:#3AADAA"/>
+</linearGradient>
+<path class="st1" d="M0,0h605.4v1.4l0-0.4h794.5l0.1,192.6c0,10.4-8.4,18.7-18.8,18.8h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-7.5
+ c0-10.4-8.4-18.8-18.7-18.8h-0.4c-10.3,0-18.7,8.4-18.7,18.8v40c0,10.4-8.4,18.7-18.8,18.8h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-2.8
+ c0-10.4-8.4-18.8-18.7-18.8h-0.4c-8.9,0-16.5,6.3-18.4,14.9v56c0,10.4-8.4,18.7-18.7,18.8h-0.4c-10.4,0-18.7-8.4-18.7-18.8
+ l-0.4-68.5c-1.9-8.7-9.5-14.9-18.3-14.9h-0.4c-10.3,0-18.7,8.4-18.7,18.8v44.1c0,10.4-8.4,18.8-18.7,18.8h-0.4
+ c-10.4,0-18.7-8.4-18.8-18.8v-15.6c0-10.4-8.4-18.7-18.7-18.8h-0.4c-10.3,0-18.7,8.4-18.7,18.8l0,0v48.8c0,10.4-8.4,18.8-18.7,18.8
+ h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-7.5c0-10.4-8.4-18.8-18.7-18.8h-0.4c-10.3,0-18.7,8.4-18.7,18.8v40c0,10.3-8.4,18.7-18.7,18.8
+ h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-2.8c0-10.4-8.4-18.7-18.7-18.8h-0.4c-8.9,0-16.5,6.3-18.4,14.9v56c0,10.4-8.4,18.7-18.7,18.8H927
+ c-10.3,0-18.7-8.4-18.7-18.8l-0.4-68.5c-1.9-8.7-9.5-14.9-18.3-14.9h-0.4c-10.4,0-18.7,8.4-18.8,18.8v44.1
+ c0,10.4-8.4,18.7-18.7,18.8h-0.5c-10.3-0.1-18.7-8.4-18.7-18.8v-15.6c0-10.4-8.4-18.8-18.7-18.8h-0.4c-9.2,0-17,6.7-18.5,15.7
+ l-0.3,58c0,10.4-8.4,18.7-18.7,18.8h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-56c-1.8-8.7-9.5-14.9-18.3-14.9h-0.4
+ c-10.3,0-18.7,8.4-18.7,18.8v2.9c0,10.4-8.4,18.8-18.7,18.8H700c-10.3,0-18.7-8.4-18.7-18.8v-40c0-10.4-8.4-18.8-18.7-18.8h-0.4
+ c-10.4,0-18.7,8.4-18.8,18.8v7.5c0,10.4-8.4,18.8-18.8,18.8h-0.4c-10.2,0-18.6-8.3-18.7-18.5v-2.9c0-10.4-8.4-18.8-18.7-18.8h-0.4
+ c-9.7,0-17.8,7.4-18.7,17.1c-0.1,0.6-0.1,17.5-0.1,17.5c0,10.4-8.4,18.8-18.7,18.8h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-44.1
+ c0-10.4-8.4-18.8-18.8-18.8h-0.4c-8.9,0-16.5,6.2-18.3,14.9l-0.4,68.5c0,10.3-8.4,18.7-18.7,18.8h-0.4c-10.3,0-18.7-8.4-18.7-18.8
+ v-56c-1.8-8.7-9.5-14.9-18.4-14.9h-0.4c-10.4,0-18.7,8.4-18.8,18.8v2.8c0,10.4-8.4,18.7-18.7,18.8h-0.4c-10.3,0-18.7-8.4-18.7-18.8
+ v-40c0-10.4-8.4-18.7-18.7-18.8h-0.3c-10.4,0-18.8,8.4-18.8,18.8v7.5c0,10.4-8.4,18.8-18.8,18.8h-0.4c-10.3,0-18.7-8.4-18.7-18.8
+ V210l0,0c0-10.4-8.4-18.7-18.7-18.8h-0.4c-10.3,0-18.7,8.4-18.7,18.8v15.6c0,10.3-8.4,18.7-18.7,18.8h-0.4
+ c-10.3,0-18.7-8.4-18.7-18.8v-44c0-10.4-8.4-18.7-18.7-18.8h-0.4c-8.8,0.1-16.4,6.3-18.3,14.9l-0.4,68.5c0,10.4-8.4,18.7-18.7,18.8
+ h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-56c-1.8-8.7-9.5-14.9-18.4-14.9h-0.4c-10.3,0-18.7,8.4-18.7,18.8v2.8c0,10.4-8.4,18.8-18.8,18.8
+ h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-40c0-10.4-8.4-18.7-18.7-18.8h-0.4c-10.4,0-18.8,8.4-18.8,18.8v7.5c0,10.4-8.4,18.7-18.8,18.8
+ h-0.4C8.4,183.1,0,174.7,0,164.3L0,0z"/>
+<path style="fill:#3AADAA" d="M0,130.1c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1l0.5-76.8c2.1-9.7,10.7-16.7,20.6-16.7H64
+ c11.6,0.1,21,9.5,21,21.1V107c0,11.6,9.4,21,21,21h0.5c11.6-0.1,20.9-9.4,21-21V89.6c0-11.6,9.4-21,21-21.1h0.5
+ c11.6,0.1,21,9.5,21,21.1v54.6c0,11.6,9.4,21,21,21.1h0.5c11.6,0,21-9.5,21-21.1v-8.4c0-11.6,9.4-21,21-21.1h0.5
+ c11.6,0.1,21,9.5,21,21.1v44.8c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1v-3.1c0-11.6,9.4-21,21-21.1h0.5
+ c9.9,0,18.5,7,20.6,16.8v62.7c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1l0.5-76.8c2.1-9.8,10.8-16.8,20.8-16.7h0.5
+ c11.6,0.1,21,9.5,21,21.1v49.4c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1v-17.5c0-11.6,9.4-21,21-21.1h0.5
+ c10.3,0,19,7.5,20.7,17.6l0.4,65c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1v-26.2c2.1-9.7,10.6-16.7,20.6-16.8h0.5
+ c11.6,0.1,21,9.5,21,21.1v3.1c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1v-44.8c0-11.6,9.4-21,21-21.1h0.4
+ c11.6,0.1,21,9.5,21,21.1v8.4c0,11.6,9.4,21,21,21.1h0.5c11.5,0,20.8-9.3,21-20.7v-3.3c0-11.6,9.4-21,21-21.1h0.5
+ c10.9,0,19.9,8.3,20.9,19.1c0.1,0.7,0.1,19.6,0.1,19.6c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1v-49.4
+ c0-11.6,9.4-21,21-21.1h0.5c9.9,0,18.5,7,20.6,16.7l0.5,76.8c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1v-62.7
+ c2.1-9.7,10.6-16.7,20.6-16.8h0.5c11.6,0.1,21,9.5,21,21.1v3.1c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1v-44.8
+ c0-11.6,9.4-21,21-21.1h0.4c11.6,0.1,21,9.5,21,21.1v8.4c0,11.6,9.4,21,21,21.1h0.4c11.6-0.1,21-9.5,21-21.1V93.2l0,0
+ c0.1-11.6,9.4-20.9,21-21h0.4c11.6,0.1,20.9,9.4,21,21v17.5c0.1,11.6,9.4,20.9,21,21h0.5c11.6-0.1,20.9-9.4,21-21V61.3
+ c0-11.6,9.4-21,21-21.1h0.5c9.9,0,18.5,7,20.6,16.7l0.5,76.8c0,11.6,9.4,21,21,21.1h0.4c11.6-0.1,21-9.5,21-21.1V71
+ c2.1-9.7,10.6-16.7,20.6-16.7h0.5c11.6,0.1,21,9.5,21,21.1v3.1c0.1,11.6,9.4,20.9,21,21h0.5c11.6-0.1,20.9-9.4,21-21V33.7
+ c0-11.6,9.4-21,21-21.1h0.4c11.6,0.1,21,9.5,21,21.1v8.4c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1V0H0.1L0,130.1z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Rainy/05_001.svg b/addons/web_editor/static/shapes/Rainy/05_001.svg
new file mode 100644
index 00000000..1dcf31a0
--- /dev/null
+++ b/addons/web_editor/static/shapes/Rainy/05_001.svg
@@ -0,0 +1,25 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+<style type="text/css">
+ .st0{fill:url(#SVGID_1_);}
+ .st1{fill:url(#SVGID_2_);}
+ .st2{fill:#3AADAA;}
+</style>
+<defs>
+ <mask maskUnits="userSpaceOnUse" x="0" y="0" width="1400" height="1400" id="SVGID_3_">
+ <rect style="fill:white" width="1400" height="1400"/>
+ <path class="st2" d="M0,0h605.4v1.4l0-0.4h794.5l0.1,192.6c0,10.4-8.4,18.7-18.8,18.8h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-7.5 c0-10.4-8.4-18.8-18.7-18.8h-0.4c-10.3,0-18.7,8.4-18.7,18.8v40c0,10.4-8.4,18.7-18.8,18.8h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-2.8 c0-10.4-8.4-18.8-18.7-18.8h-0.4c-8.9,0-16.5,6.3-18.4,14.9v56c0,10.4-8.4,18.7-18.7,18.8h-0.4c-10.4,0-18.7-8.4-18.7-18.8 l-0.4-68.5c-1.9-8.7-9.5-14.9-18.3-14.9h-0.4c-10.3,0-18.7,8.4-18.7,18.8v44.1c0,10.4-8.4,18.8-18.7,18.8h-0.4 c-10.4,0-18.7-8.4-18.8-18.8v-15.6c0-10.4-8.4-18.7-18.7-18.8h-0.4c-10.3,0-18.7,8.4-18.7,18.8l0,0v48.8c0,10.4-8.4,18.8-18.7,18.8 h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-7.5c0-10.4-8.4-18.8-18.7-18.8h-0.4c-10.3,0-18.7,8.4-18.7,18.8v40c0,10.3-8.4,18.7-18.7,18.8 h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-2.8c0-10.4-8.4-18.7-18.7-18.8h-0.4c-8.9,0-16.5,6.3-18.4,14.9v56c0,10.4-8.4,18.7-18.7,18.8H927 c-10.3,0-18.7-8.4-18.7-18.8l-0.4-68.5c-1.9-8.7-9.5-14.9-18.3-14.9h-0.4c-10.4,0-18.7,8.4-18.8,18.8v44.1 c0,10.4-8.4,18.7-18.7,18.8h-0.5c-10.3-0.1-18.7-8.4-18.7-18.8v-15.6c0-10.4-8.4-18.8-18.7-18.8h-0.4c-9.2,0-17,6.7-18.5,15.7 l-0.3,58c0,10.4-8.4,18.7-18.7,18.8h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-56c-1.8-8.7-9.5-14.9-18.3-14.9h-0.4 c-10.3,0-18.7,8.4-18.7,18.8v2.9c0,10.4-8.4,18.8-18.7,18.8H700c-10.3,0-18.7-8.4-18.7-18.8v-40c0-10.4-8.4-18.8-18.7-18.8h-0.4 c-10.4,0-18.7,8.4-18.8,18.8v7.5c0,10.4-8.4,18.8-18.8,18.8h-0.4c-10.2,0-18.6-8.3-18.7-18.5v-2.9c0-10.4-8.4-18.8-18.7-18.8h-0.4 c-9.7,0-17.8,7.4-18.7,17.1c-0.1,0.6-0.1,17.5-0.1,17.5c0,10.4-8.4,18.8-18.7,18.8h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-44.1 c0-10.4-8.4-18.8-18.8-18.8h-0.4c-8.9,0-16.5,6.2-18.3,14.9l-0.4,68.5c0,10.3-8.4,18.7-18.7,18.8h-0.4c-10.3,0-18.7-8.4-18.7-18.8 v-56c-1.8-8.7-9.5-14.9-18.4-14.9h-0.4c-10.4,0-18.7,8.4-18.8,18.8v2.8c0,10.4-8.4,18.7-18.7,18.8h-0.4c-10.3,0-18.7-8.4-18.7-18.8 v-40c0-10.4-8.4-18.7-18.7-18.8h-0.3c-10.4,0-18.8,8.4-18.8,18.8v7.5c0,10.4-8.4,18.8-18.8,18.8h-0.4c-10.3,0-18.7-8.4-18.7-18.8 V210l0,0c0-10.4-8.4-18.7-18.7-18.8h-0.4c-10.3,0-18.7,8.4-18.7,18.8v15.6c0,10.3-8.4,18.7-18.7,18.8h-0.4 c-10.3,0-18.7-8.4-18.7-18.8v-44c0-10.4-8.4-18.7-18.7-18.8h-0.4c-8.8,0.1-16.4,6.3-18.3,14.9l-0.4,68.5c0,10.4-8.4,18.7-18.7,18.8 h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-56c-1.8-8.7-9.5-14.9-18.4-14.9h-0.4c-10.3,0-18.7,8.4-18.7,18.8v2.8c0,10.4-8.4,18.8-18.8,18.8 h-0.4c-10.3,0-18.7-8.4-18.7-18.8v-40c0-10.4-8.4-18.7-18.7-18.8h-0.4c-10.4,0-18.8,8.4-18.8,18.8v7.5c0,10.4-8.4,18.7-18.8,18.8 h-0.4C8.4,183.1,0,174.7,0,164.3L0,0z"/>
+ </mask>
+</defs>
+<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="18645.6504" y1="-5838.8398" x2="18645.6504" y2="-6533.2598" gradientTransform="matrix(-1 0 0 1 19345.6504 6388.0703)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0"/>
+ <stop offset="1" style="stop-color:#3AADAA"/>
+</linearGradient>
+<g style="mask:url(#SVGID_3_)">
+ <rect class="st0" width="1400" height="570"/>
+</g>
+<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="18640.3203" y1="-6558.2705" x2="18626.9336" y2="-7232.4897" gradientTransform="matrix(-1 0 0 1 19345.6504 6388.0698)">
+ <stop offset="0" style="stop-color:white"/>
+ <stop offset="1" style="stop-color:white;stop-opacity:0"/>
+</linearGradient>
+<path class="st2" d="M0,130.1c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1l0.5-76.8c2.1-9.7,10.7-16.7,20.6-16.7H64 c11.6,0.1,21,9.5,21,21.1V107c0,11.6,9.4,21,21,21h0.5c11.6-0.1,20.9-9.4,21-21V89.6c0-11.6,9.4-21,21-21.1h0.5 c11.6,0.1,21,9.5,21,21.1v54.6c0,11.6,9.4,21,21,21.1h0.5c11.6,0,21-9.5,21-21.1v-8.4c0-11.6,9.4-21,21-21.1h0.5 c11.6,0.1,21,9.5,21,21.1v44.8c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1v-3.1c0-11.6,9.4-21,21-21.1h0.5 c9.9,0,18.5,7,20.6,16.8v62.7c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1l0.5-76.8c2.1-9.8,10.8-16.8,20.8-16.7h0.5 c11.6,0.1,21,9.5,21,21.1v49.4c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1v-17.5c0-11.6,9.4-21,21-21.1h0.5 c10.3,0,19,7.5,20.7,17.6l0.4,65c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1v-26.2c2.1-9.7,10.6-16.7,20.6-16.8h0.5 c11.6,0.1,21,9.5,21,21.1v3.1c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1v-44.8c0-11.6,9.4-21,21-21.1h0.4 c11.6,0.1,21,9.5,21,21.1v8.4c0,11.6,9.4,21,21,21.1h0.5c11.5,0,20.8-9.3,21-20.7v-3.3c0-11.6,9.4-21,21-21.1h0.5 c10.9,0,19.9,8.3,20.9,19.1c0.1,0.7,0.1,19.6,0.1,19.6c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1v-49.4 c0-11.6,9.4-21,21-21.1h0.5c9.9,0,18.5,7,20.6,16.7l0.5,76.8c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1v-62.7 c2.1-9.7,10.6-16.7,20.6-16.8h0.5c11.6,0.1,21,9.5,21,21.1v3.1c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1v-44.8 c0-11.6,9.4-21,21-21.1h0.4c11.6,0.1,21,9.5,21,21.1v8.4c0,11.6,9.4,21,21,21.1h0.4c11.6-0.1,21-9.5,21-21.1V93.2l0,0 c0.1-11.6,9.4-20.9,21-21h0.4c11.6,0.1,20.9,9.4,21,21v17.5c0.1,11.6,9.4,20.9,21,21h0.5c11.6-0.1,20.9-9.4,21-21V61.3 c0-11.6,9.4-21,21-21.1h0.5c9.9,0,18.5,7,20.6,16.7l0.5,76.8c0,11.6,9.4,21,21,21.1h0.4c11.6-0.1,21-9.5,21-21.1V71 c2.1-9.7,10.6-16.7,20.6-16.7h0.5c11.6,0.1,21,9.5,21,21.1v3.1c0.1,11.6,9.4,20.9,21,21h0.5c11.6-0.1,20.9-9.4,21-21V33.7 c0-11.6,9.4-21,21-21.1h0.4c11.6,0.1,21,9.5,21,21.1v8.4c0,11.6,9.4,21,21,21.1h0.5c11.6-0.1,21-9.5,21-21.1V0H0.1L0,130.1z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Rainy/06.svg b/addons/web_editor/static/shapes/Rainy/06.svg
new file mode 100644
index 00000000..89c9a282
--- /dev/null
+++ b/addons/web_editor/static/shapes/Rainy/06.svg
@@ -0,0 +1,29 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <line x1="1351.39" y1="78.27" x2="1247.64" y2="182.02" style="fill: none;stroke: #F6F6F6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="1154.71" y1="184.46" x2="1128.35" y2="210.82" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+ <line x1="647.52" y1="100.54" x2="494.36" y2="253.7" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+ <line x1="619.32" y1="232.3" x2="440.32" y2="411.29" style="fill: none;stroke: #F6F6F6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 12px"/>
+ <line x1="1018.48" y1="238.8" x2="973.98" y2="283.31" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 30px"/>
+ <line x1="795.71" y1="184.78" x2="704.65" y2="275.85" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+ <line x1="865.18" y1="30.26" x2="738.04" y2="157.39" style="fill: none;stroke: #F6F6F6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="460.27" y1="175.69" x2="304.69" y2="331.27" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="121.19" y1="261.8" x2="-34.39" y2="417.38" style="fill: none;stroke: #F6F6F6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="376.56" y1="141.96" x2="324.25" y2="192.37" style="fill: none;stroke: #F6F6F6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 30px"/>
+ <line x1="-27.1" y1="112.28" x2="61.91" y2="23.27" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 30px"/>
+ <line x1="1290.66" y1="58.02" x2="1313.28" y2="35.4" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+ <line x1="11.59" y1="287.15" x2="201.73" y2="97.01" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 4px"/>
+ <line x1="917.69" y1="473.58" x2="1073.27" y2="318" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="153.75" y1="376.92" x2="177.79" y2="352.89" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 10px"/>
+ <line x1="471.15" y1="607.87" x2="593.29" y2="485.73" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="759.7" y1="580.1" x2="859.8" y2="480.1" style="fill: none;stroke: #F6F6F6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 12px"/>
+ <line x1="111.19" y1="544.02" x2="131.58" y2="523.63" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 30px"/>
+ <line x1="205.26" y1="239.31" x2="232.46" y2="212.1" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 6px"/>
+ <line x1="766.15" y1="421.86" x2="843.94" y2="344.07" style="fill: none;stroke: #F6F6F6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 4px"/>
+ <line x1="1120.38" y1="427.5" x2="1357.66" y2="190.22" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="1355.61" y1="315.58" x2="1444.62" y2="226.57" style="fill: none;stroke: #F6F6F6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 30px"/>
+ <line x1="505.03" y1="482.2" x2="559.44" y2="427.8" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+ <line x1="1065.97" y1="741.31" x2="1352.27" y2="455.01" style="fill: none;stroke: #F6F6F6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+ <line x1="259.67" y1="501.44" x2="388.95" y2="372.16" style="fill: none;stroke: #F6F6F6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 4px"/>
+ <line x1="270.71" y1="583.69" x2="307.72" y2="546.68" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+ <line x1="1040.1" y1="596.04" x2="1144.14" y2="492" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Rainy/07.svg b/addons/web_editor/static/shapes/Rainy/07.svg
new file mode 100644
index 00000000..b433a4df
--- /dev/null
+++ b/addons/web_editor/static/shapes/Rainy/07.svg
@@ -0,0 +1,30 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <line x1="163.04" y1="403.13" x2="192.53" y2="373.63" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 30px"/>
+ <line x1="1126.43" y1="45.87" x2="1157.16" y2="15.15" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="6.35" y1="77.88" x2="62.96" y2="21.27" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+ <line x1="725.37" y1="516.93" x2="929.51" y2="312.79" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 12px"/>
+ <line x1="413.43" y1="418.36" x2="502.44" y2="329.35" style="fill: none;stroke: #f6f6f6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 30px"/>
+ <line x1="636.11" y1="346.28" x2="690.52" y2="291.87" style="fill: none;stroke: #f6f6f6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 8px"/>
+ <line x1="559.46" y1="463.08" x2="585.28" y2="437.26" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="938.54" y1="388.38" x2="1094.12" y2="232.8" style="fill: none;stroke: #f6f6f6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="1220.47" y1="359.42" x2="1376.05" y2="203.84" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="950.03" y1="462.71" x2="1004.15" y2="410.85" style="fill: none;stroke: #f6f6f6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+ <line x1="1321.13" y1="416.75" x2="1374.88" y2="363" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 30px"/>
+ <line x1="299.31" y1="326.77" x2="341.43" y2="284.64" style="fill: none;stroke: #f6f6f6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+ <line x1="1240.09" y1="429.55" x2="1284.03" y2="385.62" style="fill: none;stroke: #f6f6f6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 4px"/>
+ <line x1="462.88" y1="106.66" x2="391.05" y2="178.49" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="900.83" y1="35.26" x2="859.75" y2="76.34" style="fill: none;stroke: #f6f6f6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="772.36" y1="-3.11" x2="685.27" y2="83.98" style="fill: none;stroke: #f6f6f6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 12px"/>
+ <line x1="1300.38" y1="101.62" x2="1267.67" y2="134.33" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 30px"/>
+ <line x1="1193.56" y1="324.76" x2="1139.15" y2="379.17" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 6px"/>
+ <line x1="632.66" y1="142.21" x2="554.87" y2="220" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 4px"/>
+ <line x1="278.26" y1="75.79" x2="40.98" y2="313.07" style="fill: none;stroke: #f6f6f6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="70.54" y1="221.16" x2="-18.47" y2="310.17" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 30px"/>
+ <line x1="885.28" y1="195.97" x2="861.25" y2="220" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+ <line x1="167.23" y1="6.59" x2="37.94" y2="135.87" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 4px"/>
+ <line x1="1048.68" y1="153.09" x2="1009.86" y2="191.91" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 4px"/>
+ <line x1="471.15" y1="37.87" x2="593.29" y2="-84.27" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 20px"/>
+ <line x1="1065.97" y1="171.31" x2="1352.27" y2="-114.99" style="fill: none;stroke: #f6f6f6;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+ <line x1="1040.1" y1="26.04" x2="1144.14" y2="-78" style="fill: none;stroke: #3aadaa;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+ <line x1="270.71" y1="13.69" x2="307.72" y2="-23.32" style="fill: none;stroke: #7c6576;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 9px"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Rainy/08.svg b/addons/web_editor/static/shapes/Rainy/08.svg
new file mode 100644
index 00000000..f3ffe286
--- /dev/null
+++ b/addons/web_editor/static/shapes/Rainy/08.svg
@@ -0,0 +1,21 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1400 1400">
+ <defs>
+ <linearGradient id="linear-gradient" x1="8580.65" y1="1132.94" x2="8580.86" y2="1133.27" gradientTransform="translate(-7080023.8 -1371996.47) scale(825.14 1211)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#3aadaa" stop-opacity="0.1"/>
+ <stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
+ </linearGradient>
+ <linearGradient id="linear-gradient-2" x1="8580.43" y1="1132.95" x2="8580.57" y2="1133.15" xlink:href="#linear-gradient"/>
+ <linearGradient id="linear-gradient-3" x1="8581.81" y1="1132.92" x2="8581.91" y2="1133.06" xlink:href="#linear-gradient"/>
+ <linearGradient id="linear-gradient-4" x1="8580.02" y1="1132.94" x2="8580.24" y2="1133.27" gradientTransform="translate(-7079089.8 -1371996.47) scale(825.14 1211)" xlink:href="#linear-gradient"/>
+ <linearGradient id="linear-gradient-5" x1="8581.73" y1="1132.94" x2="8581.94" y2="1133.27" gradientTransform="translate(-8053572.65 -1371996.47) scale(938.49 1211)" xlink:href="#linear-gradient"/>
+ <linearGradient id="linear-gradient-6" x1="8576.37" y1="1132.94" x2="8576.58" y2="1133.27" gradientTransform="translate(-5386592.97 -1371996.47) scale(628.16 1211)" xlink:href="#linear-gradient"/>
+ <linearGradient id="linear-gradient-7" x1="8576.89" y1="1132.91" x2="8577.14" y2="1133.28" gradientTransform="translate(-5386592.97 -1371996.47) scale(628.16 1211)" xlink:href="#linear-gradient"/>
+ </defs>
+ <path d="M142.83,0H289.32L537.15,569.93H390.66Z" style="fill: url(#linear-gradient)"/>
+ <path d="M0,0H73.24L239.9,373.28H166.66Z" style="fill: url(#linear-gradient-2)"/>
+ <path d="M1122.83,0H1253l83.32,186.64H1206.16Z" style="fill: url(#linear-gradient-3)"/>
+ <path d="M561.2,0H707.68L955.51,569.93,809,569.8Z" style="fill: url(#linear-gradient-4)"/>
+ <path d="M214.72,0H417.88L665.71,569.93H462.55Z" style="fill: url(#linear-gradient-5)"/>
+ <path d="M688.69,0h48L984.46,569.8l-48.32-.72Z" style="fill: url(#linear-gradient-6)"/>
+ <path d="M985.44,0h144.24l247.77,569.8-144.55-.72Z" style="fill: url(#linear-gradient-7)"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Rainy/09.svg b/addons/web_editor/static/shapes/Rainy/09.svg
new file mode 100644
index 00000000..98d030d8
--- /dev/null
+++ b/addons/web_editor/static/shapes/Rainy/09.svg
@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <polygon points="1314.33 2.04 1184.19 2.04 1399.36 555.61 1399.36 220.81 1314.33 2.04" style="fill: #3aadaa;opacity: 0.04"/>
+ <polygon points="287.56 1140 0 395.53 0 730.33 157.43 1140 287.56 1140" style="fill: #3aadaa;opacity: 0.04"/>
+ <path d="M146.17.33H292.66L750.84,1140H604.35Z" style="fill: #3aadaa;opacity: 0.04"/>
+ <path d="M3.34.2H76.59L532.8,1140H459.55Z" style="fill: #3aadaa;opacity: 0.04"/>
+ <polygon points="197.18 1140 0 656.27 0 975.19 67.04 1140 197.18 1140" style="fill: #3aadaa;opacity: 0.04"/>
+ <path d="M564.54,0H711L1169.2,1140H1022.65Z" style="fill: #3aadaa;opacity: 0.04"/>
+ <path d="M761.7,0H908.19l458.17,1140H1219.81Z" style="fill: #3aadaa;opacity: 0.04"/>
+ <path d="M218.07.33H421.23L879.4,1140H676.24Z" style="fill: #3aadaa;opacity: 0.04"/>
+ <path d="M692,0h48l458.12,1140h-48.33Z" style="fill: #3aadaa;opacity: 0.04"/>
+ <polygon points="1128.02 0.33 983.79 0.33 1399.36 1033.34 1399.36 674.76 1128.02 0.33" style="fill: #3aadaa;opacity: 0.04"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/01.svg b/addons/web_editor/static/shapes/Wavy/01.svg
new file mode 100644
index 00000000..27037a91
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/01.svg
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 -840 1400 1400" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:url(#SVGID_1_);}
+ .st1{fill:#FFFFFF;fill-opacity:0.3;}
+ .st2{fill:#FFFFFF;fill-opacity:0.35;}
+ .st3{fill:url(#SVGID_2_);}
+</style>
+<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="11045.0801" y1="1173.1815" x2="11045.0801" y2="1173.4854" gradientTransform="matrix(-1920 0 0 165.28 21207254 -193451.7031)">
+ <stop offset="0" style="stop-color:#FFFFFF"/>
+ <stop offset="1" style="stop-color:#FFFFFF"/>
+</linearGradient>
+<path class="st0" d="M0,560v-43.9c264.3-45.2,505-41.8,722,10.2c265.1,63.5,491.1,36.1,678-82.3v116H0z"/>
+<path class="st1" d="M0,560v-45c196.1-53,439.9-50,731.5,9c356.8,72.3,668.5-80,668.5-80v116H0z"/>
+<path class="st1" d="M0,560v-45c271.3-71.3,513.5-78.2,726.4-20.7c266.6,72,491.1,55.2,673.6-50.3v116H0z"/>
+<path class="st2" d="M0,560v-42c291.1-78.6,541.7-91.9,751.9-39.9C1067.2,556,1302,492.6,1400,444v116H0z"/>
+<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="645.858" y1="-13971.1143" x2="790.134" y2="-13027.3477" gradientTransform="matrix(1 0 0 1 0 13896.9502)">
+ <stop offset="0" style="stop-color:#FFFFFF;stop-opacity:0"/>
+ <stop offset="9.000000e-02" style="stop-color:#FFFFFF;stop-opacity:3.000000e-02"/>
+ <stop offset="0.22" style="stop-color:#FFFFFF;stop-opacity:0.1"/>
+ <stop offset="0.37" style="stop-color:#FFFFFF;stop-opacity:0.23"/>
+ <stop offset="0.54" style="stop-color:#FFFFFF;stop-opacity:0.41"/>
+ <stop offset="0.73" style="stop-color:#FFFFFF;stop-opacity:0.63"/>
+ <stop offset="0.93" style="stop-color:#FFFFFF;stop-opacity:0.9"/>
+ <stop offset="1" style="stop-color:#FFFFFF"/>
+</linearGradient>
+<rect class="st3" width="1400" height="560"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/02.svg b/addons/web_editor/static/shapes/Wavy/02.svg
new file mode 100644
index 00000000..d137c8a8
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/02.svg
@@ -0,0 +1,20 @@
+<svg id="Waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1400 1400">
+ <defs>
+ <linearGradient id="linear-gradient" x1="605.42" y1="13882.68" x2="737.52" y2="13018.52" gradientTransform="matrix(1, 0, 0, -1, 0, 13548.95)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#ffffff"/>
+ <stop offset="0.1" stop-color="#ffffff" stop-opacity="0.82"/>
+ <stop offset="0.23" stop-color="#ffffff" stop-opacity="0.61"/>
+ <stop offset="0.37" stop-color="#ffffff" stop-opacity="0.42"/>
+ <stop offset="0.5" stop-color="#ffffff" stop-opacity="0.27"/>
+ <stop offset="0.63" stop-color="#ffffff" stop-opacity="0.15"/>
+ <stop offset="0.76" stop-color="#ffffff" stop-opacity="0.07"/>
+ <stop offset="0.88" stop-color="#ffffff" stop-opacity="0.02"/>
+ <stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
+ </linearGradient>
+ </defs>
+ <rect width="1400" height="570" style="fill: url(#linear-gradient)"/>
+ <path d="M1400,0V14.6q-230.7,148.8-669.7,81C496,59.3,251.5,76.2,0,146.2V0Z" style="fill: #ffffff"/>
+ <path d="M1400,0h0C1216.5,125.2,995.5,171.7,738,132.6,264.4,60.8,0,146.2,0,146.2V0Z" style="fill: #ffffff;fill-opacity: 0.349999994039536"/>
+ <path d="M1400,0V22.1Q1148,202.25,746,146.3c-330.4-46-601.2-36.5-746,50.1V0Z" style="fill: #ffffff;fill-opacity: 0.170000001788139"/>
+ <path d="M1400,0V22.1q-244.65,159-661.7,94.1C460.5,73,213.3,90,0,167.2V0Z" style="fill: #ffffff;fill-opacity: 0.449999988079071"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/03.svg b/addons/web_editor/static/shapes/Wavy/03.svg
new file mode 100644
index 00000000..59af6ef5
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/03.svg
@@ -0,0 +1,20 @@
+<svg id="Waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1400 1400">
+ <defs>
+ <linearGradient id="linear-gradient" x1="10519.02" y1="-581.49" x2="10518.92" y2="-582.19" gradientTransform="translate(-18081425.5 463943.89) scale(1719 796.99)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#3aadaa"/>
+ <stop offset="1" stop-color="#7c6576"/>
+ </linearGradient>
+ <linearGradient id="linear-gradient-2" x1="10519.03" y1="-581.94" x2="10518.93" y2="-582.65" gradientTransform="translate(-18081425.5 449715.77) scale(1719 771.86)" xlink:href="#linear-gradient"/>
+ <linearGradient id="linear-gradient-3" x1="10519.02" y1="-582.86" x2="10518.92" y2="-583.56" gradientTransform="translate(-18081425.5 423970.22) scale(1719 726.56)" xlink:href="#linear-gradient"/>
+ <linearGradient id="linear-gradient-4" x1="10519.02" y1="-581.73" x2="10518.92" y2="-582.43" gradientTransform="translate(-18081425.5 456496.12) scale(1719 783.89)" xlink:href="#linear-gradient"/>
+ <linearGradient id="linear-gradient-5" x1="10519.02" y1="-583.11" x2="10518.93" y2="-583.73" gradientTransform="translate(-18081425.5 419548.87) scale(1719 718.79)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#3aadaa"/>
+ <stop offset="0.82" stop-color="#7c6576"/>
+ </linearGradient>
+ </defs>
+ <path d="M0,0V570q140-90.58,280-106.62c93.33-10.68,186.67-50,280-63.79S746.67,320.69,840,285s186.67-41.11,280-16.25,186.67-82,280-197.35V0Z" style="opacity: 0.200000002980232;isolation: isolate;fill: url(#linear-gradient)"/>
+ <path d="M0,0V552q140-61.47,280-78.81c124.94-15.47,276.17-90.87,392-142.9,41.89-18.81,80.58-48.29,126.27-53,18-1.84,36.22.37,54.22,2.56,50.31,6.15,86.66,8.63,136.34-3.91C1204.08,221.65,1306.67,73,1400,71.4V0Z" style="opacity: 0.400000005960465;isolation: isolate;fill: url(#linear-gradient-2)"/>
+ <path d="M0,0V519.63q140-34.39,280-55A1323.85,1323.85,0,0,0,482.88,418c52.67-16.48,105.39-54,146-92.72a228.65,228.65,0,0,1,64.4-43.18c7.85-3.51,16.23-6.62,24.8-6,23.6,1.72,33,23.45,45,40.37,78.16,111,195.49,55.27,271.4-13.94,58-52.94,272.19-272.48,365.52-252.77V0Z" style="opacity: 0.300000011920929;isolation: isolate;fill: url(#linear-gradient-3)"/>
+ <path d="M0,0V560.63q140-85.81,280-108.82,112.17-18.44,225.05-32.18c65.77-8,98.82-33.36,147.59-84.43,29.86-31.26,53.06-68.59,84.47-98.29C747.54,227,761,217.49,775,220.32c12,2.41,21.24,12.44,27.2,23.1,53.56,95.89,124,95.84,178.59,69.1C1220.56,195.13,1191.17,209.82,1400,71.4V0Z" style="opacity: 0.119999997317791;isolation: isolate;fill: url(#linear-gradient-4)"/>
+ <path d="M0,0V439.85a1160.44,1160.44,0,0,0,280-7.12c101.45-14.87,227.12-30.31,308.41-97.84L709.58,234.22c18.3-15.21,39.61-31.35,63.21-28.3,28.74,3.72,45.41,33.13,65.77,53.75C913.81,335.85,1273.29,105.21,1400,2.51V0Z" style="fill: url(#linear-gradient-5)"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/04.svg b/addons/web_editor/static/shapes/Wavy/04.svg
new file mode 100644
index 00000000..d3cc0f5a
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/04.svg
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 -1079.7 1400 1400" xml:space="preserve">
+<style type="text/css">
+ .st0{opacity:0.2;fill:#3AADAA;enable-background:new ;}
+ .st1{opacity:0.5;fill:#3AADAA;enable-background:new ;}
+ .st2{fill:url(#SVGID_1_);}
+</style>
+<path class="st0" d="M1400,106.5c-289-129.3-354.6-112.7-741.9,0c-351.6,102.3-518.9,68.4-658.1,0v213.7l1400,0.1V106.5z"/>
+<path class="st1" d="M1400,67.3C1159-71.8,908.3,35,727.3,115.7C476.3,227.6,196.1,238.8,0,106.5v213.8h1400V67.3z"/>
+<path class="st1" d="M1400,106.5C1248.7,9.6,1077.8-9.3,747.9,128C540.7,214.2,121.5,207.2,0,106.5v213.7l0,0l1400,0.1V106.5z"/>
+<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="710.3524" y1="323.7249" x2="685.2361" y2="-803.1566" gradientTransform="matrix(1 0 0 -1 0 72.6983)">
+ <stop offset="0" style="stop-color:#383E45"/>
+ <stop offset="0.5071" style="stop-color:#3AADAA"/>
+ <stop offset="0.6677" style="stop-color:#383E45"/>
+</linearGradient>
+<path class="st2" d="M1400,106.5C1007.7-56.2,874.6,207.2,364.5,211.7C105.9,214,0.8,106.7,0,106.5v213.7l1400,0.1V106.5z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/05.svg b/addons/web_editor/static/shapes/Wavy/05.svg
new file mode 100644
index 00000000..dd3173ae
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/05.svg
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1400 1400" xml:space="preserve">
+<style type="text/css">
+ .st0{opacity:0.2;fill:#3AADAA;enable-background:new ;}
+ .st1{opacity:0.25;fill:#3AADAA;enable-background:new ;}
+ .st2{opacity:0.31;fill:#3AADAA;enable-background:new ;}
+ .st3{fill:url(#SVGID_1_);}
+</style>
+<path class="st0" d="M1400,250.4c-264-76.2-491.7-117-794.2,34.6C292.3,442.3,77.5,327.5,0,250.4V0l1400,0.1V250.4z"/>
+<path class="st1" d="M1400,250.4c-187-86.6-313.7-107-620.1,24.5C548.2,374.4,290.1,385.4,0,250.4V0l1400,0.1V250.4z"/>
+<path class="st2" d="M1400,250.4c-299.1-83.6-453.1-70.7-701.1,34.6C383.7,419.1,94.2,320.6,0,250.4V0l0,0l1400,0.1V250.4z"/>
+<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="715.5229" y1="1143.2604" x2="690.4067" y2="16.379" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#383E45"/>
+ <stop offset="0.507" style="stop-color:#3AADAA"/>
+ <stop offset="0.99" style="stop-color:#383E45"/>
+</linearGradient>
+<path class="st3" d="M1400,250.4c-208.8-131.2-520.5-98.8-829.9,28C339.4,372.8,69.2,312.7,0,250.4V0l1400,0.1V250.4z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/06.svg b/addons/web_editor/static/shapes/Wavy/06.svg
new file mode 100644
index 00000000..da58b7c9
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/06.svg
@@ -0,0 +1,44 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1400 1400">
+ <defs>
+ <filter id="drop-shadow" y="-50%" height="200%">
+ <feGaussianBlur in="SourceAlpha" stdDeviation="10" />
+ <feOffset dx="0" dy="-4" result="offsetblur" />
+ <feFlood flood-color="rgba(0,0,0,0.4)" />
+ <feComposite in2="offsetblur" operator="in" />
+ <feMerge>
+ <feMergeNode />
+ <feMergeNode in="SourceGraphic" />
+ </feMerge>
+ </filter>
+ <mask maskUnits="userSpaceOnUse" x="0" y="0" width="1400" height="500" id="SVGID_1_">
+ <rect style="fill:white" width="1400" height="550"/>
+ </mask>
+ <linearGradient id="linear-gradient" x1="700.51" y1="8915.67" x2="699.42" y2="9429.18"
+ gradientTransform="matrix(1, 0, 0, -1, 0, 9440.95)" gradientUnits="userSpaceOnUse">
+ <stop offset="0.11" stop-color="#3aadaa" />
+ <stop offset="1" stop-color="#383e45" />
+ </linearGradient>
+ <linearGradient id="linear-gradient-2" x1="904.49" y1="9586.58" x2="351.14" y2="8214.94"
+ gradientTransform="matrix(1, 0, 0, -1, 0, 9440.95)" gradientUnits="userSpaceOnUse">
+ <stop offset="0.8" stop-color="#3aadaa" />
+ <stop offset="0.97" stop-color="#383e45" />
+ </linearGradient>
+ <linearGradient id="linear-gradient-4" x1="622.62" y1="8824.69" x2="1098.85" y2="10216.47"
+ gradientTransform="matrix(1, 0, 0, -1, 0, 9440.95)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#3aadaa" />
+ <stop offset="0.99" stop-color="#383e45" />
+ </linearGradient>
+ </defs>
+ <g style="mask:url(#SVGID_1_)">
+ <polygon points="1400,471.7 0,470 0,0 1400,0" style="fill: url(#linear-gradient)" />
+ <path d="M0,156.2C89.9,77.9,390.4-46,695,195.9c232,184.2,528.2,252.1,705,157.9v53.2H0V568V156.2z"
+ style="fill: url(#linear-gradient-2); filter: url(#drop-shadow)" />
+ <path d="M1037.6,418.4c137.4,29.3,241.9,7.7,325.9-24c-237.3-114-374.4-94.2-529.3-47.5
+ C899.6,377.3,966.9,403.3,1037.6,418.4z"
+ style="fill: #FFFFFF; filter: url(#drop-shadow)" />
+ <path d="M0,212.2c401.1-238,673.5,128.4,1037.6,206.2c158.1,33.8,272.6,0,362.4-38.7v92H0V212.2z"
+ style="fill: url(#linear-gradient-4); filter: url(#drop-shadow)" />
+ <path d="M0,287c283.4-184.2,559.7,140.5,1066,174.3c175.6,11.7,333.1-48.9,334-49.3v140H0V287z"
+ style="fill:#F6F6F6;filter: url(#drop-shadow)" />
+ </g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/06_001.svg b/addons/web_editor/static/shapes/Wavy/06_001.svg
new file mode 100644
index 00000000..8a4482d4
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/06_001.svg
@@ -0,0 +1,45 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1400 1400">
+ <defs>
+ <filter id="drop-shadow" y="-50%" height="200%">
+ <feGaussianBlur in="SourceAlpha" stdDeviation="10" />
+ <feOffset dx="0" dy="-4" result="offsetblur" />
+ <feFlood flood-color="rgba(0,0,0,0.4)" />
+ <feComposite in2="offsetblur" operator="in" />
+ <feMerge>
+ <feMergeNode />
+ <feMergeNode in="SourceGraphic" />
+ </feMerge>
+ </filter>
+ <mask maskUnits="userSpaceOnUse" x="0" y="0" width="1400" height="500" id="SVGID_1_">
+ <rect style="fill:white" width="1400" height="550"/>
+ <path d="M-1,286c283.4-184.2,559.7,140.5,1066,174.3c175.6,11.7,333.1-48.9,336-49.3v1400H-1V287z" style="fill:black"/>
+ </mask>
+ <linearGradient id="linear-gradient" x1="700.51" y1="8915.67" x2="699.42" y2="9429.18"
+ gradientTransform="matrix(1, 0, 0, -1, 0, 9440.95)" gradientUnits="userSpaceOnUse">
+ <stop offset="0.11" stop-color="#3aadaa" />
+ <stop offset="1" stop-color="#383e45" />
+ </linearGradient>
+ <linearGradient id="linear-gradient-2" x1="904.49" y1="9586.58" x2="351.14" y2="8214.94"
+ gradientTransform="matrix(1, 0, 0, -1, 0, 9440.95)" gradientUnits="userSpaceOnUse">
+ <stop offset="0.8" stop-color="#3aadaa" />
+ <stop offset="0.97" stop-color="#383e45" />
+ </linearGradient>
+ <linearGradient id="linear-gradient-4" x1="622.62" y1="8824.69" x2="1098.85" y2="10216.47"
+ gradientTransform="matrix(1, 0, 0, -1, 0, 9440.95)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#3aadaa" />
+ <stop offset="0.99" stop-color="#383e45" />
+ </linearGradient>
+ </defs>
+ <g style="mask:url(#SVGID_1_)">
+ <polygon points="1400,471.7 0,470 0,0 1400,0" style="fill: url(#linear-gradient)" />
+ <path d="M0,156.2C89.9,77.9,390.4-46,695,195.9c232,184.2,528.2,252.1,705,157.9v53.2H0V568V156.2z"
+ style="fill: url(#linear-gradient-2); filter: url(#drop-shadow)" />
+ <path d="M1037.6,418.4c137.4,29.3,241.9,7.7,325.9-24c-237.3-114-374.4-94.2-529.3-47.5
+ C899.6,377.3,966.9,403.3,1037.6,418.4z"
+ style="fill: #F6F6F6; filter: url(#drop-shadow)" />
+ <path d="M0,212.2c401.1-238,673.5,128.4,1037.6,206.2c158.1,33.8,272.6,0,362.4-38.7v92H0V212.2z"
+ style="fill: url(#linear-gradient-4); filter: url(#drop-shadow)" />
+ <path d="M0,287c283.4-184.2,559.7,140.5,1066,174.3c175.6,11.7,333.1-48.9,334-49.3v140H0V287z"
+ style="filter: url(#drop-shadow)" />
+ </g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/07.svg b/addons/web_editor/static/shapes/Wavy/07.svg
new file mode 100644
index 00000000..0530d06c
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/07.svg
@@ -0,0 +1,13 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1400 1400">
+ <filter id="drop-shadow">
+ <feGaussianBlur in="SourceAlpha" stdDeviation="3"/>
+ <feOffset dx="0" dy="3" result="offsetblur"/>
+ <feFlood flood-color="rgba(0,0,0,0.3)"/>
+ <feComposite in2="offsetblur" operator="in"/>
+ <feMerge>
+ <feMergeNode/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+ <path d="M0,134.2C283.4,274.6,559.7,27.7,1066,2c175.6-8.9,333.1,36.9,334,37.2V-10H0V134.2z" style="fill: #f6f6f6; filter: url(#drop-shadow);"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/08.svg b/addons/web_editor/static/shapes/Wavy/08.svg
new file mode 100644
index 00000000..39eced0c
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/08.svg
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1400 1400" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:url(#SVGID_1_);}
+ .st1{opacity:0.22;fill:url(#SVGID_2_);enable-background:new ;}
+ .st2{fill:url(#SVGID_3_);}
+</style>
+<radialGradient id="SVGID_1_" cx="700.05" cy="6519.4429" r="734.1025" fx="-9.7756" fy="6332.231" gradientTransform="matrix(1 0 0 -1 0 6708.9502)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" style="stop-color:#7C6576"/>
+ <stop offset="0.98" style="stop-color:#7C6576;stop-opacity:0.3"/>
+</radialGradient>
+<path class="st0" d="M0,245c0,0,84.1,25.3,239.7-51.1c112-55,271.8-35.4,314.3-2c142.4,111.8,138.5,138.7,249,148
+ s155.6-59.1,286.3-39.4c65.4,9.9,172.7,129.3,310.8,53.4V0H0V245z"/>
+<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="303.6805" y1="6686.5811" x2="1025.8474" y2="6284.4497" gradientTransform="matrix(1 0 0 -1 0 6708.9502)">
+ <stop offset="0" style="stop-color:#7C6576"/>
+ <stop offset="1" style="stop-color:#7C6576"/>
+</linearGradient>
+<path class="st1" d="M0,486c57.8-4.9,277-192.5,390.6-225s211.9-37.5,420.2,59.2c104.2,48.4,195.2,29.7,240.9,12.9
+ c61.5-22.5,156.9-161,348.3-106.7V0H0l-0.2,486"/>
+<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="699.95" y1="580.0013" x2="699.95" y2="-31.0112" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="9.365156e-02" style="stop-color:#7C6576"/>
+ <stop offset="0.3846" style="stop-color:#7C6576;stop-opacity:0.5412"/>
+ <stop offset="0.7278" style="stop-color:#7C6576;stop-opacity:0"/>
+</linearGradient>
+<path class="st2" d="M0,273.3c62.5,34.5,113.6,11.9,138.5,0s148.2-130.8,263-134.2c107.8-3.2,135.4,37.5,222.5,117.4
+ s158.7,63.3,196,39.5s113.2-98.8,234.7-40.5s110.2,49.3,137.1,48.4c28.5-1,84.1-46.9,129.1-64.1s79-6.9,79-6.9V0H0V273.3z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/09.svg b/addons/web_editor/static/shapes/Wavy/09.svg
new file mode 100644
index 00000000..9a6ff367
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/09.svg
@@ -0,0 +1,19 @@
+<svg id="Waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 -830 1400 1400">
+ <defs>
+ <linearGradient id="linear-gradient" x1="-1619.24" y1="6536.52" x2="2880.89" y2="4684.49" gradientTransform="matrix(1, 0, 0, -1, 0, 6020.95)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#3aadaa"/>
+ <stop offset="0.99" stop-color="#383e45"/>
+ </linearGradient>
+ <linearGradient id="linear-gradient-2" x1="-281.64" y1="5943.99" x2="2700.98" y2="4772.25" gradientTransform="matrix(1, 0, 0, -1, 0, 6020.95)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#3aadaa"/>
+ <stop offset="0.99"/>
+ </linearGradient>
+ <linearGradient id="linear-gradient-3" x1="-439.37" y1="6258.49" x2="1539.51" y2="5035.85" xlink:href="#linear-gradient-2"/>
+ </defs>
+ <path d="M1400,570H1.13V416.16C1.13,378.93,0,302.68,0,302.68S98.19,106,257.36,106c61.3,0,117,38,163.13,78.44S504.08,274,542.18,322c16.2,20.48,35.35,42.4,61.33,45,9,.9,18.09-.7,27-2.29,26.61-4.74,54.2-12.68,81-15,91.36-7.8,183.26,55.36,270.67,27.69,26.89-8.51,50.55-25.09,77-34.75,38.82-14.15,82.82-12.51,121.17,2.88,33,13.23,105.79,38.7,138.69,52.06,11,4.47,42.66-.71,50.2-32.37,11.15-46.88,30.73-56.93,30.73-56.93V570Z" style="opacity: 0.3;isolation: isolate;fill: url(#linear-gradient)"/>
+ <path d="M0,371.52S64.07,295.57,155.93,266s206.86-40.41,323.85,96.73C565.91,463.73,668,475.9,752.25,411.1c130.17-100.17,250.67-35.54,294.27-2s105.35,40.06,201.74-14.81c73.26-41.71,85.52-55,151.74-39V570H.24Z" style="fill: url(#linear-gradient-2)"/>
+ <path d="M1400,362.82c-61.32-20.36-111.17,15.54-199.28,67.11-87.5,51.21-168.06-7-211.7-31.7S856.63,361.7,741.37,445.29,523.31,440.67,496.8,418,396.2,292.22,258.54,295.57,0,439.69,0,439.69V570H1399.81Z" style="opacity: 0.3;isolation: isolate;fill: url(#linear-gradient-3)"/>
+ <path d="M0,413s77.49-38.25,129.46-41.51c211.75-13.28,290,112.59,387.64,146.42,246,99.43,269.4-100,457.8-107.87,110.12-4.61,169.7,63.18,264.68,62.19S1400,410,1400,410V570H.24Z" style="fill: #3aadaa;opacity: 0.639999985694885;isolation: isolate"/>
+ <path d="M0,477.2S139.1,338.88,273.1,342c208.39,4.8,195.33,179.66,371.76,175.93,72.64-4.3,116.11-51.37,169.32-81.88,128.51-73.69,202.68,16.26,268.68,31.81,99.44,23.42,127.41-24,227.18-79.35,13.4-7.43,41.87-22.45,90-12.45V570H.24Z" style="fill: #3aadaa;opacity: 0.29;isolation: isolate"/>
+ <path d="M1239.6,472.2c-20.9,0.2-40-2.9-58.3-7.8c-27.2,10.6-56.5,13.3-98.4,3.4c-38.3-9-79.3-43-132-55.7C784,435,752.2,612.9,517.1,517.9c-93.9-32.5-169.8-150.1-363.7-147.3C69.2,409.4,2.9,474.3,0.1,477.1L0.2,570H1400V410C1400,410,1334.6,471.2,1239.6,472.2z" style="fill: #3aadaa;isolation: isolate"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/10.svg b/addons/web_editor/static/shapes/Wavy/10.svg
new file mode 100644
index 00000000..27a596f2
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/10.svg
@@ -0,0 +1,19 @@
+<svg id="Waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1400 570">
+ <defs>
+ <linearGradient id="linear-gradient" x1="10389.33" y1="-7.09" x2="10390.32" y2="-7.09" gradientTransform="translate(-14659339.74 3842.12) scale(1411 499.7)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#3aadaa"/>
+ <stop offset="1" stop-color="#7c6576"/>
+ </linearGradient>
+ <linearGradient id="linear-gradient-2" x1="10389.33" y1="-7.27" x2="10390.32" y2="-7.27" gradientTransform="translate(-14659339.74 3868.84) scale(1411 490.78)" xlink:href="#linear-gradient"/>
+ <linearGradient id="linear-gradient-3" x1="10389.33" y1="-7.83" x2="10390.32" y2="-7.83" gradientTransform="translate(-14659339.74 3866.9) scale(1411 457.54)" xlink:href="#linear-gradient"/>
+ <linearGradient id="linear-gradient-4" x1="10389.33" y1="-7" x2="10390.32" y2="-7" gradientTransform="translate(-14659339.74 3861.89) scale(1411 506.53)" xlink:href="#linear-gradient"/>
+ <linearGradient id="linear-gradient-5" x1="10389.33" y1="-6.84" x2="10390.32" y2="-6.84" gradientTransform="translate(-14659339.74 3865.1) scale(1411 518.74)" xlink:href="#linear-gradient"/>
+ <linearGradient id="linear-gradient-6" x1="10389.33" y1="-6.98" x2="10390.32" y2="-6.98" gradientTransform="translate(-14659339.74 3865.87) scale(1411 508.27)" xlink:href="#linear-gradient"/>
+ </defs>
+ <path d="M0,378.62A2593.46,2593.46,0,0,1,350,346.2Q525,342,700,407.38t350,69.51Q1225,481,1400,498V184q-175-10.27-350-17.83T700,109Q525,59.36,350,220T0,197Z" style="opacity: 0.400000005960465;isolation: isolate;fill: url(#linear-gradient)"/>
+ <path d="M0,408q175-19.81,350-22.2t350,41.06a2062.1,2062.1,0,0,0,350,56,2451.17,2451.17,0,0,0,350,.06V114q-175,56.69-350,53.15a2159.49,2159.49,0,0,1-350-36Q525,98.7,350,170.85T0,197Z" style="opacity: 0.4;isolation: isolate;fill: url(#linear-gradient-2)"/>
+ <path d="M0,380.64Q175,428.81,350,426t350-13q175-10.14,350,29.13T1400,408V134.26q-175-15.37-350-18.94t-350,91.4q-175,95-350,10T0,225.88Z" style="opacity: 0.4;isolation: isolate;fill: url(#linear-gradient-3)"/>
+ <path d="M0,422.08q175-54.25,350-57.23t350,97q175,100,350-32.43t350,8.17V158.29q-175,3.84-350-2.83T700,132q-175-16.8-350,35.52T0,214.59Z" style="opacity: 0.4;isolation: isolate;fill: url(#linear-gradient-4)"/>
+ <path d="M0,346.76q175,80.73,350,71.49a2173.62,2173.62,0,0,0,350-47.47q175-38.23,350,81.54t350,17.88V131q-175,74-350,67.53T700,147.16q-175-44.89-350-25.93T0,199.84Z" style="opacity: 0.400000005960465;isolation: isolate;fill: url(#linear-gradient-5)"/>
+ <path d="M0,361.71q175-.13,350-5.91t350,41.72Q875,445,1050,410.88t350,95.69V125.81q-175,51.42-350,42.9T700,201.62q-175,41.43-350-24.43T0,182.31Z" style="opacity: 0.400000005960465;isolation: isolate;fill: url(#linear-gradient-6)"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/11.svg b/addons/web_editor/static/shapes/Wavy/11.svg
new file mode 100644
index 00000000..e97b4e59
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/11.svg
@@ -0,0 +1,153 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1400 570">
+ <defs>
+ <linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="1072.686" y1="636.4281" x2="1074.1099"
+ y2="637.8519" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="69.1723" y1="52.9513" x2="69.188" y2="52.9223"
+ gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="1105.6154" y1="154.4204" x2="1105.6178"
+ y2="154.4157" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="357.1088" y1="182.44" x2="387.2482"
+ y2="-5.0945" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0.3376" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="1117.5948" y1="160.6083" x2="1117.5953"
+ y2="160.6075" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="5.2234" y1="52.1418" x2="5.2689" y2="52.0576"
+ gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="1317.8413" y1="301.5315" x2="1147.0508"
+ y2="8.5088" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_8_" gradientUnits="userSpaceOnUse" x1="1143.0813" y1="174.5339" x2="1143.0818"
+ y2="174.533" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="1136.5748" y1="170.8951" x2="1136.5756"
+ y2="170.8937" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_10_" gradientUnits="userSpaceOnUse" x1="1123.528" y1="163.7651" x2="1123.5282"
+ y2="163.7648" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_11_" gradientUnits="userSpaceOnUse" x1="1111.0732" y1="157.205" x2="1111.0746"
+ y2="157.2024" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_12_" gradientUnits="userSpaceOnUse" x1="27.7729" y1="52.0499" x2="27.7833"
+ y2="52.0308" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_13_" gradientUnits="userSpaceOnUse" x1="37.2868" y1="52.1243" x2="37.2921"
+ y2="52.1145" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_14_" gradientUnits="userSpaceOnUse" x1="14.8765" y1="52.0434" x2="14.887" y2="52.024"
+ gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_15_" gradientUnits="userSpaceOnUse" x1="42.2324" y1="52.2327" x2="42.2406"
+ y2="52.2175" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_16_" gradientUnits="userSpaceOnUse" x1="1.458" y1="52.2632" x2="1.5479" y2="52.0965"
+ gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_17_" gradientUnits="userSpaceOnUse" x1="57.9686" y1="52.5578" x2="57.9736"
+ y2="52.5486" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_18_" gradientUnits="userSpaceOnUse" x1="52.5934" y1="52.4123" x2="52.6035"
+ y2="52.3936" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_19_" gradientUnits="userSpaceOnUse" x1="44.1631" y1="81.7052" x2="20.7213"
+ y2="42.6356" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0.2536" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_20_" gradientUnits="userSpaceOnUse" x1="1007.7426" y1="353.0724" x2="1079.7424"
+ y2="34.9335" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0.245" style="stop-color:#3AADAA;stop-opacity:0.15" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_21_" gradientUnits="userSpaceOnUse" x1="1044.1539" y1="371.617" x2="1147.6492"
+ y2="245.6057" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.1" />
+ <stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0" />
+ </linearGradient>
+ <linearGradient id="SVGID_22_" gradientUnits="userSpaceOnUse" x1="398.8781" y1="108.9368" x2="395.5292"
+ y2="15.1696" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA;stop-opacity:0.25" />
+ <stop offset="0.9289" style="stop-color:#3AADAA;stop-opacity:0.15" />
+ </linearGradient>
+ <linearGradient id="SVGID_23_" gradientUnits="userSpaceOnUse" x1="1017.0001" y1="182.947" x2="1017.0001"
+ y2="-2.0778" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="1.299100e-02" style="stop-color:#3AADAA;stop-opacity:0.25" />
+ <stop offset="0.8755" style="stop-color:#3AADAA;stop-opacity:0.15" />
+ </linearGradient>
+ </defs>
+ <path style="fill:url(#SVGID_1_)"
+ d="M75,518.8c57.4,22.6,132.1,42.3,224.8,42.3c151.5,0,268.1-49.8,369.5-113.6c37.9,10.4,79.3,20.4,126.1,24.6 c16,1.4,32.7,2.2,50.1,2c130.5-1.2,210-35.7,254.3-53.6c14.9-7.3,31.9-16.5,50.1-26.9l0,0C935.8,421.4,829.1,342,699.8,353 c-105.3,9-278,72.8-400,91C157,465.3,0,473,0,473v47C17.9,520,43.7,520.2,75,518.8z" />
+ <path style="fill:url(#SVGID_2_)" d="M69.5,519.1c-0.2,0-0.4,0-0.6,0C69.1,519.1,69.3,519.1,69.5,519.1z" />
+ <path style="fill:url(#SVGID_3_)"
+ d="M1106.3,417.2c-0.5,0.2-0.9,0.5-1.4,0.7C1105.4,417.7,1105.8,417.5,1106.3,417.2z" />
+ <path style="fill:url(#SVGID_4_)"
+ d="M299.8,561.1c151.5,0,268.1-49.8,369.5-113.6c-70.7-19.4-129.6-40.3-190.3-27.7 c-61.6,12.8-114.6,38.4-179.2,59.9c-83.4,27.8-164.3,36.5-224.8,39.1C132.5,541.4,207.1,561.1,299.8,561.1z" />
+ <path style="fill:url(#SVGID_4_)" d="M1118,411.2c-0.3,0.1-0.6,0.3-0.8,0.4C1117.4,411.5,1117.7,411.3,1118,411.2z" />
+ <path style="fill:url(#SVGID_6_)" d="M4.5,520c0.5,0,1.1,0,1.6,0C5.5,520,5,520,4.5,520z" />
+ <path style="fill:url(#SVGID_7_)"
+ d="M1400,210.5c-49.2,55.1-163.7,133.7-250.1,183.1l0,0c20.9-2.7,42.9-6.4,66-11.4c76.4-16.3,171.7-78.3,183-85.8" />
+ <path style="fill:url(#SVGID_8_)"
+ d="M1143.3,397.4c-0.1,0.1-0.3,0.2-0.4,0.2C1143,397.5,1143.1,397.4,1143.3,397.4z" />
+ <path style="fill:url(#SVGID_9_)" d="M1137,400.9c-0.3,0.2-0.6,0.3-0.8,0.5C1136.4,401.2,1136.7,401,1137,400.9z" />
+ <path style="fill:url(#SVGID_10_)"
+ d="M1124.1,407.9c-0.4,0.2-0.8,0.4-1.2,0.6C1123.3,408.3,1123.7,408.1,1124.1,407.9z" />
+ <path style="fill:url(#SVGID_11_)"
+ d="M1111.6,414.5c-0.4,0.2-0.7,0.4-1.1,0.5C1110.9,414.9,1111.3,414.7,1111.6,414.5z" />
+ <path style="fill:url(#SVGID_12_)" d="M28,520c-0.1,0-0.2,0-0.4,0C27.7,520,27.9,520,28,520z" />
+ <path style="fill:url(#SVGID_13_)" d="M37.4,519.9c-0.1,0-0.1,0-0.2,0C37.3,519.9,37.3,519.9,37.4,519.9z" />
+ <path style="fill:url(#SVGID_14_)" d="M14.7,520c0.1,0,0.2,0,0.4,0C14.9,520,14.8,520,14.7,520z" />
+ <path style="fill:url(#SVGID_15_)" d="M42.4,519.8c-0.1,0-0.2,0-0.3,0C42.2,519.8,42.3,519.8,42.4,519.8z" />
+ <path style="fill:url(#SVGID_16_)" d="M0,520c1,0,2.1,0,3.2,0C2.1,520,1,520,0,520z" />
+ <path style="fill:url(#SVGID_17_)" d="M58.1,519.5c-0.1,0-0.1,0-0.2,0C57.9,519.5,58,519.5,58.1,519.5z" />
+ <path style="fill:url(#SVGID_18_)" d="M52.8,519.6c-0.1,0-0.2,0-0.4,0C52.6,519.6,52.7,519.6,52.8,519.6z" />
+ <path style="fill:url(#SVGID_19_)" d="M75,518.8c-29.5-11.6-54.5-24-75-34.8v36C17.9,520,43.7,520.2,75,518.8z" />
+ <path style="fill:url(#SVGID_20_)"
+ d="M1400,227.7c-80.3-13.6-171.6-70.4-300.2-43.7c-154.6,33.7-274.4,165.2-430.6,263.5l0,0 c37.9,10.4,79.3,20.4,126.1,24.6c16,1.4,32.7,2.2,50.1,2c130.5-1.2,210-35.7,254.3-53.6c14.9-7.3,31.9-16.5,50.1-26.9 c20.9-2.7,42.9-6.4,66-11.4c76.4-16.3,171.7-78.3,183-85.8" />
+ <path style="fill:url(#SVGID_21_)"
+ d="M1397.9,56.5c-80.5-29.1-215-28.3-298,36.2C968,195.3,932.1,352.1,795.4,472.1c16,1.4,32.7,2.2,50.1,2 c130.5-1.2,210-35.7,254.3-53.6c14.9-7.3,31.9-16.5,50.1-26.9c20.9-2.7,42.9-6.4,66-11.4c76.4-16.3,171.7-78.3,183-85.8" />
+ <path style="fill:url(#SVGID_22_)"
+ d="M669.3,447.5c-101.3,63.8-218,113.6-369.5,113.6c-92.7,0-167.3-19.7-224.8-42.3c-31.3,1.4-57.2,1.2-75,1.2v50 h634c67.3-27.3,119.1-60.9,161.4-97.9C748.5,467.9,707.2,457.9,669.3,447.5z" />
+ <path style="fill:url(#SVGID_23_)"
+ d="M1400,569.9l-1.1-273.4c-11.3,7.5-106.6,69.4-183,85.8c-23.1,5-45.1,8.7-66,11.4 c-18.2,10.4-35.2,19.6-50.1,26.9c-44.3,17.9-123.8,52.4-254.3,53.6c-17.4,0.2-34.1-0.6-50.1-2c-42.2,37.1-94.1,70.6-161.4,97.9 L1400,569.9L1400,569.9z" />
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/12.svg b/addons/web_editor/static/shapes/Wavy/12.svg
new file mode 100644
index 00000000..5e911351
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/12.svg
@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 570">
+ <path style="opacity:.08;fill:#3AADAA;" d="M664.2,521.6c-75.6,4-193.4,14.8-304.9,24.3c18.5,2.4,36,4.8,52,7.1c217.7,30.7,328.6,12.5,498.9-16.7
+ C826.5,527.4,760.3,516.5,664.2,521.6z"/>
+ <path style="opacity:.08;fill:#3AADAA;" d="M1400,562.4v-39c0,0-52.1,7.2-124.7,13.6C1347.3,549.1,1400,562.4,1400,562.4z"/>
+ <path style="opacity:.08;fill:#3AADAA;" d="M989,522.9c-27.9,4.6-54,9.1-78.8,13.4c38.8,4.1,81.3,7.9,132.4,9.4c78.1,2.4,164.5-2.8,232.7-8.7
+ C1188.2,522.2,1072.9,509,989,522.9z"/>
+ <path style="opacity:.08;fill:#3AADAA;" d="M0,522.4v41c0,0-3.8,0.8,116.7-1.2c55.7-0.9,146.8-8.1,242.6-16.3C223.4,528.1,35.4,508,0,522.4z"/>
+ <path style="opacity:.15;fill:#3AADAA;" d="M0,0v522.4c35.4-14.4,223.4,5.7,359.3,23.5c111.5-9.5,229.3-20.4,304.9-24.3c96.1-5.1,162.3,5.8,246,14.7
+ c24.8-4.3,50.9-8.8,78.8-13.4c83.9-13.9,199.2-0.7,286.3,14.1c72.6-6.3,124.7-13.6,124.7-13.6V0H0z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/13.svg b/addons/web_editor/static/shapes/Wavy/13.svg
new file mode 100644
index 00000000..d8c218f3
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/13.svg
@@ -0,0 +1,20 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+<path d="M1360.1,531.5L1360.1,531.5c-3.9-3.9-10.2-3.9-14.1,0l0,0l-9.2,9.2c-6.2,6.2-16.3,6.2-22.5,0l0,0l0,0
+ c-6.7-6.7-17.6-6.7-24.4,0l0,0l0,0c-3.9,3.9-10.2,3.9-14.1,0l-12.2-12.2c-6.9-6.9-18.1-6.9-25.1,0l0,0l0,0c-2.8,2.8-7.3,2.8-10.1,0
+ l0,0c-3.9-3.9-10.2-3.9-14.1,0l-12.2,12.2c-3.9,3.9-10.2,3.9-14.1,0l-16-16c-7.7-7.7-20.1-7.8-27.8-0.2l0,0
+ c-7.5,7.3-19.6,7.3-27-0.2l0,0c-7.4-7.4-19.5-7.5-27-0.1l-27.6,27.1c-7.3,7.3-19.2,7.3-26.5,0l0,0c-7.3-7.3-19.1-7.3-26.5,0l0,0
+ l-4.8,4.8c-7.3,7.3-19.2,7.3-26.5,0l-0.9-0.9c-4.7-4.7-12.2-4.7-16.9,0l0,0c-7.8,7.8-20.5,7.8-28.3,0l0,0l-11.6-11.6
+ c-6.9-6.9-18.2-7-25.1,0l0,0l-0.2,0.2c-2.2,2.2-5.8,2.2-8.1,0l-0.2-0.2c-7.8-7.8-20.5-7.8-28.3,0l0,0l-11.8,11.8
+ c-1.9,1.9-4.9,1.9-6.8,0l0,0l-0.3-0.3c-2.6-2.6-6.8-2.7-9.4-0.1c0,0,0,0-0.1,0l-0.2,0.2c-7.8,7.8-20.5,7.8-28.3,0l0,0l-9.4-9.4
+ c-7.8-7.8-20.5-7.8-28.3,0l0,0l-17.8,17.8c-3.1,3.2-8.3,3.2-11.4,0l0,0l-0.1-0.1c-2.5-2.5-6-3.5-9.4-2.7c-4.7,1.1-11.5,1.9-14.9-1.5
+ l-24-24c-11.7-11.7-30.7-11.7-42.4,0l-9.2,9.4c-13.6,13.6-35.7,13.7-49.4,0l0,0l-0.5-0.5c-7.8-7.9-20.5-8-28.4-0.2c0,0,0,0-0.1,0.1
+ l-18.1,18.1c-3.9,3.9-10.2,3.9-14.1,0l-0.5-0.5c-3.9-3.9-10.2-3.9-14.1,0l-0.5,0.5c-3.9,3.9-10.2,3.9-14.1,0l0,0l-6.7-6.7
+ c-7.8-7.8-20.5-7.8-28.3,0l0,0l0,0c-4.3,4.3-10.3,6.4-16.3,5.7c-3.4-0.4-6.8-0.2-8.4,1.4l-0.3,0.3c-7.8,7.8-20.5,7.8-28.3,0
+ L388,544.2c-4.5-4.5-11.8-4.5-16.2,0l0,0l-0.3,0.3c-7.8,7.8-20.5,7.8-28.3,0l0,0l-0.9-0.9c-3.3-3.3-8.6-3.3-11.9,0l0,0
+ c-2.4,2.4-5.7,3.4-9,2.7c-3.5-0.7-8.1-1-10.4,1.4l-0.3,0.3c-11.7,11.7-30.7,11.7-42.4,0l-11.8-11.3c-2.4-2.4-6.4-2.5-8.9,0l0,0
+ l-0.6,0.6c-5.9,5.9-15.4,5.9-21.2,0l0,0l-0.4-0.4c-7.8-7.8-20.5-7.8-28.3,0l-7.2,7.2c-2.1,2.1-5.5,2.1-7.6,0l0,0l0,0
+ c-3.9-3.9-10.2-3.9-14.1,0l-7.6,7.6c-3.9,3.9-10.2,3.9-14.1,0l-14.7-14.8c-3.9-3.9-10.2-3.9-14.1,0l-7.7,7.7
+ c-7.8,7.8-20.5,7.8-28.3,0l0,0l-11.3-11.3c-9.6-9.6-25.2-9.6-34.9,0l0,0c-3.9,3.9-10.2,3.9-14.1,0l0,0l-4.4-4.4
+ c-3.9-3.9-10.2-3.9-14.1,0C1,530.8,0,533.3,0,536v34h1400v-36.1c0-5.5-4.5-10-10-10c-2.5,0-4.9,1-6.8,2.7l-5.4,5
+ C1372.9,536.4,1364.9,536.4,1360.1,531.5L1360.1,531.5z" style="fill:#FFFFFF"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/14.svg b/addons/web_editor/static/shapes/Wavy/14.svg
new file mode 100644
index 00000000..805f576c
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/14.svg
@@ -0,0 +1,26 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 -830 1400 1400">
+ <defs>
+ <clipPath id="clip-path">
+ <rect x="0.42" width="1400" height="570" style="fill: none"/>
+ </clipPath>
+ <linearGradient id="linear-gradient" x1="12667.44" y1="-5269.61" x2="12667.44" y2="-4501.1" gradientTransform="translate(13367.44 -4423.62) rotate(180)" gradientUnits="userSpaceOnUse">
+ <stop offset="0"/>
+ <stop offset="1" stop-color="#3aadaa"/>
+ </linearGradient>
+ <linearGradient id="linear-gradient-2" x1="700" y1="7.37" x2="700" y2="506.48" gradientTransform="matrix(1, 0, 0, 1, 0, 0)" xlink:href="#linear-gradient"/>
+ </defs>
+ <g style="clip-path: url(#clip-path)">
+ <g>
+ <image width="1431" height="279" transform="translate(-15.29 303)" xlink:href="data:image/png;base64,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ZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADANsJlAAAAAAAAAMA2wmUAAAAAAAAAwDbCZQAAAAAAAADA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" style="opacity: 0.30000000000000004;mix-blend-mode: multiply"/>
+ <path d="M0,571l1400-1V383.22c-24.81,2.43-52.15-12-104.5-33.41-57.41-23.44-117.09-1.83-191.29,15.36-38,8.79-95.92,28.56-141.2,7.73-54.32-25-116.72-25.35-156.3-17.38-90.24,18.16-134.87,13.85-183.75-4.08-97.94-35.92-105.31,15.12-224.48-22-57.39-17.87-151,9-173.79,17.93C142.32,379.64,106.86,344.26,0,344.91Z" style="fill: url(#linear-gradient)"/>
+ </g>
+ <g>
+ <image width="1421" height="182" transform="translate(-10.01 395.28)" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABY0AAAC2CAYAAACVtKKJAAAACXBIWXMAAAsSAAALEgHS3X78AAAgAElEQVR4Xu296XbjxpZtvalUNnbap3yq6vve/w3vPel02tmJ9we1iIXFHQCplqLmHCMGQIpiA0TsZsVGYLPdbgsAAAAAAAAAAAAAoKrqau0FAAAAMLHZbDZrrwEAAAAAAAB4yWyoNAYAANixIAiPnh860S0OFgAAAAAAAF4o12svAAAAuEQagXgT26V94cLw1rf59ojIAAAAAAAA8FJANL4nT3GZMkIDAMD9CXudAvGm2ffmr3W21vLx7Hn/eOw6AAAAAAAAnDMsTzFgRQx+dKF4AS6FBgA4kkYoPqZdxbYTkKsOxeEb297Ec62YjN0GAAAAAACAc+RVi8ZHrl15zP5T0V0Gfezj6Q+v+aQDwMWzIhS7IJz73t7EY71O7yk76uLwz5XtSEzGLgMAAAAAAMBZ8SpE44E4vIlt95xvs8rsOUXjg2q15u/53NL+9ORr6BAAcJGYre+E4k4QfnNk02v9Pd0Guzj847Yt7XciMuIxAAAAAAAAnA0XKRoP1q30/RR/R6KwCw7d60af81CkyHtMq9gu/c23o+d2T1xiRwGAF08IxVWHS0tI9L2+3b8+saV4nKKxxGIXhr9H+9Y81wnIiMcAAAAAAABwFlyEaLxwc6MlYTgF4avBc93ly/6asu1j4ELu2nqZS2tn5t+q+tdVsy9mnQVRAwCei0FVcVYTp0j8tqreDbZd64TjFI1TMJZA/O22fR00vc4rkGfiMTYWAAAAAAAAnosXKRqviMRrwvAp7U2z79VmV7XjMcTjFG27dTOzdcJy/m1JbF4SnTtBWY93Oy+xMwHAi6IRi9Nup0gsYfhdVb1v2rtoLiR3FcdLonEnFv9z2/629o81CciteIxdBQAAAAAAgOfgxYjGKzc2GlWadQLw2pqVo+ev4zk1fe5j4cKu2s9onYDs4vLP5rlTWorLS0IyIjIAPDgLYrHba68SliD84Xb/w237xfb1vATlrurYRWO3+Wui8beahOEv1v66bXrsIrIvXTGzv9hTAAAAAAAAeEqu117w3Czc2GgkEI+EXk/8uzUrs5rsemX7XKKxxIkUjlMM9ue9em30f93znejsQkYnIu/b7blDRAaAO7MiFncVxS4QSyT+9XbrzV/jFccpFueEYYrGbpd9zWJVGqu6WGLx59v2p+1/uf3Mv28/R5XHe9u72WywoQAAAAAAAPBknKVofMKNjbTtxGEJCbkd7a8JybnficYPKR5nFa/EWgkJKRzfxNZfM9pq/0fs53NLgvJSZTIiMgDciSPF4lx2QmKwhOJfq+qj7bt4vCYYd1eY+KSl27e0p6o4duHYReNPtRONP1nT50s4/nr7Pj9rx3az2VB1DAAAcMbE1bEt+HEAAHgpnJVofOKNjbRNEdibr0/5rnk+L0HOS5Fzf63SeDVIOIGRaDwScm9iP4XgNYH4u22/N8/l608RkxGRAeAojhCLZbMlFH+oSQx2odhbisYSjCU4Z3WxC8UuGF/VjhSN8woPF461TIWWo/hcVf+qqv/U9N30nXwtZQnH324/76c+i6pjAACAp2dBED4pB4z3af05fh4AAM6BsxCNF0QCNa8ATlF41N7HNm905OJxCsZdlVm251yewkXhbvkIFy5ym/spFqt9G+wvicv5eaeKyCUIlABeH7e+4BixWNXEEoo/VtVvNYnEvu/CrMTZrC52u+9CcV7lUjW392nH0kb/qEPh+PfafT99RxeNfV1l9z+ywT9r9/k3VB0DAAA8DgNxuIsDRvvH4j58tp/FNVXkRwAA8PQ8q2i8UlGmliLx+2gfmueydaLxu+rF4hSKr5r9x64yFlvbdsKxC7JdtVvXukpjF39TLP5mzy3tp+is9ztWRJ6JyR4oESABXDYhFm9qsrOy0bL9vvSEC8S/1STGumic1cVrYrG3/E4j0VjbtNOyxW5bf63d8hO5bIZ/P/dbb2u6Wd6bmmzr3o7Kjb5UO9kl5i/1twAAwMuk8UXp8zMOWIsPjqWLI9Qq97lKEwAAnppnEY2PuPzYq8pcLMibG+XNjHyNyk4w7pak6ITiTkBIMWFUefbQeNCQ4nAnuKZg4SJyCsq53IQEiZFw3LWvsR2JyZ2I/KMOxW//XQjIABeOCcayqV1lsVcVSxD+Pdpv1kZibLcMxTFCccV+Mkr6XDj2iU75sM5/pbgt3/V37aqVZVPdru+v1Dg3Gzmo1qpaOJ72P+1vObffCAAAL4/wT2uisLfMBU+JG9x/dUJxW0wzav4T8I0AAPAYPKlofKRY7MtLSCzIGxuNbmi0lHCvCcVZRTwKCJYqjIdJ8B3IoELbYwKJTlzO1lUgu3iczYVgF4i/Whs9HlUkp2g9qkYmOAK4MKK62H1BLkPhVcUuEv+rDkXjbrmHFIu7JYauasd9bPu22W5r8m8/a5oI9aWTUkz+EI+zOvqfmm6SN6s6rnp+8bhJwrtt7o9o/SB+AABOYWHyqmqyRUNbgp25LJp8NJvHBsc2/7+ybYfHCV3O1hXT5OsO8kF8IwAAPAZPIhofIRa7SOBCwejGRt1Njbrq4q6qbLRu5Ugk9lbNvnNMEnws6exTiOj2/bmDYKIOAw4PTNYqkFNAzmpjb/80z40qkkdLWxwtIBMYAbwcwh90vuBdTT5AlcUSibNJMHa/0InFev+09519T46165va2Sa9Xnbp6nbfJynzappceqmrRJZv03IV/9TOfspOz+zkU9rHOKfaLvlPf26J7VojSQaAZDB55fvdc24/ZrF1as7YmpfJIB/1WMRzwywq6lq+Xu+lz/Bt1WHOprymy8WW2khYJkcCAIAH59FF44VqshSLlSQfc2OjkWDcVRV3Dn5JOOiSXN/mfvf4IUln3wW1vu/b3M92jIjsS0msCcgj4Xi034nKLiT7khbeZt/9to9tqwiOAM6ZgT/wq0wkkMrmSyz+L2suGHt18Ugsdtu/ZONz/y50/y/75L87BWQXzHMJC28fqupzVf1VO/G4qzqWDVfiuPcFD2UfF6qJ/Tf6Oc79PAf6//Rp7qu65JgkGQDWbJLvd885a7FzVTFZ9dJYKF5yf5wFRqPHXSFS5phd33LSty0V7qw1/Y/ncHv/iF8EAID78miicYgDctAuFt/nxkaeQKdYvCQUezLbNXHM/lMx+sx0/kuPt8121LrkPEXbrgr52Orjf5r9bCki53IWLozMZtcJjgDOE/MJ3eShX2Ei++9C8R81F4197WL5gvQDXgHUiQXisey63ndbvb/JhDWrjjvR+Jeq+lRVf9YkHv9dk73slqxw+7ip8BVLtjKEmP3Tth39Jj/26Yvf2Gu78+F+qvND6QNIkgFeIY1QvNQyBxgJex4Luw3y52YNe3O+LIjFLhS/jdbdA0dxRe67gNxNUI/wftblV90ygblUoG87IXkWA9BPAQDgrjyKaBziwKYOHbPWKr7vjY1GYnGXmHZNjPadJef/HOj7yPmPvp8HB0v7IwE5E/eRiHzsEhYpGP9tW28pIKuarguQ8vsRHAGcCZa0uaDok4cSR726WEKxt/+qyTfkUhRdZbH7oarj7Pxj4La680mdiN4Jx7lEk56XcOxVx8PKY2tVtReSy56rWvaP2VIkXqvM8vPTnRt9j04sdl9DkgzwCmmEwM4ede1N7LsNErKVOSmV2wMxGXtzPqyIxfJHyiHfD1reG6cTk9PH6XP0uWXbKvO9Nc+v0p+lMLxUlHPMlZr4RQAAuDMPKhqHOLCpeQVVVpM9xI2NMhH14DADyaq54/b9pefOnbXvvKmxsHysiFwVwXEtC8gKVjzwyYDHK4tTMP5ShwKyqum0jqeLIx4YERwBnAk2gaikTTb7bc2XovhY07ITEoz/XZNgnMtR5MSh/MBSwrZmKx+b9D/ZsvKpuxon1/f/87b5chWykd9qPLl2IB7XIX7csqUI44m4zocn2H6eunOVyMe4X/EEmiQZ4BURImDVZDs830ibJLuU+yMblLGtT0iN9pmsOiOOEIs9Hx0tBeX3EfCcM8Vj93fHxCBVY9G4mxj1Ihz5PC+88QKbvFrT/WM3sUo/BQCAo3kw0bgRB9xBu1j8kDc2SrHYv0M1W9ElqZfM6PduqheUOzE5hWQPeLY1r8IYichZhdyJx1+arYvILox4cJTVZwRHT4ACdI4rJHHFiQRjry6WAKrK4lF18X/VdMWJTx4uicVuz87N3svuuuCRya3859oyTr5Uxeea20pPHDuRQ/a7am7nO//pAk1+RxeL30XLiq303yPROBNpT5y7pY2OTpKxVQAvh0FV8VX1tsjzjpzEWhL59Bk+WbUk4OVznYBM3PmEnCgWSxT2m6rnTdb1GvkxF4yzH/nERCcad3Sisfc773vHFNxkruRXH3VXIOEXAQDgaB5ENG7EgTc1FweU7HpF8VPd2Kh7DBPdsZGo4X8/Rkj2KrYlEbkLhHzmPIMgb3/VPDjyKuSRSEIQf0csED/40+B17XHleL8erC94Yu8CqPuDrrL43zX5BvcJv9SUvClRW5swPFf8+6UP29RckHVf6mK7Jl9Vbaw1jrVcRZc0yi7KNsteqwn/LiOBxr+bknEXuX2bfjwFGz8emUj/qMmu5wRjlyR7pfVBkowPADh/FkRA9yluh9RGSwp0VaLyIR6/3NRkJ/3qBo9Vs+k1TFY9MXcQi/2qHV8C0a/gUcWxT3p6H+vE4mOqjEXmUJ1wvJQvpWjsudFf1txHeo6UfnF/w1z6KQAAdGzu6x9CMO6qi/PSY1WPvcQbG712srNsm+2xInI3k55VZGsBUQZFqkJenVm/bQTyQYjEObaOGWN+PLv+sdvhuF8k5g/kE5RY+QRirlv872juE3ICcSQ4LiVo585SApmCqdvDz03T8z7RlkLqqOq4E43XxOKsKP7QND3vQk438Sv8OLifyIlF9wmfbT99gZLkg9+LHQI4L1ZEQLUUid9H88kqt1FZLSpfos9UrOqi8VJs6hNVi5NVRcz5oNxDLM6brXf3zxkJxiOh2H2ZT0IsxSPu929su5YryaflBKrnRV1ckFchZX60/x70UQAASO4lGjeCsQdwfumxxOJcp/Kl3tgIdmTn6URCbzfRukuxRpdheaWxB0a574GRRAPE4xWaANz3R60jz3n3XPaTV3/8L4HmihMXF/2mp5os/HdV/bdt3Sdk8rZUXVw17o8viS6JVOLoCWPaxG4yzSfVPFF0eyj7OxM1bunEGk/EO7HYReNuXciuws8TbMcTaBeNv9ZcNFbzJTp8qY6ReLz/zdgegOfnCBEw7U83SZVr0nbicVeMopxCdiHjURfp0u4yWfXENJPTp4rFef8cLYmoKuOc5HSx2EVi92FLMYnv+7nPGNnF4xSQl/KkFI4/13QF0id77HFB5kfqp/p84nIAANhzvfaCEea0PaH06mI5ZheL/7sOBeOXfGOj187o+Ov5FAddNEzx2GfVR9XHfkmyC8UKiHxW3QWTVfH4tV6yPEjUcpvBsTcnhWEXo3J/Jh6/1uN/KSxccaKkzdcu/qN2vkBNE4nyCV3i1l3+WXXYB18ym9qNCR1H35cIr6pt+dpf69A2dk2ChwvHeSm1xmSeyzXBuBONP8TfR8l3Z0eqDhPnkWjuVVVKkD/V7rj8WbvPli/QZ+q3b6rqZrPZkCADPBNHisVpc+QffKkBtdHNzDrB2AtSqqqdqFuasOqu9lidrNqwXMWdGIjF8o05mbAkFqdonDdcV19Jf7UUC7svS5/mj/O8d/lRlycticd5BY4mUv+s3e/7VLvf+KmmMeH9VH5xc/sZVcWyKgAAMHEn0bgRjL2azC89llAsYUDN1y++hBsbwY7RefGAXFuJIi4iLs2qj8RjD9b/tNZdloV4fMtCouZCUTb9Ta/P8z06l76vbQrJ29olU5vb/Ys/B5fCwhUnEjXlE3zyMP2CfEJXXbw2eXhJpJ9LEdcTZCXHo3UOu0uoJWJ0y1SkaOxjv6vyS+E425JIsyT+d0lzCscp4Mj+K674T01XLuVEtI6Bfn8VCfKTYv5nCOfisjlCLPaKUZ8kc6HY16FN4dgFY9mtkS3yGHUtBk3R+NTJqiqE45OwGKPqME4dicW+5ESKxb4kYq5hPCpcWhOKK/a7x85IPNb+koA86p+eH/1eu/7pS3C4OP5nzceDllfR+zLBAQAAe04WjQeCsWb+vbr4v6vqf26bBII/anLal3ZjIzhkdM48QHcB+U3NRceu+rgTj73i41MdXpaFeHzLSqKmZE0Jm2+94sKFY2d07rz9tK0Lyt62dcHn4JJo/MF1TcnbUnWxVxi7YJwiXyZsZdtLJhNRH68p5HqynBNsLia7YJyXpGocCheoU8TxamMXkN/Z3zwZ7aq13H5053MkHI+S5L+qT4692lDf60vtPvtb7T6bBPkBWRGEjx679j7t+eA8vVzMb2QMkmKx7FpWjX5s9pcE404sdsE4Y1Kf8O6qOr1oIServFo1/Zj3f+zNChGv6vh57pn9RH3Bq4j9RuspFuf6xe6zsn94P+likaNt2y0jv5f7W9tXfLwmHv9d8wrrbpxojHyq6Xf/XXO/uKldP91WsawKALxsMj7Fpp3GSaLximAsh/zvmsTi/7V9iQO5HEXO6I6cM7x8unOZwXoKyGoSGj/UoXisWXUJB35Z1l3F423VVPl6CYZlJVG7rrno04lBXfWFn79RILvUUkw+EJARj88Pc7zej9wffKydvfcrTnwSMW9454l2Tk68Zn+wqd3Y0pgt29fY/VnTeE2BI1uOPReNZYPdz3fCsQs7aRsy6XZxxm3GMedzG20pST6m4jAvN/67pu9CBeAdaUTiPLf+97uM31ZIkW/e/4FzdvYMYhDZGNmTrBh1gVhXrfxmz7n4l+N9zR6lf+nsjeLPH3W4HMBosmp0tYyPiZ9V9RN7c0iIxe7v3A/paiavLE6h2FsnFvtSFG9r3D+q2Tp3sWsdbtP8PfWc8iPvo29r8uHfa5oo9Yp8H0cuGndjxSdVZwU19FUAOGcWihba5ylOOI2jRWML9jqBwJej+N9o/1PT5ce/1byaTMHcqckkXAajZDMF5G1N4ogCpPc1ice/1iQeK3DUZVl3FY+VMLx48TgC8BSLXST+0DQlcF1g3SVbHrx24tXXZl+vkxCU1Y8v/hxcEpH4dxOIoytOfAIx1y/2BDsFxrLtayR/eycee/L4o6bJtZy8yap/r/TXGHZf78m6bIZPNHkbicQ6j/69R4l3N7aPTZK9KtGb/uYCt3/Pf2r+fUiOV4jAPOO2bPn8qWyjdc9t/Stx7s4L8xlVh3ZlJBZL4Ps9mldPjiaHcpI7hcBRvpH9yyewuysdliarOtHYbaBAODYGEwsZryrWkBiqvqGJaG19KcQUi70KvSuGyL6R+4/F0me4j/QcSTHAdc2LPpb84tIki5ZVUV70U5+54R4AAHAmNLFot839DrdnextLXDnnKNG4EYzlmDrB+P+z1gnGo0CqO8nwesjz3gVHCowkHv+oebWBB5FKKu4jHqeo8uLW3G0C8LVELdsoqFwSjbMq56u17pJ5v0GX/sfFrYsR8F864Quuakrk1IckGKu6WBOH/1vzCmO/4sT71VrS9prJ4yABIpPH69qNmXc1n4DpWgrGIs/zm2b7pnm+E2W8+ft3dM+vJcle9fzemk985VUTPmGdfY0KwAExAZntKvaX+sL+LeuQDN69eX/N/f3rCPTPg+gv6gceg2iyUf5jtLxAisadAOhXLo7E4q4fZh90e5N9T/GN25cU5TxeWqo0dl69vRmIxR5jeLzqcb76yH9FU3+5i1i8ZqOemu47bGrqq8f4xa6/uo/s/KOEYy1Z4eLxq+6vAPD0LIjEI9/e2fSO9PcHjbjyCNE4HLkS0qwok2D8/9ckGP9vzQWCnHlHHIAR2Q88OFJAqQCpm1l/CPE4xcsDgUUG5FyNRyPwdWKxxGG/dM/XP1Nw6UGlkrE0xEqqvNJYAaeEYR3f7mZdLtx7FfJFCPgvmUFCp7H3vqY+5MtR+BUnEox9/WKfiGAC8XiW7KNsoxLHm5WWgZG/Z3fOrxYe5/9U7Iu7ntfud8oHeJLsNs59w6gK0X/H15q42VBVlcJf1fxcu2/J/dx6P/H369jaVv1U9j8nPWZ+oaJfn7ufvlQGPsPFYp/wX1teIAVAXbHoYrH8yEgsrtjX445ts3V7o8KFbrIqbY1/n7SPzqsUjq2fVM37SRev5sSC+kcnGCuOvYtYfFcf9RR0302+Mcdajre1idXsu36cPB9SDP7q+isAPD1NwYLvZ16S+0t+t2qeA43yJOLKOkI0vmVTkwNSoPex5msYe4WxRAIJxhII5IT8ZFaNTyS8brp+cXW73dYUGOXM+kOIx14JOxIut1XnV/m6IPD5sclKjazoGYnGowo9N7i55qhXG7to/MXaX/HYBeQXL+C/ZAaTDxKM1ZckGP9RU2VxJxj7EkWZlLyUpO1cWEseqw6Doe1C0+szIDu2VbPN/buw9jszSVb/zGT5bbQl4SAvyX11tqUJ0v1Yd8fbBfzc1+v1Hnpf31bN+2HVXDBW+zFo+puEZO/zTDI+ERF/bGo6/14xKrEqK4uPWV7gGLF4yTaJJbvk9qVq6o9Xt/vev9OudBNTnY/reDXCcdgXHZsUOlMsVhzvEwoSinPtYu8rnQia/aRq+dycI12fHvlF7685kToSjXXMrmu6Sd5VWRy+uaCb5KlPXsJvAXjpHFmw4DGGb7u4c2TfFSuqeazZFSjsY8vXpj0sisYhFsiRv6+dM/69pjUrO8H4jzoUjHUiX7KThufh2ODoPuKx2ueai5cSPPPGbZ6cbhU8VT2fAVkR+DR2NX4zSVu6pK8LuvOcyJB6Yt9VHKdo/Fe0rPzOc9BWHtcrNOCPySD5dz+gpD8F4+4mqP9VU5/yCcSRkASn0dnHqrkId2X72qZI57iPTn892orHOo9LfiDjFRcvO8E4BR1vm9rZmapXVlXViMV5bK6t6Vh2ovzScfY+1aG+6UG8L3vka+b7NkXkAx9R+IlHoREBPXlzoSonrbtq0U4AzFhE/cuTRbcDVfM+dqpN6l7v9iZ/owtzbn9G323ERQvHA/vix099ZVSF3k0seF9RsYNPLFyaWNyRfd39YuYD7hNHlfJd1bGKOa5qisP3/rHqfG2q9buDPx08Mb12+FvO9XcCvHQaH+E2bOR33bZl3DnKMzWGO7HY9YuuQOFVag+LovEtOtBKDDTjq8uQvarsf6oXjN/V6YETQEf2m1EQfxfx+FPNhWMXj33ZBDckXfXx7M7uT2FEQuDL5N4TNQXfSs7+sJZVPaoGGlVpVJ1mfL3qOIVjCcWfa378JSSvicezc/BaDPhjEZMPm5rGlBIM+QH1Jb/pnXzBv2vqV7/VlMh5sq/3L9vC/eiOY5cE5djwx52dHf1t9Nxjk9/p2OByFFSmqKDtj9vtRVVVJTbmq+bHwI+PC8KjajWv7ParUzoBbYSLxrLvLhR/ta03+Zgu2D+oQMZPPAxN/OHjLcVij0O6pQV8eYFcikJ9L5NBfW7Vuq26C5097Car0ua4Pck+v/TdLlI4HsSpHq97fKFY3cXiFIz1t+wrskGyPaPz8FD945zIPrZ0zP24q2msdvZcsVsKx/sKvOe2qQNxuDvfx4zFLl7aVlXlxzzX7wW4FAYTit5SHO4KFUZFC5lviowzvTDBY8outmy1h0u2BUPROEQDnSSvMnbRWC2XpNAlyCOhCeA+pNO/r3j8r5oLxxIvc71jX/O4qz7e2vbRBOQwsFX97/YAXEnaHzUJetoqCPeqHgWMaXgz8BZba8eIx762sYRjtRTvjxHwD86Bjv1DHvdLZiGp84ROY0fJm9YwlmisfU/+f6n55EMKR/iExyGP694WNX87hrv8z2OTtn/Uh9da97qrmpbGmVVVXYJNWfAhHqS7D3VRwZuvG9qJDUuisfcpT8w9mM+rVtT8ypV/rKWIrP8fVolcwvl8asJfbGouFqu/HCsW32d5gWq2j4W/f9oatzl5bPy7OqPvezHC8SCukH1Ju+Kx+VJf8X6SfWUtZn3sPnIObKr3i378M0daqjpOe35dk41Nu/ok4nEjEOfY1Db3j+0PntPk49nz/lUe8zcDXBoLYrHH4rI7XpDgdsn3c6IrY0/HtQrXKDLGXIotLzI/6BiKxrfo5OlE6XJkCU8uEuSalaoKQDCGxyYDhfuIx91SFS5adlWvI/F4Vn1cISBXnR5cRJDkgU8m+SnweUXov2sS+jrBOINvH8NudHMsdwn/tqbj0YnHWXWsamMXjkcCfp4DN+CzqjLE42Usqavqx827ml8q2gnGaupTv9c0ASEHvjbxAI/PJR7z/E3ZxzJRzq36ejb9TRNUP2/ff78c0Uu0KU2Q3vnLFHQ0Aem+U+0X+5tX+qXI4D6k8yMj0ViVxj7h6IKxN5/g7XxEXqUikePFns+nJvpPJnguFvsSRu43uspiv8rp1OUFKvafAsVyOgbdmLqvn3vRwvFCXOH9xOPxu9zkzq9gOnZi4bWQ42Nk7zPWc5s/mgxULH5dk12d2dSy2Lvq9HzHOUIgXht/Pk7ThnRjNMVhLwi6aZ73PG96k3v8ZoBLZkUs9jh8ZJM+xH5np7oJRCdF466ozeNK1x/yvld+VeJN1eWN/1Y0NkfvJ08ClAI/F59GlyAjGMNTkkGEB/Qpgo3EYyUvWfWayyV41avfsC3F49ns++22agou9gHVEYyCovxt/rt8OQoXi7Wfl4KOlqNYCq6SLvFPASANtMTjL7X7Pr5UxZqAP6o+zuP/YAHspTBI6tSfPKnzG9G4/ZdQ7P0pJyC6yYeq9X4EcCpuI7e2n/bS/UIKxW+tecApG7+p+YTUi7AlEaBXHR6HTiz2SmIXij9ac+HYA/c8jiMhp8N9x02NJxvzapXuxqoe+LuvziqRF3U+nwvzGR57qO+4CKh8QT5DfiMrRl0EfGnLC+Tnv6nDvn3f7/jihImexOUAACAASURBVOPBpILHFRmn3vUmdx5fyMboHLhAcN9z8NKRP/Tj4mN4ZP9dhPkQTcKM4m+3qW3hzIn5jpPn0bf5W/J3rbXsK6LLXbqt7x+IyBoKL2n8AjwmR4rFnVD8y6Dpb3qd/u+YSmONWY8xs5jN773kOpAXEeo3fPf3fmm+e41WNL5FJ1JOvhONvUrRqwOUJOgg6v0AnoIM2E8Rj73SwY1EtpF4/K0OE9LF6uOai6wjMgj38ZmBuFeEpmDsTYG4xm4KfJ4AqZVtl/Df9KYmw3xd80DrXc0N9a81F4/XBPyu+jgD2INL5+qVB3MrYnGKRsdUq3t1sfsC9Sf/jKrj+hDAXUib3yWW2d+7SqtOOH5T86qqTdX5rnVswXnV+jHIQF1jP0ViCTwpGruI4xOPfuz8fJRtHfcdnrBnUO8Vx51o3DUXkrtJxqqi6rijEYvTXyi5y+UFXCR20Vj+PStGR9VBa/3mOem+17t6uO/5IoTjgRjgkwoeV2Scqr7yL9umWHyXKvSHOgcvnTwOfqx8PHs+IVuegrE3FXHIpmbVsQuqmfNU7FfNv2eew7RBnT/T7/Dfky1fo/dw9J1dJPZ8IouDPM84EJFfc77heEzy2o/Fa+MIsdh1jCzq81g0r3Jz0dirjD0Gdf/gZIzpS6BJU1D8KP1By5jK/r2tSQe6qql4UMVqF9PXD0RjCwx1gJVI6MT5ZUOqEtBJpMIYzokMPk4Vj3+rfpYphWMXLV089oR0tJ5iBlNl26p58CuDNwrEsyK0E/m8GlRj16sGukStmu2x6PXb2r2nCwEy5prl82TiWAF/VAXuFeCjpUMkHu+P/aUY9o7GYXdjQfZ+NPmQfWlpeZPRDO+pfQjgVLKPKZbZRPNEMhPlbAoM/759/YFNVyXVc9qRBaHYx7uOxzFi8W/RXDRW0J5Cjos5Hg8eawfcF/okowf2eZWKVxxnVUheqeJChwf6m6LqeMZggjH9RScCeqVoVhh7xaiLxUsiYNVyn3luNjXFOY/B2QrHR4oBGVf4lX1eUezxqV8F99Kq0M8VPz5b29d587Ht520k4vxak50dFW2kkNqJx072J323zo+NhOHrmv+W63guRWT3TSO/I98zaj+iHRQLXbJ4HLHHwZ8PnphePzwWl3icXhtH+ofUYNxPeJFCFitINO4qjH3MZywhcqxrLPvVbF9qihs/1e77/KcOC2Xzc37cfsbFxJIHovEt+sFKpHQiXYySSDCqMMZxw7ngfVCB0shwZeL8sQ4vf/Vk1Ctd81JYGR1fSzFnp5fE46pxkORBXQZzGYj/UYfVoLl8wJrAd+o4Hr3ej78LyfpNKV4sCfgSArolLLKibKny2AVkfa+LMPDhrLXt+r6LxXnc/dJiCcVqPvnQVatnMH5qPwK4D6N+txS8epKclQuylW5XPDFWYvikE1EL43xpvLtA7j7Px77iPW31fAbsKfopDnR/MrIDvu/HSvteseUVIUvisey/Ty7+WVOFyC81+Qt956ySq9qdy5uqpzmP50T0KZ07xQiZ5Ekslr9IsVi+IvMGjz9cyFnqL+eMxzWPwVkJx0eKAaO4IicWXDj2SQWfiHaxOBP0l9ZXnhP1Ux9nfv7WzqFPKn66bR53d0v2eeWx7HknHI98V363FIi9vW22+ZzbG9kc9R19rxSM9Xv8itKvsfV8L3/7TDw+l3F8VxqROMfgyM8nnd/fVu0/Y//3l37MXhs26TzyD52GIWE4Y9C1+DNj0NGkUI7zjC9dOFbFseJJ/y75+f6Z/9S8z1+EcHw9eF4n10WpdPZLgrE7IYBzIR2YB02ZUL+rnbNXQtStn+iVrS4cu3jsFa+5YHobTFhLY+sB0igYV/VGBuOjhE1G2w1rVe/070MXXHhwoN93U/3v6wT8f9V89m9JPF5ay/LgUrKq+9+08LlYEYq9L3mAreO8JgDk5cU++bA041q2BXhKNI5dyOlsqwexWV2l5sHpl5rb9dGVDA9qR1YStdFYT7FPv0G/ay1Y/93+NgrWU8zJWHBkB/xxHhv3ER7Yd+KxB/ny1V5x/Kl2v+M/Nf8Nf9bumHyp6bt71XHVhST5x7KQ6Hn/6UTATiyWr/CrEj1vyPhjrb+cO529eUieXTg+UizuxACPKzrBOHPL7CdpX15yP3lu/Hhtoq3lGxJt3E/4zarzqsvMe7Jgw8k+lT4sheIUhvVdu33vR94896ma/M3o6hafpPSKRG/yRfn75bte5DJIEX/k+MuWz4/wvDcfz5576HgKHodBDHFVY/3Cq4plU/xqE59IlG/QpPMxYrHGd/ZD72cZW2rMSzjWd+ji4Pz8HAM/6wx8932ZicbNSVaQ+L4OT6gOWldZhvOGcyad3rbmhk39/mft+vb32vVzDxRSQP4S+1l17AHUaBbaxUvhY9GDpJHBdUPrS8h0M2PPJfDle4/OwZqA/6V2v+2v2+1IPPZzMhKPW8HHWwYrVc8fsBwhILnDXOpHKRp5/1lL7LzKUM56yUkDPDUau52d6wSPkWgse/vpdj+vZkhbsq25Tb/PzYCqDu3zJlqXaLsd9d+lwNurxn4btC5I7gLl9CVLPmXJLuTx8eSxqzz2IN/F499qmtRd+y3vbl93XXOxY+8XXnrAv8aRiZ7GwWiSOu934pOL6S+6+GPUX14Sbm8eg2dJPq1/VM1jizWx2CcWMq7wggb1E/WxnIzy2OIS+sk5oL7qx9VtQOcX5Tt0Xj3e9uX65BtTPM18R/04bU8nEnv8mkLxu9jvxONr27r9ybE68i3KAzMX/DLYuois97iq8CtVz59PLNFMFGU/yXPX9adurGaulXHTTfM3j6d2O2d87F4TjY/wcTzSLjzW9KWJ3C+MCha6sZx+Ys1XdH0wixJ+ieaFJDmx6bFT1fxzn8V3PxQz0fgWHWAZaa8o+NWaq/ydYDw6OQDngvfRTc0Dp23NDd2Pmhu6FJAVOEjMdMFYYmU3+yyDJAOlRFhk8i8j6WJGl/S7sOcTPFndI+O65NQfi/ysPAf67Z2A/7UmAflL7X6rLh3pKo5VcebnxcXjrIJYFJBrx1AAemiH0IjDVYe2Ns9jOuxOLFZ/9gpDT+y8eZ/yCcN39hkeMJZtAZ6btC96To99nHTCscdAv1RfWdXZkqyq2tuPmujsRfqn3Op7+/e/qike88R6JBZ/jPZbbDNQ15hPH7Im/J1qB/L1fnzknz3AV5Ngr0ne0e/08+ixbP6uq5rOZ9WFCscnJnoea/jSRS4aLy1FkTnDsYndS0P2RrbmoXmy5HMgFru9OUYsHsUVLgpoUqEraliyL3A/Rr4m/YtsgeceOr+j5fpGBTMZY/tnqqUvS7F4JBR3gnGKxT4Roc8TS+JRVhpn8dBfTfNj4RPMM/H8qcbzsYRQXDUfg+4ncj+33pcSHWc/3jcr+/4/26rpfhJVD59/wTrRV9xHyG6kf/BJ567QLa9Ocg0j44iMQe8aU2RcKe3hR02f+b5pbmuO0UOfzHc/NCPRWINdIpUSDReNXYCSsu4nCeClkAFTBjDb2vXxnzUFIEuXK6WQ3C1T4cGTiww/azJaGk8pZHgCl2JGlxR3s3IemJ1qWB+D7hz48XfnIwOu3+4JihxN3vTIhWMXj7uAVuchq4+7YEV9ZbYdicl3JI9Nbj2Ic/utoDvFozx2Odnwr9hmUpf96dKTf7gc0rZsonnsk2KZ2xrZG01OdVcy5JUknQ2pWrYTa+Nc31fNE+MMcjV29d1d1OkE1c5/KFCXD1kTi3P/rrg99f1tTT6i89N57kZNv1G/039jJrsXIxyfmOipT7if8CUo/EoUT/YkBOr99P7+mVX1IP3k3FBfvVp74R15tORzIBilzTlWLE7B2OMKzyk7G7NmX+Dh8P7qOYifc/eNHj/mVZZdpa1X2/qkauZc7oczhvVYNsVifz5bJxZn7OrIT3u1cXc1i+d5vhxS3nPFc4+cYNZnnMWSFTH2vQ+MYg4/V7mv1+s99L76fTr/ipE898pttpto2yqqj58am1TM8etj1f3DKROJrje65uj9y2Pi9BGn+IourlyzQ2lfupi4+w6P5rsfk+t4nCd9FHinYOwHqeq0kwRwLmS/lYOTAXFDKEf2oSbHnwKyBxX+2F+XorE7Pw/W3FgpsU3h2MenV2x4EH7uAp9/Dw8sdCzciKeAnrOXHsDlfga4Ok9LYr4HNbNZ7po7mxSD0imMnESeg875+b6fwwzkRn0mJxpSMHZn7mLSSCzugu5z6UsAHWljtPXYJ5NjnzjXmPitpjspe8WxX5LrdqRLdLY1txlOBp1X0Xycq3kCLfs/Erw/NvvuQ1zE8cDYk0B9r6r+uD4k3Xu6j5CfflO7Y+v2L4/Lh6Zl1cgotv1ZLzTgd+6R6PnSArnEgPyGi8Wj45n+7FJRH9U4eWgetC+uCEZubzSeRvGETz579ZiLAS4IdHHqa+on54L6q461zoP6gJ9/n1Dqrrr01l3Zd6xoPBJqZKeWRBzvUx6zeh9LH+aCppqLx/LrWSykSmOJxbpJ4MealrRSLuIFK99r9x328cFzCJ/N2M+YI8Uz9YOl4z/KE6qm/Gkkzvuxzuc8N/O+tO9Tz3EMXxMRQ2xqOs8eOyu+yspixQ1d/ODxqOLQUQzqfqJq3r+6x8cgG6j9pZjb+7n7rvxeHQ/qu5+Ca+00FQeeNGWQ7Yp/BtUAL53O6LiI68LlTe0MWjcTnZcyfbPnOmFSDlAiQo5FN8SeALtAkImvxqiaG9mq8x23+b0U1C0JAyke/1bT7P+X2PplY6oQzPXXMsBdE5A7EWjbPCfcKTnpiPM5D3Q9AHab7UldN7mQ4pfP7P5mf89Jwkz+1xw2wLkiuy6bruc2dTieuskpCSO6c3wKx1l1vFR5nPYhx3sGrV1CPRJFfczru2c1sZr7DxdxjhnzTzn2u8/S+UzfueY/O/G4s3P6DPHiAv6qeohErxOMVVnsPsP70FrfuXTc1jwG9+6L1i+q5jGGj6OMtSQYjsRibT2+IK44f/x4q++mP3J9IHOdr7GfMXVXJKPP8j6Xwoz3ldE2BRyPkd2Xbmw76l8e13u8n7mefqMqrP+qnWjcTZJIPP5cu+/87EtWLIjFHm+8teY5Z+4r98xJIB1/P9Z+XDOH9nxZ+11upuP23d5nFl8hHj8sjV6YPiInlDxe7iadu6uT0j94X8wYJu3VXen+12M//V63KdncpizZFnFv3/2UXMdj/5EyunIKHiTIKMgYnHKAAF4K2Zc3NQU3VzU5vOuaBxTu/EYzpb7/07YeRLlBzsDpusYzvD42M1Cqmn7XSxiro3OQzsoT3qx+kHjs6451bbSMiFcMLgnIs1nu6oVkF4dSNO4c4VXse9P5zb7hgogLXaNqQ6809OofOe0u+PPvVrYFeEmkPcmx5mNMYpqLxn/WLggerZ0+qrDq7H0nGnfJWybQitFc7NP3zLHfNdkIT/h8vGeinXHec4/9JT+tY3isrcxjkIluftaLCfgfIdHrKoNGiR7+YuqXisMempP74qCqeFPzpNjHi8aJxxF+ddKSWJxiAHHF+ZN23v1S+sb31RfNdIUyLu657xu9f9ruN83+m2gZL6f/Sl+Wfc3jdI/n1bLyWLmCfL/Gheyox9n/qUk4VvGKX500iw82j7RkxYpYnHHGaJLVx/SSH01fWtWLxtlvPA/7Z7D1GCvzM8TjByQmnUd2QDHE2hVKEow1Pu46mfgYfiLfs7MfnY25S5x8su9+Lq4Hz7vR8KD6nbUMqB/jpAGcC9m/NzWJDUoE3tQkALiA7KLwz4XnUkDoDHMXLHWB05IBe4ljden4d4HOu9od3w81r3jw9cdcSHaRx5eryEoJD2hG4vFIRE4B2UlH1DkkT+QyiHYBJIO7TjDOfTUP/tac9kvuTwAiA7tsGmueOGn8fKzx1QtLa9mn7R+Jxhp3buNdLO4miLqELlsmeEuTjXk8yrbnRn6vK3u+8xVpN5cSXf/9ztkH/I+U6EkQ8cmHFALxF3M29czCcQhF2ubYSFvj9sX7iAvGx4rFxBUvD/Vb9ZOyfeU9b+swvxm10YRp9sXMfbxdxX62jW27Jtb6WSceu4Dsv/V77fr6PzXFB93WY2+NC48ZXPx08XhTDyAeL1xVkDmUxxY+CZ2/weOMzpde1/y8CD+Ofgy9wtgF4y5/ywn6TkBGPL4nTQzRxVHHxhC5drFPOGcMNvIPFfuPRX7eqS0ZfedV330OpGi81CHUKUYnE+A1kH1djxVIXN1u39TcIWbrhEUPooSPR43Jbqt9vb4zXJcyTtOId+KxAliJxx6EuIC81vy1Puvdicc5KTASkZdE49G5zpZisRK7FIxTNPbAL0WknBRcc9qX0p8Aqg5tSRcPdcLxP3V48x9PZLpkJoVjT0Z9nOV4zzHficY+/t9Hy4QuBVKNd//tVS9vzOf3HJ3PPK6e5HbHY8RZBvxPkOi5WLyU6FUtH7/XhOyMxtZ9yeO6r0xceK33CW27uEL9w8UiVU521cWIxZdLZ1O1Va5zXYc5TsbCI8FYpL1aa96P02dl39Lz3f4S22br4rHyjR81jsFH4rHGiK5WeltTzOCV2fscQuJx1fHCZzNZlMdxaZLIheLu6sRRwcmaXrQkvmfOloKxJue7e9R4zOVXiB6Ix8cev9dKM8GQvsJ9RC5TtDTh7NXF3m/O1T9ozKW9GbW7cJZxpJOicdX0gz1ZSQPgJ/UcTibAc5D9XY89EPLAIgON3HfBWMapam6k1rbdeLzUcem/Kw36tib7JfH4fR0KyC4iH9NcOPZL7rqq4xSSFRgtBcqdUHwd+ylypHDkAV82F5ZGFQHn6rQBHpu0KWoZLCux+lA7e/Cx5gJxN/HkE05uMzKB9s/tJol83Hfj/1314ztjOB/nGc9dig8ZnU8/p2lvRwL62jE4m4D/iRK9D3UoFr+p6di+9L7zmGyq7iUcZ7/W1gWmqinO8NeMbJqLxbIlmmiWQPSbNReNEYtfD3me1Jf1vHKZa9vPookUjEf9dJTfjPrPqD91feuU/tb9Rm0z3/Bx5AKyxtJIPM4lK7zquBU9q6aq2RW649qN/dF39eU1cqkNVYnKH2Q+0flS4Tmw50prwrELxt7yRue6atRjr6zerqo6C799TsQkg86dzqVPLmhiwWOIf1XVH3XcclbqM+ov5+wf8jtc1e47r9maUzibOLLjunnOT1QnXLjj30QDeK1k/8/g4sr2t9ZGj0U6+86QjgzraxmT+Zs9AFVglNUAH2oKIDI4yUAlhWJv3625aNyJyF1VYdXY5npAdx2PUzzy1lUX+r7+R++Tdv2cnTbAUyA7Ihvi+x44a2zlRFRnO/zqBIk7KRqLtAduB95WbwN824nEnSDq41wC1iX6kDyf/pu9jY7NscfkWQP+JtHz3/WQiZ6LgQiBd0N9UuPuWPL4Zl9WzDGKMdQflPCmWCwBKCsLXSz+LVpXcYhYfNnkedNjF1KXWtk26fr36LFvc797fFe699EYrtr95puaj6tubHXisTeNIYmfEj49z8gClLXcUdv0cx4/ZEV0N+ZdNNZ3zUpRH/NdTpFsaz6psCQe+2S8hGEJxZ9rd4+Jz9b8GOo4KgbzYh6qjo2FK5S6/uJXnnjs4LHEpS1n1X23d/Vw3/VZ48glXDTeRLuybZdoqAHAnFFwUdU799G+6AKikUF9KKP1EsnfLvvkQawEmJvaGXkPTvzSqAxWcn8kGHeicQrHGei53R0Jxikar4lG75rnXEBaCupegtMGeGzS7rroqLhI41ATUW43srlt8Srj7uoDj8PcHnR2wYXkbnyfOs4vdbzn+cw2OjZ+jJzRcXrygD/E4q7fkOidJ7Irx+ZSeYy7c/2tJtvi/5eCkfcL9Y0lsdjbqNIQsfh1MrKPme+s5TpO2uu1/e7xYzH6HPV3icfur5fE427ZBwmgWXWs8e35RE46Cx9zSz5B4z6F4rySYDRBlNXF+izXkbpx7/3Cq9BH4rEm4b/WfJkKF4r/vG2f7LHfb0JXf7l4vK86fkq/fW4MxOKr6vuL+mouZ5UTz95vLmk5q1N996k8eRx5DNeD57tO0yUcVed/YgGek9H4yOePMQyjwAzmdIGkju9VTYHJdc0DlC5QyZbiT1YY/7T93N5FNO62GYgubTsRaRTQpU2nbwHMx4FsicZNTkSl/XA70tmC2aWm8TkZg6UIvNT0en+PTbSybe5fMnkO/Vh07Rj02m+3j282m81NVT1q0N8sQ6HflMKAlhqQMPBaE71zRP3xmOQzx6sSep1riSKyMx73pGCUIoAvRdEJxikUj9YxRSx+3XTn15871R6O+su59KPue6TvTXuc4nGKxh9rt1yFBNCR4KnJIbWqybdVHX6HNzWNU7/iRFed/B5t7aaW+i2eY3Ra0dLYd+FYzQXkJfHYl6vwauM/a/f9P9Xue3+q6TvrWOq76jhu6on89rmxsJyV+ov3V59c6K5Q+sOeV7+51OWsTvHdd+HshOPrqvIqhY7NoJVtAeA41gKg0WwxnM4oePWgygVkn+UeCckjMdirBrvmwd2SaCyH7U61E4RSQH5T4/+5sm0nHrlNr9gHgB05RtYmotyGpD3x17gtyITPbcKxbWPbtbjttY71/N1KXo45NmvHUdVLVfU4l70Okjz3HV1V26i6+LUleueI7MmxyaeOv4tREkRUkeiicQrG6huaTHDBWALSx5qLRX45uvrFkmi0ZHfgddCdcz23ZhNfWn/J76u4wH24j8NR1bELx59qNwZdOB6tddzlFf65GqOj6uK8qaVfaeJXFPgkUVeQshZzjOjEYxeQM5/yZcC0XIW+82fbdxvW+TOJ8Jrcr3okv31u3GHdYp9c0KRzTjhnv9Gks97v0nzEqb77VM5KOL5e+FsO/KWT+xJPNMA5wlh6HPK4euCq4G4UrNxEO0YI6kTnFIw9uPPmweVVzZ3sVTz3pnkum7+v229sOMDpdLbExd7OfixtPVkSOU6vYpv7S3HayPbB/DiJdzU+Rnls/Tmdk6vaJaGburX7ys3uE/g3S1D457ko4ImehAhVi63d6O61JHrnyKbWk08/7y4+Kan/XDtRyUVjvT4rHb3C+NdoXlXsQrGLxdfW6B9wKpfaL/x3aUx347ab3POxJ3HOl1r4XL1w7PmHyM/Lq05yItEF46wu7q4oyMKU0bg/5Ty7f8xcySfkf9but6jy+GvtvuffNb5Cwm2YXznzpeY+e1/gc05i3UNz4rrFnVjsTRPO6jtrVyhdmo84xnffh7MRjkei8doJXPs7AMC50tkvGf2KbbY1AWgkNI8EY7GJNhJ/j912rWyb+wBwGjl+9Fhj+6oOReHOBiwFgj5ucxx345pxfjqy/acG/Eu22pPQ/YShX9R3TAIQQrG2/plZQZaJnosCSuxI9M6Xpb7ox93PvUSgjzWtf6rKue3t6104kviTovEvzX4nsnRLUNA/AOakDx7Z7u6qEBfp/qydnfY1jnOpiqw29s+SEDj6DH2Otjlh5NW5Gvt637Vxf6oNkP3TftVhrHRTk8+7qd1vX5sE+9Wed3vmk15/1/Q7VHF8ccJxiMXyDVc174s6luoLmlToJpwVQ6Q475OL3aRi2fYSWPLdD8FZCMdLlcZVvdHLv1/SSQeA10VnvzxYye2SANS1kVicht+daAaY+dyoVbPv29wHgIfhVDsijgkAR+O3G9/d62CdTR0f8Kd9VgLtAq6SJE/oZ75geVW4qjq06T4xOKosXrrkOBuJ3nmy1he9D7yt6Zz/XpOY1InGXkHm/WXUfM1SvxR9JBjRPwAO0XjWuPX9TjxOO67lFtR8mYq8oZvnGHr/7r01yeRVze4L5A9GlcXuk6rGMcpdWIpndOxkH7c1v+IibdsvTdPfXQj3iul/av4dLkI4bpai8LhlVPHu6xaPxOKuIj2Pq8cvVf05vgTWfPd9eXbheE00BgB4LYwcmT8/EoC6bYrExxr6TMC6hGxt61yqgwY4R06xI6dwzPvC3Tkm4N9E88S8ay7gLa1rn4w+J8Viv9xYFVUuFqdg7Jce6/WvPdE7R5b6os6Lkv33NYnGEpJcRNrUoTClfqP2zradUOyTCN4nq+gfAGv42NDY9nHkk45p0yXe6WZvEoxdNM6JyYr3zSsM/KoCr8SVqOr+4JgJxMcc+/neOn5Vk418U1MFck6O5QSZ27xuQix/3886A7HuPjRLUXT9TWLx2lIUuZyV95us3s6JxbLtpbLkux+CZ+2LiMYAAD1Lzs3/5sb7mP1jyCBzbX/pOQB4PpbG6TF2gTH9dCwF/J70ePLlIq6Lcu9qXvnpd7vvblwkugTPhenuUuNcB9PFYj3Om5p5NelrT/TOkVFfzL4ngem3motIEpD0+q6f+jZFohSLnkMwArgkMn53O+9jOicEP9bOj0gs1tIUmiA6ptI4heOlKwvSH3Q+oerpx35+nmxkHkMJ5Z1PdjHZbZ9EYzW9v3hWse4uDJaiyD52rFjsN8o9Zjmrkb94DYx890PxbH0R0RgA4Hg6xycHMfr7XQ37kpN9TQ4Y4FJhHJ8fawG/J/pZsZPVTbpxUXe3+6w6zvfO9/fk16vF/MY/Szc08mUoTq0kg+eh64veP9TvPlTfv1xQ8b7UtTfWOqHY+0UVfQPgrmhca2zmONWYdOFYIvE323ZXsfhn5JhP8dSbV9wuicXnMO7zO+TxdHvn/tN96NtoOWladfg5zybWncKRS1FossDjh7XKYsUSuda1+w995jn1l+eg890PybP0RURjAID7s+QY9bdjjftrdbIAAOfAUsCvBLpLxLz6V9U7ujmZqsNc2MtLijuBz5PdXKsxBWPfemWxi8VZSUaid954X9xEUx95X5N4lOtnd/2123eRaDR5QL8AeBh8LGmMp8gn+/+jdmNcVcU/bNstTaH39Mklfz/fpi84Z7G4Y3Qc0+7lMXCxOAXj/O3Os4h1x3LiUhQuFv+rsAG4gAAAEMNJREFUqv6ou61b/NL6zFOh/ngxwjGiMQDA0/DaHSgAwEthFPB7QqakLEVjrUX5qXaisYRjv6TYK8RSNFYi6++rijMXjF00/s0e+zqVx1YWk+idLy6G+DlT/7tpWlUvoHjfzffyPuD9gD4B8DjkOFsSjzXR6JOOOUnk75Xv07WraC/VH+RxzN+/dBxGPnHEk4t1azRicfYdxRB5o1wXiV00Zt3ih0Fj+iKEY0RjAAAAAIA5XcCfyVkmZl4B/K+a7nifNy/yimMXjf39fDmKrGBW8qfmz/s6lYjFl0EnilzV4Y0VUzzK/xm1sm3uA8DjkmPPxeNt7Wz3tvoJoqwy9vdxEdFtf+cHLsEf5LHz37UkHHfHYoknFetGmFhcdSgWq7rYY4du3eKsMGbd4odFffLFC8eIxgAAAAAAh3jAnwmSJ2m+hISSMwnGX2pa21jrG+dd76vmCZ9XB6Vo/Euz71XFiMWXi4siVZOYVM3W/yf3u/NPXwB4XnI8aixLPPbmYvFINNZ2qeVrXzr5G0bicSem+zGpWj4eTybWJYN1iz0WUfzQLUWRYvEfNb/RHesWPzwayw8lHKuffrt9fLPZbG6q6lH7I6IxAAAAAECPAn4lS3pOj0fVxv+qQ8G4E427SmO/sV53t3tvqgRSy8tHEYsvizx3/ngpYezOOf0A4PzoxvhocujYMZ+2/1hx9KXiv9N97DEt8ffa1BOLdSLE4k3NK6f9CiVNMndLUfwRW1+KwquLWbf4YVE/PFU4Xhuv32sXR1ZVbR9zIgPRGAAAAABgjAJ+JWqePI2qjSUSf7WtBONTlqd4b+1dHYrE3U2NSPIunyVxAwBePktj/BRh6LXaiiW/3bX8X9/m/pOJdc0yFB535P0PdBXSx+qXopBY7De6W1uKgjjiYVB/PFY47o65nwudm++3f7up3UTGo/RFRGMAAAAAgGW65FGBuydw72snDP9ak0gsodgF42NuhKdk0Le59EQKxSR5AACXxUjUPLbS+LWSx0A+diQG6/GaWHdVO3++qdtlplQI/FCC3WDNYrUUi5eqi100XlqKQlcqsW7x47Gp04RjkX3Pz4+2P25f+yjCMaIxAAAAAMBxeNCvKiYXfH/WdLf7HzUXidV+1rSecYrGfrlp1/IGPp1QTJIHAHD5YOOPo/OJ72r9+I3EOq/y3AvHdVt1XHW6eGzLT1T1IrXHBb5usd/oLtcu9nbMUhSsW/z4nCIcj/pBxoD+Ok1ibGvXDU/qhyMQjQEAAAAAjkdBv5IpT7SU2CmJ/FlzkTgF41xv0ZOCbr+rMMnkjiQPAABg4iHFOvliLTvlN7adiccnsGlaisWqCHax2KuLVUksoViPWYrivDimL3Z94aoOiwe8P+pqtn1ffKiqY0RjAAAAAIDTyGRKwb8SgTc1CcM31ra27URjF4Ozqsn/5v8jSPAAAAB67ivWeaXvde1udPumGrGu5v69E+3Sd+fn+VVHLhbrvgkuFrtg7E2VxSxFcX4s9cWM8XLyIPezsEBXuG3Kqo6rTq+AF4jGAAAAAAB3I5MrJQJVu+B9u9A6MmHNVrbNfQAAABhzH7HubbR3VfVX7W52mze5TeG4I/27RD//PF+3+NealqL4vXrBWFXFqiz+9fZ/WYri/Fjqi/q7zlFOJORNkF1E/uf29Qd90SvgTxGQEY0BAAAAAO5Hl2y5gNxVHGXA3onBCMUAAAAPxyliXVfxKyFXTcLx15qEYy1HNRKO/TMkGOdSFBKLP9RUMdxVF+uxbnCXYrEqi1mK4vxYm8ToJjBy8sJvkKymiYyDpVPUThGQEY0BAAAAAB6OkYAs1qo71v4fAAAA7s4xYp2Lxl716zehU/tSh8LxqOI4K0iPrS5OwdirinMZCq8sZimK82bUF0eTF1n5/q5pb2teAe/308gl0lZv4IhoDAAAAADwOCwJwEuVxgAAAPB4HCPWSbBL0dhvRPdnVX2uqr9rLhxLqFsSjV2U1meouviXOqww9q1XFrtYnJXFLEVx/nR9MSuNfXIhRWOfzPh0u6+JDAnHKR77pIZu4LjphGNEYwAAAACAp4ekDQAA4Pk4RqzzZQEk6vrN6CQaf6mdcPy1DpcG6ETjTjD2KmZ9hj7nN9tXBfIplcUIxueN98U8X13FcfYZr37/taY+qYkMF49/1NQ/f96+v4Tjg4pjRGMAAAAAAAAAAHhtLIl1XbWxi7q/1U6c+1y7tY1dNFZlpy8H4AJuin9ZZaxK419t6+sVIxZfHuqL6nt6To9zIiP7jfrKf2rql3/XvAr+W03V8H4Dx6pBxTGiMQAAAAAAAAAAvEbWxLpRtfHvNVV0jiqNfYkKf08tN9CJxrlm8i81F4oRiy+X7IubaNkf31Z/w0RVwP9VU9/M5jdGFLmcCqIxAAAAAAAAAAC8WpbEuhTqtK7xx9oJxxLhvtZUxZmVxv45uUat38TM16d9H00Cs69ZjFh8efi5y/1umQq/caL65GdrX2onHv9Vk5D8+fb/NUkisTj7K6IxAAAAAAAAAAC8ajqxzkVeXxZA1cBfrX2rqcr4R+0EuLU1jXNd426bVcWIxa8DTWSsVRxrssEnMv6quVCs9ql2y1dIMN7WVBH/U8/52saIxgAAAAAAAAAAAHOxLpeVyArP79H8JmO+nrG/dydEL7UUihGLXw/qi+ozXf/J9bY/1q7yXcumSDj+dPv3tzX17R912G/1mVWFaAwAAAAAAAAAACBcrKvaiXMpHP+snVj3s+ZrGHeCsb9XJ/y9if0Uibtq07ItXC7ZF7WkhPqQL3PyvnbLVPxjTaKxBONN7frnd3vN15r63M8yEI0BAAAAAAAAAAAmUpBNse5NTevAds0FY9EJxy4gX8VzI5EYsfh14efb+4RPNKgC/nvtqo6/1U4Q1o0Ur2//52ftROIvNa1xrOUqvK9VFaIxAAAAAAAAAABAx0g81iX+3kZicZLi8aj5a3MfXh+bmi9XUbbvwvGPmm6g+O72b3X7/N9V9WfthGTdWLEVjKsQjQEAAAAAAAAAAJZIQU0CXi1sl+jEYIRiWGM0iZHi8Y+alpzQ+sVfaqo8fl/zGyz6++z7NqIxAAAAAAAAAADAOp2Iq+eOEYuTpfcDGLFUAX9TU/XwtqaqY1Uep1ic1e17EI0BAAAAAAAAAABOo6s+rjpOPEYYhoeg60dajkI3bJRIrG0nGM/YbDab7Xa7RTQGAAAAAAAAAAB4GBCE4anpKo9VcaxlK/xGi9largoAAAAAAAAAAAAAXjK5HvZa6/5vD6IxAAAAAAAAAAAAwGUwqh4+SiwWiMYAAAAAAAAAAAAAl8Pi0hPHgGgMAAAAAAAAAAAAAHsQjQEAAAAAAAAAAABgD6IxAAAAAAAAAAAAAOxBNAYAAAAAAAAAAACAPYjGAAAAAAAAAAAAALAH0RgAAAAAAAAAAAAA9iAaAwAAAAAAAAAAAMAeRGMAAAAAAAAAAAAA2INoDAAAAAAAAAAAAAB7EI0BAAAAAAAAAAAAYA+iMQAAAAAAAAAAAADsQTQGAAAAAAAAAAAAgD2IxgAAAAAAAAAAAACwB9EYAAAAAAAAAAAAAPYgGgMAAAAAAAAAAADAHkRjAAAAAAAAAAAAANiDaAwAAAAAAAAAAAAAexCNAQAAAAAAAAAAAGAPojEAAAAAAAAAAAAA7EE0BgAAAAAAAAAAAIA9iMYAAAAAAAAAAAAAsAfRGAAAAAAAAAAAAAD2IBoDAAAAAAAAAAAAwB5EYwAAAAAAAAAAAADYg2gMAAAAAAAAAAAAAHsQjQEAAAAAAAAAAABgD6IxAAAAAAAAAAAAAOxBNAYAAAAAAAAAAACAPYjGAAAAAAAAAAAAALAH0RgAAAAAAAAAAAAA9iAaAwAAAAAAAAAAAMAeRGMAAAAAAAAAAAAA2INoDAAAAAAAAAAAAAB7EI0BAAAAAAAAAAAAYA+iMQAAAAAAAAAAAADsQTQGAAAAAAAAAAAAgD2IxgAAAAAAAAAAAACwB9EYAAAAAAAAAAAAAPYgGgMAAAAAAAAAAADAHkRjAAAAAAAAAAAAANiDaAwAAAAAAAAAAAAAexCNAQAAAAAAAAAAAGAPojEAAAAAAAAAAAAA7EE0BgAAAAAAAAAAAIA9iMYAAAAAAAAAAAAAsAfRGAAAAAAAAAAAAAD2IBoDAAAAAAAAAAAAwB5EYwAAAAAAAAAAAADYg2gMAAAAAAAAAAAAAHsQjQEAAAAAAAAAAABgD6IxAAAAAAAAAAAAAOxBNAYAAAAAAAAAAACAPYjGAAAAAAAAAAAAALAH0RgAAAAAAAAAAAAA9iAaAwAAAAAAAAAAAMAeRGMAAAAAAAAAAAAA2INoDAAAAAAAAAAAAAB7EI0BAAAAAAAAAAAAYA+iMQAAAAAAAAAAAADsQTQGAAAAAAAAAAAAgD2IxgAAAAAAAAAAAACwB9EYAAAAAAAAAAAAAPYgGgMAAAAAAAAAAADAHkRjAAAAAAAAAAAAANiDaAwAAAAAAAAAAAAAexCNAQAAAAAAAAAAAGAPojEAAAAAAAAAAAAA7LmvaLy9bQAAAAAAAAAAAADw/Nxbs72vaAwAAAAAAAAAAAAAF8T1yt9dkXaFOtXqbVVt6p4KNgAAAAAAAAAAAADcmVP03CEj0bh7c7Wb2L+pXcXyTe2EYwAAAAAAAAAAAAB4WlKz7fTco8TjpUrjTixW+xmtar7UBeIxAAAAAAAAAAAAwOPjwm+n3bquOxKPZ1xXVW232+1ms0mh18VifcCPqvp+277VJDq/KURjAAAAAAAAAAAAgKemE42/3TZpuT9qLiK3ovF2u91W9ZXGWV0ssfhbVX2tqn+q6u+qenv7+h+1E403hVgMAAAAAAAAAAAA8BxI05Vo/Pdt+6d2uu63msTjrDqe4aKxv8DF4u81icV/VdXnqnp3+7rvtROPEY0BAAAAAAAAAAAAng8Xjb/XTjD+s3Z67l81icdeeXwT/1tVy5XGNzUJxn/X7s0/1SQYf6uqD7UTja9qvjwFAAAAAAAAAAAAADwtWrv4e+1E4s9V9X9rp+t+rp3OK+F4uExFisYuGKvKWG/+S01LUnyrnTr9/vY9ropKYwAAAAAAAAAAAIDnIrXdr7XTcD9V1f+pSTj+p6Zq41Y47iqNfWmKr1X1pXbVxXrt99vnPtw+r5vgIRoDAAAAAAAAAAAAPA95n7pvNRUESzj+s3ba7tc6XKJiz6jSWOteSDR+c/v3H7UrYf5UU5Ux6xkDAAAAAAAAAAAAPD+u76ooWEsP/1lz0fj77evGlcbb7Xa72WyqpnUvftROjdZaxVKntSyFboBHlTEAAAAAAAAAAADA85PVxioM/qd2YvGX2onI32panuKmdvLw6o3wJBpLCPZ1MN7etm4tY4RjAAAAAAAAAAAAgKdna9u8b53E46+1E4xdNF69EV7V9Ib+2MuZJRb7shSIxQAAAAAAAAAAAADPz9aa1iz+UZOArH397UA03ljV8e6J3RoValfW3tR8OQpt9/9aAAAAAAAAAAAAAPBcuNir4mBfrkJC8Y39bbY0RVUjGlftheOquXjsW5ajAAAAAAAAAAAAADhfuuUqfLut2inG+Y+taFw1E46reqEYwRgAAAAAAAAAAADgfNk2270g3AnGVQui8exFcwF5/3TzHAAAAAAAAAAAAACcBwfi70godo4SjZcYCMoAAAAAAAAAAAAA8AwcIwwvcW/RGAAAAAAAAAAAAAAuh/8HnFzohGpvE8UAAAAASUVORK5CYII=" style="opacity: 0.30000000000000004;mix-blend-mode: multiply"/>
+ <path d="M0,570H1400V481.57c-10.41,2.42-21.89-12-43.86-33.42-24.09-23.44-49.14-1.82-80.28,15.36-15.93,8.79-40.26,28.57-59.26,7.73-12.57-13.78-31.25-24.44-50.53-21.67-16.23,2.33-26.61,17.09-43.84,9.85-8.33-3.5-15.38-9.54-23.68-13.1-19.77-8.48-34.55,3.09-53.3,1.55-14.26-1.17-22-9.9-33.9-17.72-15.76-10.34-36.29-11.11-54.53-6.37-12.66,3.29-73.81,45.15-85,34.87-6.34-5.85-13.87-13.8-23.38-23-24.09-23.44-49.14-1.83-80.28,15.36-15.93,8.79-40.26,28.56-59.26,7.73-22.8-25-49-25.35-65.6-17.38-37.88,18.16-56.6,13.85-77.12-4.08-41.1-35.92-44.2,15.12-94.21-22-24.09-17.87-63.39,9-72.94,17.93-25.83,24.1-64.07,27.46-96.76,13.24-16.07-7-33.11-18.93-49.4-12.47-6.34,2.51-11.35,7.56-17.46,10.57-20.06,9.9-42.84-4.25-62.9-14.16-38.42-19-134.36-32.91-166.46,8-3.37,4.29-4.83,10.37-6.1,17.48Z" style="fill: url(#linear-gradient-2)"/>
+ </g>
+ <g>
+ <image width="1431" height="154" transform="translate(-15.29 428)" xlink:href="data:image/png;base64,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" style="opacity: 0.30000000000000004;mix-blend-mode: multiply"/>
+ <path d="M0,571H1400V550.87c-24.81,4.16-51.66-20.57-104-57.1-57.44-40.06-117.14-3.12-191.37,26.25-38,15-95.95,48.82-141.25,13.21C909,490.52,846.6,489.91,807,503.52c-90.28,31.05-134.92,23.68-183.82-7-98-61.4-105.35,25.83-224.56-37.6-57.41-30.55-151.1,15.4-173.86,30.64C142.37,544.75,106.9,484.29,0,485.41Z" style="fill: #f6f6f6"/>
+ </g>
+ </g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/15.svg b/addons/web_editor/static/shapes/Wavy/15.svg
new file mode 100644
index 00000000..eb2529db
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/15.svg
@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="700" y1="563.5075" x2="700" y2="377.5534" gradientTransform="matrix(1 0 0 -1 0 572)">
+ <stop offset="0" style="stop-color:#3AADAA"/>
+ <stop offset="1" style="stop-color:#383e45"/>
+</linearGradient>
+<path style="fill:url(#SVGID_1_);" d="M1400,0H0v2.3c24.8-2.4,52.2,12,104.5,33.4c57.4,23.5,117.1,1.8,191.3-15.4c38-8.8,95.9-28.6,141.2-7.7
+ C491.3,37.6,553.7,38,593.3,30c90.2-18.2,134.9-13.9,183.7,4c97.9,35.9,105.3-15.1,224.5,22c57.4,17.9,151-9,173.8-17.9
+ c82.4-32.3,117.9,3.1,224.7,2.5V0z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/16.svg b/addons/web_editor/static/shapes/Wavy/16.svg
new file mode 100644
index 00000000..1523c1f5
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/16.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <path d="M0,163.34V570H980s-18.62-46.93-15.08-86.36c2.33-25.94-4.78-55.36-31.72-96.19-43.23-65.51-189.8-77.89-204.22-166.58-5.21-32-46.74-74.86-101-105.48-37.29-21-91.29-14.06-135.74-26.36-142.37-39.42-163-3.27-287.58,5C143.57,98.08,85,113.75,36.44,140.2A304,304,0,0,0,0,163.34Z" style="fill: #3aadaa"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/17.svg b/addons/web_editor/static/shapes/Wavy/17.svg
new file mode 100644
index 00000000..49977d5a
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/17.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <path d="M1399.59,0H979.16c15.7,35.12,54.6,104.19,104.75,193.25,27,47.87,136.72,117,175.95,131.23C1347.76,356.34,1388.62,514.91,1400,555Z" style="fill: #3aadaa"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/18.svg b/addons/web_editor/static/shapes/Wavy/18.svg
new file mode 100644
index 00000000..dbd5ac02
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/18.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <path d="M1399.91,570H.59C.59,567.57,0,117.65,0,0,0,0,146.24,456.93,438,359.24,696.06,272.83,726.9,605,975.58,437c53.05-35.85,155.72-230,197.48-278.89C1236.34,84,1399.91,15.29,1399.91,15.45,1400.11,28.79,1399.91,570,1399.91,570Z" style="fill: #383e45"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/19.svg b/addons/web_editor/static/shapes/Wavy/19.svg
new file mode 100644
index 00000000..c841ce73
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/19.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+<path style="fill:#383E45;" d="M0,0h1400c0,3.5,0-2.8,0,8.6c0,3.8-278.2,155.4-442.6,256.3C849.6,331,672.5,254.4,460.9,386.1
+ C369.1,443.2,287,407.8,0,293.7V0z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/20.svg b/addons/web_editor/static/shapes/Wavy/20.svg
new file mode 100644
index 00000000..b1dcd633
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/20.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <path d="M1400,570H0v-23.6c268.8,17.4,537,34.5,681.6,2.6c12.2-2.7,24.6-5,37.1-7.2c340.1-58.8,422.8-12.1,681.4,4.6
+ V570z" style="fill: #7c6576"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/21.svg b/addons/web_editor/static/shapes/Wavy/21.svg
new file mode 100644
index 00000000..4c84882b
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/21.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <path d="M0,0L1400,0c-24.4,0-51.2,12.3-103.6,33.7c-57.5,23.4-117.2,1.8-191.4-15.4c-38-8.8-96-28.6-141.3-7.7
+ c-54.4,25-116.8,25.3-156.4,17.4C717,9.8,672.3,14.1,623.4,32c-98,35.9-105.4-15.1-224.6,22c-57.4,17.9-151.1-9-173.9-17.9
+ C142.4,3.8,106.9,39.2,0,38.6V0.3z" style="fill: #7c6576"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/22.svg b/addons/web_editor/static/shapes/Wavy/22.svg
new file mode 100644
index 00000000..5162f829
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/22.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <path style="fill: #f6f6f6" d="M1400,570l-175.8-118.5c-128.3-86.4-294.3-94.3-429.8-19.7c-170.8,94-323.5,135.4-425.6,30.4
+ c-12.9-13.2-30.1-21.3-48.4-23l-37.3-3.5c-29.7-2.8-56.6-18.7-73.2-43.4C168.9,331.5,114.8,271.4,0,302.1V570H1400z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Wavy/23.svg b/addons/web_editor/static/shapes/Wavy/23.svg
new file mode 100644
index 00000000..95a0b212
--- /dev/null
+++ b/addons/web_editor/static/shapes/Wavy/23.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400">
+ <path style="fill: #f6f6f6" d="M0,342.7L0,342.7c172.4,4.6,339.1-62.3,460.3-185l73.3-74.1c41.5-20.2,86.6-32.1,132.7-35.1L1400,0H0V342.7z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Zigs/01.svg b/addons/web_editor/static/shapes/Zigs/01.svg
new file mode 100644
index 00000000..7d4a775f
--- /dev/null
+++ b/addons/web_editor/static/shapes/Zigs/01.svg
@@ -0,0 +1,47 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <defs>
+ <linearGradient id="linear-gradient" x1="700" y1="-30.22" x2="700" y2="626.71" gradientUnits="userSpaceOnUse">
+ <stop offset="0.58" stop-color="#7c6576"/>
+ <stop offset="0.9" stop-color="#7c6576"/>
+ </linearGradient>
+ </defs>
+ <polygon points="0 502.45 79.58 442.51 164.47 502.45 272.81 419.84 307.24 460.09 341.25 460.75 445.38 355.3 514.72 400.49 581.24 347.36 634.03 394.39 747.53 276.01 826.31 328.91 972.04 195.7 1146 306.45 1227.84 210.18 1280.36 249.02 1400 99.9 1400 570 0 570 0 502.45" style="opacity: 0.15;fill: url(#linear-gradient)"/>
+ <g>
+ <polyline points="0 503.48 79.58 443.54 164.47 503.48 272.81 420.87 307.24 461.12 341.25 461.79 445.38 356.33 514.72 401.52 581.24 348.39 634.03 395.43 747.53 277.04 826.31 329.94 972.04 196.73 1146 307.48 1227.84 211.22 1280.36 250.05 1400 100.93" style="fill: none;stroke: #7c6576;stroke-miterlimit: 10;stroke-width: 4px"/>
+ <circle cx="79.8" cy="444.33" r="6.5" style="fill: #7c6576"/>
+ <circle cy="503.48" r="6.5" style="fill: #7c6576"/>
+ <circle cx="272.91" cy="421.08" r="6.5" style="fill: #7c6576"/>
+ <circle cx="341.56" cy="461.12" r="6.5" style="fill: #7c6576"/>
+ <circle cx="307.24" cy="461.12" r="6.5" style="fill: #7c6576"/>
+ <circle cx="445.38" cy="356.33" r="6.5" style="fill: #7c6576"/>
+ <circle cx="515.7" cy="400.42" r="6.5" style="fill: #7c6576"/>
+ <circle cx="747.43" cy="277.23" r="6.5" style="fill: #7c6576"/>
+ <circle cx="634.03" cy="395.42" r="6.5" style="fill: #7c6576"/>
+ <circle cx="581.06" cy="348.99" r="6.5" style="fill: #7c6576"/>
+ <circle cx="826.77" cy="329.54" r="6.5" style="fill: #7c6576"/>
+ <circle cx="971.7" cy="196.7" r="6.5" style="fill: #7c6576"/>
+ <circle cx="1145.84" cy="307.2" r="6.5" style="fill: #7c6576"/>
+ <circle cx="1227.89" cy="211.22" r="6.5" style="fill: #7c6576"/>
+ <circle cx="1279.79" cy="249.17" r="6.5" style="fill: #7c6576"/>
+ <circle cx="1400" cy="100.93" r="6.5" style="fill: #7c6576"/>
+ <circle cx="164.07" cy="503.48" r="6.5" style="fill: #7c6576"/>
+ </g>
+ <g style="opacity: 0.15">
+ <polyline points="0 540.03 55.24 561.59 100.45 528.1 159.07 544.84 336.56 395.43 488.91 528.1 586.06 456.12 654.68 479.12 840.54 232.77 917.56 324.54 954.4 277.23 987.89 324.54 1074.96 244.17 1250.77 356.33 1405 184.84" style="fill: none;stroke: #7c6576;stroke-miterlimit: 10"/>
+ <circle cx="55.24" cy="561.59" r="6.5" style="fill: #7c6576"/>
+ <circle cx="100.34" cy="529.19" r="6.5" style="fill: #7c6576"/>
+ <circle cx="159.07" cy="544.19" r="6.5" style="fill: #7c6576"/>
+ <circle cx="336.56" cy="395.42" r="6.5" style="fill: #7c6576"/>
+ <circle cx="489.15" cy="529.19" r="6.5" style="fill: #7c6576"/>
+ <circle cx="586.06" cy="456.12" r="6.5" style="fill: #7c6576"/>
+ <circle cx="654.8" cy="479.12" r="6.5" style="fill: #7c6576"/>
+ <circle cx="841" cy="233.25" r="6.5" style="fill: #7c6576"/>
+ <circle cx="918.06" cy="324.54" r="6.5" style="fill: #7c6576"/>
+ <circle cx="953.61" cy="277.23" r="6.5" style="fill: #7c6576"/>
+ <circle cx="988.9" cy="324.54" r="6.5" style="fill: #7c6576"/>
+ <circle cx="1074.5" cy="244.17" r="6.5" style="fill: #7c6576"/>
+ <circle cx="1251.19" cy="356.33" r="6.5" style="fill: #7c6576"/>
+ <circle cx="1400" cy="191.7" r="6.5" style="fill: #7c6576"/>
+ <circle cy="539.19" r="6.5" style="fill: #7c6576"/>
+ </g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Zigs/02.svg b/addons/web_editor/static/shapes/Zigs/02.svg
new file mode 100644
index 00000000..0c5c0745
--- /dev/null
+++ b/addons/web_editor/static/shapes/Zigs/02.svg
@@ -0,0 +1,19 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -830 1400 1400">
+ <polyline points="0 524.92 55.24 546.48 100.45 512.99 159.07 529.73 336.56 380.31 488.91 512.99 586.06 441.01 654.68 464.01 840.54 217.66 917.56 309.43 954.4 262.12 987.89 309.43 1074.96 229.06 1250.77 341.22 1405 169.73" style="fill: none;stroke: #7c6576;stroke-miterlimit: 10;stroke-width: 4px"/>
+ <circle cx="55.24" cy="546.48" r="11.05" style="fill: #7c6576"/>
+ <circle cx="100.34" cy="514.07" r="11.05" style="fill: #7c6576"/>
+ <circle cx="159.07" cy="529.07" r="11.05" style="fill: #7c6576"/>
+ <circle cx="336.56" cy="380.31" r="11.05" style="fill: #7c6576"/>
+ <circle cx="489.15" cy="514.07" r="11.05" style="fill: #7c6576"/>
+ <circle cx="586.06" cy="441.01" r="11.05" style="fill: #7c6576"/>
+ <circle cx="654.8" cy="464.01" r="11.05" style="fill: #7c6576"/>
+ <circle cx="841" cy="218.14" r="11.05" style="fill: #7c6576"/>
+ <circle cx="918.06" cy="309.43" r="11.05" style="fill: #7c6576"/>
+ <circle cx="953.61" cy="262.12" r="11.05" style="fill: #7c6576"/>
+ <circle cx="988.9" cy="309.43" r="11.05" style="fill: #7c6576"/>
+ <circle cx="1074.5" cy="229.06" r="11.05" style="fill: #7c6576"/>
+ <circle cx="1251.19" cy="341.22" r="11.05" style="fill: #7c6576"/>
+ <circle cx="1400" cy="176.59" r="5" style="fill: #7c6576"/>
+ <circle cy="524.07" r="5" style="fill: #7c6576"/>
+ <polygon points="0 524.92 55.24 546.48 100.45 512.99 159.07 529.73 336.56 380.31 488.91 512.99 586.06 441.01 654.68 464.01 840.54 217.66 917.56 309.43 954.4 262.12 987.89 309.43 1074.96 229.06 1250.77 341.22 1405 169.73 1405 570 0 570 0 524.92" style="fill: #7c6576;opacity: 0.15"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/Zigs/03.svg b/addons/web_editor/static/shapes/Zigs/03.svg
new file mode 100644
index 00000000..889001cb
--- /dev/null
+++ b/addons/web_editor/static/shapes/Zigs/03.svg
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1400 1140" xml:space="preserve">
+<style type="text/css">
+ .st0{opacity:0.5;fill:#3AADAA;enable-background:new ;}
+ .st1{opacity:0.08;fill:#3AADAA;enable-background:new ;}
+ .st2{opacity:0.2;fill:#3AADAA;enable-background:new ;}
+ .st3{fill:#3AADAA;}
+</style>
+<g>
+ <path class="st0" d="M1335.3,914c0,0.1-0.1,0.1-0.2,0.2l-53.9,53.9h56.1c0.1-0.1,0.1-0.2,0.2-0.3l54.8-53.8H1335.3z"/>
+ <polygon class="st1" points="1346.4,968 1399.6,968 1399.6,914.9 "/>
+ <polygon class="st1" points="1334.5,801.5 1281.3,853.8 1338.3,853.8 1390.6,801.5 "/>
+ <polygon class="st0" points="1346.4,853.8 1399.1,853.8 1399.1,801.5 "/>
+ <polygon class="st2" points="1281.3,858.7 1334.8,910.3 1389.8,910.3 1338.3,858.7 "/>
+ <polygon class="st3" points="1346.4,858.7 1399.6,909.2 1399.6,858.7 "/>
+ <polygon class="st1" points="1335,1028.6 1281.3,1079.9 1338.8,1079.9 1391,1028.6 "/>
+ <polygon class="st3" points="1346.4,1079.9 1399.6,1079.9 1399.6,1029 "/>
+ <polygon class="st3" points="1339.3,973 1281.3,973 1335.1,1024.9 1391.2,1024.9 "/>
+ <polygon class="st2" points="1346.4,973 1399.6,1024.6 1399.6,973 "/>
+ <polygon class="st0" points="1336.9,1140 1393,1140 1337.8,1084.8 1281.3,1084.8 "/>
+ <polygon class="st2" points="1346.4,1084.8 1399.6,1138.1 1399.6,1084.8 "/>
+ <path class="st0" d="M1338.3,632.1h-57.1c0,0,54.3,52.4,54.4,52.7h56.3L1338.3,632.1z"/>
+ <polygon class="st2" points="1346.4,632.1 1400,683 1400,632.1 "/>
+ <path class="st0" d="M1339.6,746.7h-58.3l53.9,50.7c0.1,0.1,0.2,0.3,0.3,0.4h56.1L1339.6,746.7z"/>
+ <path class="st1" d="M1346.4,746.7c0,0,52.6,50.9,52.7,51.1l-0.2-51.1H1346.4z"/>
+ <polygon class="st3" points="1336.9,572.5 1281.3,627.1 1338.3,627.1 1393,572.5 "/>
+ <polygon class="st0" points="1399.5,574.3 1346.4,627.1 1400,627.1 "/>
+ <path class="st2" d="M1334.2,688.5l-52.9,53.2c0,0,56.2-0.4,56.3-0.5l52.7-52.7L1334.2,688.5z"/>
+ <polygon class="st3" points="1399.6,688.5 1346.4,741.7 1399.8,741.7 1399.8,688.5 "/>
+</g>
+<g>
+ <path class="st0" d="M1335.3,341.5c0,0.1-0.1,0.1-0.2,0.2l-53.9,53.9h56.1c0.1-0.1,0.1-0.2,0.2-0.3l54.8-53.8L1335.3,341.5z"/>
+ <polygon class="st1" points="1346.4,395.6 1399.6,395.6 1399.6,342.4 "/>
+ <polygon class="st1" points="1334.5,229 1281.3,281.3 1338.3,281.3 1390.6,229 "/>
+ <polygon class="st0" points="1346.4,281.3 1399.1,281.3 1399.1,229 "/>
+ <polygon class="st2" points="1281.3,286.2 1334.8,337.8 1389.8,337.8 1338.3,286.2 "/>
+ <polygon class="st3" points="1346.4,286.2 1399.6,336.7 1399.6,286.2 "/>
+ <polygon class="st1" points="1335,456.1 1281.3,507.4 1338.8,507.4 1391,456.1 "/>
+ <polygon class="st3" points="1346.4,507.4 1399.6,507.4 1399.6,456.6 "/>
+ <polygon class="st3" points="1339.3,400.5 1281.3,400.5 1335.1,452.4 1391.2,452.4 "/>
+ <polygon class="st2" points="1346.4,400.5 1399.6,452.1 1399.6,400.5 "/>
+ <polygon class="st0" points="1336.9,567.5 1393,567.5 1337.8,512.4 1281.3,512.4 "/>
+ <polygon class="st2" points="1346.4,512.4 1399.6,565.6 1399.6,512.4 "/>
+ <path class="st0" d="M1338.3,59.6h-57.1c0,0,54.3,52.4,54.4,52.7h56.3L1338.3,59.6z"/>
+ <polygon class="st2" points="1346.4,59.6 1400,110.5 1400,59.6 "/>
+ <path class="st0" d="M1339.6,174.2h-58.3l53.9,50.7c0.1,0.1,0.2,0.3,0.3,0.4h56.1L1339.6,174.2z"/>
+ <path class="st1" d="M1346.4,174.2c0,0,52.6,50.9,52.7,51.1l-0.2-51.1H1346.4z"/>
+ <polygon class="st3" points="1336.9,0 1281.3,54.7 1338.3,54.7 1393,0 "/>
+ <polygon class="st0" points="1399.5,1.8 1346.4,54.7 1400,54.7 "/>
+ <path class="st2" d="M1334.2,116l-52.9,53.2c0,0,56.2-0.4,56.3-0.5l52.7-52.7L1334.2,116z"/>
+ <polygon class="st3" points="1399.6,116 1346.4,169.2 1399.8,169.2 1399.8,116 "/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Zigs/04.svg b/addons/web_editor/static/shapes/Zigs/04.svg
new file mode 100644
index 00000000..42f90ae9
--- /dev/null
+++ b/addons/web_editor/static/shapes/Zigs/04.svg
@@ -0,0 +1,41 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 185.99">
+ <defs>
+ <style>
+ .cls-1 {
+ isolation: isolate;
+ }
+
+ .cls-2, .cls-3, .cls-4, .cls-5, .cls-6 {
+ fill: none;
+ stroke: #3aadaa;
+ stroke-miterlimit: 10;
+ stroke-width: 4px;
+ }
+
+ .cls-3 {
+ opacity: 0.75;
+ }
+
+ .cls-4 {
+ opacity: 0.5;
+ }
+
+ .cls-5 {
+ opacity: 0.25;
+ }
+
+ .cls-6 {
+ opacity: 0.06;
+ }
+ </style>
+ </defs>
+ <g class="cls-1">
+ <g id="Waves">
+ <polyline class="cls-2" points="1400 100.51 1330 30.2 1260 100.51 1190 30.2 1120 100.51 1050 30.2 980 100.51 910 30.2 840 100.51 770 30.2 700 100.51 630 30.2 560 100.51 490 30.2 420 100.51 350 30.2 280 100.51 210 30.2 140 100.51 70 30.2 0 100.51"/>
+ <polyline class="cls-3" points="1400 121.88 1330 51.57 1260 121.88 1190 51.57 1120 121.88 1050 51.57 980 121.88 910 51.57 840 121.88 770 51.57 700 121.88 630 51.57 560 121.88 490 51.57 420 121.88 350 51.57 280 121.88 210 51.57 140 121.88 70 51.57 0 121.88"/>
+ <polyline class="cls-4" points="1400 143.25 1330 72.94 1260 143.25 1190 72.94 1120 143.25 1050 72.94 980 143.25 910 72.94 840 143.25 770 72.94 700 143.25 630 72.94 560 143.25 490 72.94 420 143.25 350 72.94 280 143.25 210 72.94 140 143.25 70 72.94 0 143.25"/>
+ <polyline class="cls-5" points="1400 164.62 1330 94.31 1260 164.62 1190 94.31 1120 164.62 1050 94.31 980 164.62 910 94.31 840 164.62 770 94.31 700 164.62 630 94.31 560 164.62 490 94.31 420 164.62 350 94.31 280 164.62 210 94.31 140 164.62 70 94.31 0 164.62"/>
+ <polyline class="cls-6" points="1400 185.99 1330 115.68 1260 185.99 1190 115.68 1120 185.99 1050 115.68 980 185.99 910 115.68 840 185.99 770 115.68 700 185.99 630 115.68 560 185.99 490 115.68 420 185.99 350 115.68 280 185.99 210 115.68 140 185.99 70 115.68 0 185.99"/>
+ </g>
+ </g>
+</svg>
diff --git a/addons/web_editor/static/shapes/Zigs/05.svg b/addons/web_editor/static/shapes/Zigs/05.svg
new file mode 100644
index 00000000..da0a2ffc
--- /dev/null
+++ b/addons/web_editor/static/shapes/Zigs/05.svg
@@ -0,0 +1,10 @@
+<svg id="Waves" xmlns="http://www.w3.org/2000/svg" viewBox="0 -1303.09 1400 1400">
+ <defs>
+ <style>
+ .cls-2 {
+ fill: #f6f6f6;
+ }
+ </style>
+ </defs>
+ <path class="cls-2" d="M1400,96.91V23.2A6,6,0,0,0,1389.75,19l-55.5,55.74a6,6,0,0,1-8.5,0l-61.5-61.77a6,6,0,0,0-8.5,0l-61.5,61.77a6,6,0,0,1-8.5,0l-61.5-61.77a6,6,0,0,0-8.5,0l-61.5,61.77a6,6,0,0,1-8.5,0l-61.5-61.77a6,6,0,0,0-8.5,0l-61.5,61.77a6,6,0,0,1-8.5,0l-61.5-61.77a6,6,0,0,0-8.5,0l-61.5,61.77a6,6,0,0,1-8.5,0l-61.5-61.77a6,6,0,0,0-8.5,0l-61.5,61.77a6,6,0,0,1-8.5,0l-61.5-61.77a6,6,0,0,0-8.5,0l-61.5,61.77a6,6,0,0,1-8.5,0l-61.5-61.77a6,6,0,0,0-8.5,0l-61.5,61.77a6,6,0,0,1-8.5,0l-61.5-61.77a6,6,0,0,0-8.5,0l-61.5,61.77a6,6,0,0,1-8.5,0l-61.5-61.77a6,6,0,0,0-8.5,0L74.25,74.71a6,6,0,0,1-8.5,0L10.25,19A6,6,0,0,0,0,23.2V96.91Z"/>
+</svg>
diff --git a/addons/web_editor/static/shapes/convert.js b/addons/web_editor/static/shapes/convert.js
new file mode 100644
index 00000000..fa62749e
--- /dev/null
+++ b/addons/web_editor/static/shapes/convert.js
@@ -0,0 +1,97 @@
+/*
+The following script can be used to convert SVGs exported from illustrator into
+a format that's compatible with the shape system. It runs with nodejs. Some
+manual conversion may be necessary.
+*/
+
+const fs = require('fs');
+const path = require('path');
+
+const palette = {
+ '1': '#3AADAA',
+ '2': '#7C6576',
+ '3': '#F6F6F6',
+ '4': '#FFFFFF',
+ '5': '#383E45',
+};
+
+const positions = ['top', 'left', 'bottom', 'right', 'center', 'stretch'];
+
+const directories = fs.readdirSync(__dirname).filter(nodeName => {
+ return nodeName[0] !== '.' && fs.lstatSync(path.join(__dirname, nodeName)).isDirectory();
+});
+const files = directories.flatMap(dirName => {
+ return fs.readdirSync(path.join(__dirname, dirName))
+ .filter(fileName => fileName.endsWith('.svg'))
+ .map(fileName => path.join(__dirname, dirName, fileName));
+});
+
+const shapes = [];
+files.filter(f => f.endsWith('svg')).forEach(filePath => {
+ const svg = String(fs.readFileSync(filePath));
+ const fileName = filePath.match(/([^/]+)$/)[1];
+
+ const colors = svg.match(/#[0-9A-F]{3,}/gi);
+ const nonPaletteColors = colors && colors.filter(color => !Object.values(palette).includes(color.toUpperCase()));
+ const shape = {
+ svg,
+ name: fileName.split(/[.-]/)[0],
+ page: filePath.slice(__dirname.length + 1, -fileName.length - 1),
+ colors: Object.keys(palette).filter(num => new RegExp(palette[num], 'i').test(svg)),
+ position: positions.filter(pos => fileName.includes(pos)),
+ nonIsometric: fileName.includes('+'),
+ nonPaletteColors: nonPaletteColors && nonPaletteColors.length ? nonPaletteColors.join(' ') : null,
+ containsImage: svg.includes('<image'),
+ repeatY: fileName.includes('repeaty'),
+ };
+ shape.optionXML = `<we-button data-shape="web_editor/${shape.page}/${shape.name}" data-select-label="${shape.page} ${shape.name}"/>`;
+ if (shape.position[0] === 'stretch') {
+ shape.position = ['center'];
+ shape.size = '100% 100%';
+ } else {
+ shape.size = '100% auto';
+ }
+ shape.scss = `'${shape.page}/${shape.name}': ('position': ${shape.position[0]}, 'size': ${shape.size}, 'colors': (${shape.colors.join(', ')}), 'repeat-y': ${shape.repeatY})`;
+ shapes.push(shape);
+});
+const xml = shapes.map(shape => shape.optionXML).join('\n');
+const scss = shapes.map(shape => shape.scss).join(',\n');
+const nonConformShapes = shapes.flatMap(shape => {
+ const violations = {};
+ let invalid = false;
+ // Not sure if we want this check, edi still trying to see if she can do shadows without embedding PNGs
+ // if (shape.containsImage) {
+ // violations.containsImage = shape.containsImage;
+ // invalid = true;
+ // }
+ if (shape.nonIsometric) {
+ violations.nonIsometric = shape.nonIsometric;
+ invalid = true;
+ }
+ if (shape.nonPaletteColors) {
+ violations.nonPaletteColors = shape.nonPaletteColors;
+ invalid = true;
+ }
+ if (shape.position.length > 1 || shape.position.length == 0) {
+ violations.position = shape.position;
+ invalid = true;
+ }
+ if (!invalid) {
+ return []
+ }
+ return [[shape, violations]];
+});
+console.log('The following shapes are not conform:', nonConformShapes);
+
+const convertDir = './.converted';
+fs.mkdirSync(convertDir);
+const convertedPath = path.join(__dirname, convertDir);
+fs.writeFileSync(path.join(convertedPath, 'options.xml'), xml);
+fs.writeFileSync(path.join(convertedPath, 'variables.scss'), scss);
+shapes.forEach(shape => {
+ const pageDir = path.join(convertedPath, shape.page);
+ if (!fs.existsSync(pageDir)) {
+ fs.mkdirSync(pageDir);
+ }
+ fs.writeFileSync(path.join(pageDir, shape.name + '.svg'), shape.svg);
+});
diff --git a/addons/web_editor/static/src/img/curved_arrow.svg b/addons/web_editor/static/src/img/curved_arrow.svg
new file mode 100644
index 00000000..a8ed9acd
--- /dev/null
+++ b/addons/web_editor/static/src/img/curved_arrow.svg
@@ -0,0 +1,14 @@
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1144 1280">
+<g transform="translate(0,1280) scale(0.1,-0.1)" fill="#000000" stroke="none">
+<path d="M7166 11004 c-603 -901 -1094 -1639 -1092 -1641 1 -2 369 42 817 97
+448 55 825 101 838 103 30 3 26 14 86 -278 131 -634 218 -1317 255 -2010 13
+-246 13 -898 0 -1135 -71 -1262 -367 -2292 -883 -3065 -162 -242 -308 -418
+-541 -650 -661 -658 -1465 -1094 -2581 -1400 -1069 -293 -2283 -471 -3660
+-536 -132 -6 -242 -13 -244 -15 -6 -5 16 -190 30 -255 9 -43 13 -47 47 -53
+108 -17 1148 -2 1627 24 2760 152 4778 866 6094 2155 506 496 887 1018 1216
+1670 444 877 715 1860 819 2970 35 365 41 510 41 1000 0 581 -17 880 -76 1355
+-19 158 -65 452 -74 477 -5 14 -54 7 730 104 363 44 662 83 665 85 2 3 0 8 -6
+12 -6 4 -682 594 -1503 1312 -822 718 -1497 1306 -1501 1308 -4 2 -501 -734
+-1104 -1634z"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/src/img/snippet_disabled.svg b/addons/web_editor/static/src/img/snippet_disabled.svg
new file mode 100644
index 00000000..1d506689
--- /dev/null
+++ b/addons/web_editor/static/src/img/snippet_disabled.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15">
+ <g fill="none" class="snippet_disabled">
+ <path fill="#E16F2B" d="M7.5 0c1.36 0 2.616.335 3.765 1.006a7.466 7.466 0 0 1 2.73 2.73A7.337 7.337 0 0 1 15 7.5c0 1.36-.335 2.616-1.006 3.765a7.466 7.466 0 0 1-2.73 2.73A7.337 7.337 0 0 1 7.5 15a7.337 7.337 0 0 1-3.765-1.006 7.466 7.466 0 0 1-2.73-2.73A7.337 7.337 0 0 1 0 7.5c0-1.36.335-2.616 1.006-3.765a7.466 7.466 0 0 1 2.73-2.73A7.337 7.337 0 0 1 7.5 0z" class="path"/>
+ <path fill="#E17D41" d="M7.5 1c1.18 0 2.267.29 3.263.872a6.47 6.47 0 0 1 2.365 2.365C13.71 5.233 14 6.321 14 7.5a6.35 6.35 0 0 1-.872 3.263 6.47 6.47 0 0 1-2.365 2.365A6.358 6.358 0 0 1 7.5 14a6.35 6.35 0 0 1-3.263-.872 6.47 6.47 0 0 1-2.365-2.365A6.358 6.358 0 0 1 1 7.5c0-1.18.29-2.267.872-3.263a6.47 6.47 0 0 1 2.365-2.365A6.358 6.358 0 0 1 7.5 1z" class="path"/>
+ <path fill="#FFF" d="M8.51 10c.09 0 .167.03.23.093a.31.31 0 0 1 .093.23v1.855a.31.31 0 0 1-.093.23.313.313 0 0 1-.23.092h-2a.34.34 0 0 1-.24-.098.3.3 0 0 1-.103-.224v-1.856a.3.3 0 0 1 .104-.224.34.34 0 0 1 .24-.098zm.136-7.5c.097 0 .18.026.25.078A.19.19 0 0 1 9 2.754l-.188 6.064c-.006.065-.043.122-.109.171a.4.4 0 0 1-.245.073H6.531a.423.423 0 0 1-.25-.073c-.07-.049-.104-.106-.104-.17L6 2.753a.19.19 0 0 1 .104-.176.405.405 0 0 1 .25-.078z" class="shape"/>
+ </g>
+</svg>
diff --git a/addons/web_editor/static/src/img/snippets_options/bg_shape.svg b/addons/web_editor/static/src/img/snippets_options/bg_shape.svg
new file mode 100644
index 00000000..838ddc53
--- /dev/null
+++ b/addons/web_editor/static/src/img/snippets_options/bg_shape.svg
@@ -0,0 +1,11 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="14" height="11" viewBox="0 0 14 11">
+ <g fill="none" fill-rule="evenodd" class="symbols">
+ <g fill="#D9D9D9" class="shape" transform="translate(-176 -6)">
+ <g class="group" transform="translate(167)">
+ <g class="bg_shape" transform="translate(9 6)">
+ <path d="M12 0a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h10zm0 2l-1.59 1.68a7 7 0 0 1-4.207 2.134l-.155.02A4.967 4.967 0 0 0 2.224 8.55L2 9h10V2z" class="o_graphic"/>
+ </g>
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/addons/web_editor/static/src/img/snippets_options/o_overlay_move_drag.svg b/addons/web_editor/static/src/img/snippets_options/o_overlay_move_drag.svg
new file mode 100644
index 00000000..7d701fa3
--- /dev/null
+++ b/addons/web_editor/static/src/img/snippets_options/o_overlay_move_drag.svg
@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
+ <g fill="none" fill-rule="evenodd" class="o_overlay_move_drag">
+ <g fill="#FFF" class="group" transform="translate(3.5 6)">
+ <polygon points="0 0 0 3 3 3 3 0" class="o_graphic"/>
+ <polygon points="5 0 5 3 8 3 8 0" class="o_graphic"/>
+ <polygon points="10 0 10 3 13 3 13 0" class="o_graphic"/>
+ <polygon points="0 5 0 8 3 8 3 5" class="o_graphic"/>
+ <polygon points="5 5 5 8 8 8 8 5" class="o_graphic"/>
+ <polygon points="10 5 10 8 13 8 13 5" class="o_graphic"/>
+ </g>
+ </g>
+</svg>
diff --git a/addons/web_editor/static/src/js/backend/convert_inline.js b/addons/web_editor/static/src/js/backend/convert_inline.js
new file mode 100644
index 00000000..071caa3f
--- /dev/null
+++ b/addons/web_editor/static/src/js/backend/convert_inline.js
@@ -0,0 +1,485 @@
+odoo.define('web_editor.convertInline', function (require) {
+'use strict';
+
+var FieldHtml = require('web_editor.field.html');
+
+/**
+ * Returns the css rules which applies on an element, tweaked so that they are
+ * browser/mail client ok.
+ *
+ * @param {DOMElement} a
+ * @returns {Object} css property name -> css property value
+ */
+function getMatchedCSSRules(a) {
+ var i, r, k;
+ var doc = a.ownerDocument;
+ var rulesCache = a.ownerDocument._rulesCache || (a.ownerDocument._rulesCache = []);
+
+ if (!rulesCache.length) {
+ var sheets = doc.styleSheets;
+ for (i = sheets.length-1 ; i >= 0 ; i--) {
+ var rules;
+ // try...catch because browser may not able to enumerate rules for cross-domain sheets
+ try {
+ rules = sheets[i].rules || sheets[i].cssRules;
+ } catch (e) {
+ console.warn("Can't read the css rules of: " + sheets[i].href, e);
+ continue;
+ }
+ if (rules) {
+ for (r = rules.length-1; r >= 0; r--) {
+ var selectorText = rules[r].selectorText;
+ if (selectorText &&
+ rules[r].cssText &&
+ selectorText !== '*' &&
+ selectorText.indexOf(':hover') === -1 &&
+ selectorText.indexOf(':before') === -1 &&
+ selectorText.indexOf(':after') === -1 &&
+ selectorText.indexOf(':active') === -1 &&
+ selectorText.indexOf(':link') === -1 &&
+ selectorText.indexOf('::') === -1 &&
+ selectorText.indexOf("'") === -1) {
+ var st = selectorText.split(/\s*,\s*/);
+ for (k = 0 ; k < st.length ; k++) {
+ rulesCache.push({ 'selector': st[k], 'style': rules[r].style });
+ }
+ }
+ }
+ }
+ }
+ rulesCache.reverse();
+ }
+
+ var css = [];
+ var style;
+ a.matches = a.matches || a.webkitMatchesSelector || a.mozMatchesSelector || a.msMatchesSelector || a.oMatchesSelector;
+ for (r = 0; r < rulesCache.length; r++) {
+ if (a.matches(rulesCache[r].selector)) {
+ style = rulesCache[r].style;
+ if (style.parentRule) {
+ var style_obj = {};
+ var len;
+ for (k = 0, len = style.length ; k < len ; k++) {
+ if (style[k].indexOf('animation') !== -1) {
+ continue;
+ }
+ style_obj[style[k]] = style[style[k].replace(/-(.)/g, function (a, b) { return b.toUpperCase(); })];
+ if (new RegExp(style[k] + '\s*:[^:;]+!important' ).test(style.cssText)) {
+ style_obj[style[k]] += ' !important';
+ }
+ }
+ rulesCache[r].style = style = style_obj;
+ }
+ css.push([rulesCache[r].selector, style]);
+ }
+ }
+
+ function specificity(selector) {
+ // http://www.w3.org/TR/css3-selectors/#specificity
+ var a = 0;
+ selector = selector.replace(/#[a-z0-9_-]+/gi, function () { a++; return ''; });
+ var b = 0;
+ selector = selector.replace(/(\.[a-z0-9_-]+)|(\[.*?\])/gi, function () { b++; return ''; });
+ var c = 0;
+ selector = selector.replace(/(^|\s+|:+)[a-z0-9_-]+/gi, function (a) { if (a.indexOf(':not(')===-1) c++; return ''; });
+ return a*100 + b*10 + c;
+ }
+ css.sort(function (a, b) { return specificity(a[0]) - specificity(b[0]); });
+
+ style = {};
+ _.each(css, function (v,k) {
+ _.each(v[1], function (v,k) {
+ if (v && _.isString(v) && k.indexOf('-webkit') === -1 && (!style[k] || style[k].indexOf('important') === -1 || v.indexOf('important') !== -1)) {
+ style[k] = v;
+ }
+ });
+ });
+
+ _.each(style, function (v,k) {
+ if (v.indexOf('important') !== -1) {
+ style[k] = v.slice(0, v.length-11);
+ }
+ });
+
+ if (style.display === 'block') {
+ delete style.display;
+ }
+
+ // The css generates all the attributes separately and not in simplified form.
+ // In order to have a better compatibility (outlook for example) we simplify the css tags.
+ // e.g. border-left-style: none; border-bottom-s .... will be simplified in border-style = none
+ _.each([
+ {property: 'margin'},
+ {property: 'padding'},
+ {property: 'border', propertyEnd: '-style', defaultValue: 'none'},
+ ], function (propertyInfo) {
+ var p = propertyInfo.property;
+ var e = propertyInfo.propertyEnd || '';
+ var defVal = propertyInfo.defaultValue || 0;
+
+ if (style[p+'-top'+e] || style[p+'-right'+e] || style[p+'-bottom'+e] || style[p+'-left'+e]) {
+ if (style[p+'-top'+e] === style[p+'-right'+e] && style[p+'-top'+e] === style[p+'-bottom'+e] && style[p+'-top'+e] === style[p+'-left'+e]) {
+ // keep => property: [top/right/bottom/left value];
+ style[p+e] = style[p+'-top'+e];
+ }
+ else {
+ // keep => property: [top value] [right value] [bottom value] [left value];
+ style[p+e] = (style[p+'-top'+e] || defVal) + ' ' + (style[p+'-right'+e] || defVal) + ' ' + (style[p+'-bottom'+e] || defVal) + ' ' + (style[p+'-left'+e] || defVal);
+ if (style[p+e].indexOf('inherit') !== -1 || style[p+e].indexOf('initial') !== -1) {
+ // keep => property-top: [top value]; property-right: [right value]; property-bottom: [bottom value]; property-left: [left value];
+ delete style[p+e];
+ return;
+ }
+ }
+ delete style[p+'-top'+e];
+ delete style[p+'-right'+e];
+ delete style[p+'-bottom'+e];
+ delete style[p+'-left'+e];
+ }
+ });
+
+ if (style['border-bottom-left-radius']) {
+ style['border-radius'] = style['border-bottom-left-radius'];
+ delete style['border-bottom-left-radius'];
+ delete style['border-bottom-right-radius'];
+ delete style['border-top-left-radius'];
+ delete style['border-top-right-radius'];
+ }
+
+ // if the border styling is initial we remove it to simplify the css tags for compatibility.
+ // Also, since we do not send a css style tag, the initial value of the border is useless.
+ _.each(_.keys(style), function (k) {
+ if (k.indexOf('border') !== -1 && style[k] === 'initial') {
+ delete style[k];
+ }
+ });
+
+ // text-decoration rule is decomposed in -line, -color and -style. This is
+ // however not supported by many browser/mail clients and the editor does
+ // not allow to change -color and -style rule anyway
+ if (style['text-decoration-line']) {
+ style['text-decoration'] = style['text-decoration-line'];
+ delete style['text-decoration-line'];
+ delete style['text-decoration-color'];
+ delete style['text-decoration-style'];
+ delete style['text-decoration-thickness'];
+ }
+
+ // text-align inheritance does not seem to get past <td> elements on some
+ // mail clients
+ if (style['text-align'] === 'inherit') {
+ var $el = $(a).parent();
+ do {
+ var align = $el.css('text-align');
+ if (_.indexOf(['left', 'right', 'center', 'justify'], align) >= 0) {
+ style['text-align'] = align;
+ break;
+ }
+ $el = $el.parent();
+ } while ($el.length && !$el.is('html'));
+ }
+
+ return style;
+}
+
+/**
+ * Converts font icons to images.
+ *
+ * @param {jQuery} $editable - the element in which the font icons have to be
+ * converted to images
+ */
+function fontToImg($editable) {
+ var fonts = odoo.__DEBUG__.services["wysiwyg.fonts"];
+
+ $editable.find('.fa').each(function () {
+ var $font = $(this);
+ var icon, content;
+ _.find(fonts.fontIcons, function (font) {
+ return _.find(fonts.getCssSelectors(font.parser), function (data) {
+ if ($font.is(data.selector.replace(/::?before/g, ''))) {
+ icon = data.names[0].split('-').shift();
+ content = data.css.match(/content:\s*['"]?(.)['"]?/)[1];
+ return true;
+ }
+ });
+ });
+ if (content) {
+ var color = $font.css('color').replace(/\s/g, '');
+ $font.replaceWith($('<img/>', {
+ src: _.str.sprintf('/web_editor/font_to_img/%s/%s/%s', content.charCodeAt(0), window.encodeURI(color), Math.max(1, Math.round($font.height()))),
+ 'data-class': $font.attr('class'),
+ 'data-style': $font.attr('style'),
+ class: $font.attr('class').replace(new RegExp('(^|\\s+)' + icon + '(-[^\\s]+)?', 'gi'), ''), // remove inline font-awsome style
+ style: $font.attr('style'),
+ }).css({height: 'auto', width: 'auto'}));
+ } else {
+ $font.remove();
+ }
+ });
+}
+
+/**
+ * Converts images which were the result of a font icon convertion to a font
+ * icon again.
+ *
+ * @param {jQuery} $editable - the element in which the images will be converted
+ * back to font icons
+ */
+function imgToFont($editable) {
+ $editable.find('img[src*="/web_editor/font_to_img/"]').each(function () {
+ var $img = $(this);
+ $img.replaceWith($('<span/>', {
+ class: $img.data('class'),
+ style: $img.data('style')
+ }));
+ });
+}
+
+/*
+ * Utility function to apply function over descendants elements
+ *
+ * This is needed until the following issue of jQuery is solved:
+ * https://github.com./jquery/sizzle/issues/403
+ *
+ * @param {Element} node The root Element node
+ * @param {Function} func The function applied over descendants
+ */
+function applyOverDescendants(node, func) {
+ node = node.firstChild;
+ while (node) {
+ if (node.nodeType === 1) {
+ func(node);
+ applyOverDescendants(node, func);
+ }
+ var $node = $(node);
+ if (node.nodeName === 'A' && $node.hasClass('btn') && !$node.children().length && $(node).parents('.o_outlook_hack').length) {
+ node = $(node).parents('.o_outlook_hack')[0];
+ }
+ else if (node.nodeName === 'IMG' && $node.parent('p').hasClass('o_outlook_hack')) {
+ node = $node.parent()[0];
+ }
+ node = node.nextSibling;
+ }
+}
+
+/**
+ * Converts css style to inline style (leave the classes on elements but forces
+ * the style they give as inline style).
+ *
+ * @param {jQuery} $editable
+ */
+function classToStyle($editable) {
+ applyOverDescendants($editable[0], function (node) {
+ var $target = $(node);
+ var css = getMatchedCSSRules(node);
+ var style = $target.attr('style') || '';
+ _.each(css, function (v,k) {
+ if (!(new RegExp('(^|;)\s*' + k).test(style))) {
+ style = k+':'+v+';'+style;
+ }
+ });
+ if (_.isEmpty(style)) {
+ $target.removeAttr('style');
+ } else {
+ $target.attr('style', style);
+ }
+ // Apple Mail
+ if (node.nodeName === 'TD' && !node.childNodes.length) {
+ node.innerHTML = '&nbsp;';
+ }
+
+ // Outlook
+ if (node.nodeName === 'A' && $target.hasClass('btn') && !$target.hasClass('btn-link') && !$target.children().length) {
+ var $hack = $('<table class="o_outlook_hack" style="display: inline-table;vertical-align:middle"><tr><td></td></tr></table>');
+ $hack.find('td')
+ .attr('height', $target.outerHeight())
+ .css({
+ 'text-align': $target.parent().css('text-align'),
+ 'margin': $target.css('padding'),
+ 'border-radius': $target.css('border-radius'),
+ 'background-color': $target.css('background-color'),
+ });
+ $target.after($hack);
+ $target.appendTo($hack.find('td'));
+ // the space add a line when it's a table but it's invisible when it's a link
+ node = $hack[0].previousSibling;
+ if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) {
+ $(node).remove();
+ }
+ node = $hack[0].nextSibling;
+ if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) {
+ $(node).remove();
+ }
+ }
+ else if (node.nodeName === 'IMG' && $target.is('.mx-auto.d-block')) {
+ $target.wrap('<p class="o_outlook_hack" style="text-align:center;margin:0"/>');
+ }
+ });
+}
+
+/**
+ * Removes the inline style which is not necessary (because, for example, a
+ * class on an element will induce the same style).
+ *
+ * @param {jQuery} $editable
+ */
+function styleToClass($editable) {
+ // Outlook revert
+ $editable.find('.o_outlook_hack').each(function () {
+ $(this).after($('a,img', this));
+ }).remove();
+
+ var $c = $('<span/>').appendTo($editable[0].ownerDocument.body);
+
+ applyOverDescendants($editable[0], function (node) {
+ var $target = $(node);
+ var css = getMatchedCSSRules(node);
+ var style = '';
+ _.each(css, function (v,k) {
+ if (!(new RegExp('(^|;)\s*' + k).test(style))) {
+ style = k+':'+v+';'+style;
+ }
+ });
+ css = ($c.attr('style', style).attr('style') || '').split(/\s*;\s*/);
+ style = ($target.attr('style') || '').replace(/\s*:\s*/, ':').replace(/\s*;\s*/, ';');
+ _.each(css, function (v) {
+ style = style.replace(v, '');
+ });
+ style = style.replace(/;+(\s;)*/g, ';').replace(/^;/g, '');
+ if (style !== '') {
+ $target.attr('style', style);
+ } else {
+ $target.removeAttr('style');
+ }
+ });
+ $c.remove();
+}
+
+/**
+ * Converts css display for attachment link to real image.
+ * Without this post process, the display depends on the css and the picture
+ * does not appear when we use the html without css (to send by email for e.g.)
+ *
+ * @param {jQuery} $editable
+ */
+function attachmentThumbnailToLinkImg($editable) {
+ $editable.find('a[href*="/web/content/"][data-mimetype]').filter(':empty, :containsExact( )').each(function () {
+ var $link = $(this);
+ var $img = $('<img/>')
+ .attr('src', $link.css('background-image').replace(/(^url\(['"])|(['"]\)$)/g, ''))
+ .css('height', Math.max(1, $link.height()) + 'px')
+ .css('width', Math.max(1, $link.width()) + 'px');
+ $link.prepend($img);
+ });
+}
+
+/**
+ * Revert attachmentThumbnailToLinkImg changes
+ *
+ * @see attachmentThumbnailToLinkImg
+ * @param {jQuery} $editable
+ */
+function linkImgToAttachmentThumbnail($editable) {
+ $editable.find('a[href*="/web/content/"][data-mimetype] > img').remove();
+}
+
+
+//--------------------------------------------------------------------------
+//--------------------------------------------------------------------------
+
+
+FieldHtml.include({
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ commitChanges: function () {
+ if (this.nodeOptions['style-inline'] && this.mode === "edit") {
+ this._toInline();
+ }
+ return this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Converts CSS dependencies to CSS-independent HTML.
+ * - CSS display for attachment link -> real image
+ * - Font icons -> images
+ * - CSS styles -> inline styles
+ *
+ * @private
+ */
+ _toInline: function () {
+ var $editable = this.wysiwyg.getEditable();
+ var html = this.wysiwyg.getValue({'style-inline': true});
+ $editable.html(html);
+
+ attachmentThumbnailToLinkImg($editable);
+ fontToImg($editable);
+ classToStyle($editable);
+
+ // fix outlook image rendering bug
+ _.each(['width', 'height'], function(attribute) {
+ $editable.find('img[style*="width"], img[style*="height"]').attr(attribute, function(){
+ return $(this)[attribute]();
+ }).css(attribute, function(){
+ return $(this).get(0).style[attribute] || 'auto';
+ });
+ });
+
+ this.wysiwyg.setValue($editable.html(), {
+ notifyChange: false,
+ });
+ },
+ /**
+ * Revert _toInline changes.
+ *
+ * @private
+ */
+ _fromInline: function () {
+ var $editable = this.wysiwyg.getEditable();
+ var html = this.wysiwyg.getValue();
+ $editable.html(html);
+
+ styleToClass($editable);
+ imgToFont($editable);
+ linkImgToAttachmentThumbnail($editable);
+
+ // fix outlook image rendering bug
+ $editable.find('img[style*="width"], img[style*="height"]').removeAttr('height width');
+
+ this.wysiwyg.setValue($editable.html(), {
+ notifyChange: false,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onLoadWysiwyg: function () {
+ if (this.nodeOptions['style-inline'] && this.mode === "edit") {
+ this._fromInline();
+ }
+ this._super();
+ },
+});
+
+return {
+ fontToImg: fontToImg,
+ imgToFont: imgToFont,
+ classToStyle: classToStyle,
+ styleToClass: styleToClass,
+ attachmentThumbnailToLinkImg: attachmentThumbnailToLinkImg,
+ linkImgToAttachmentThumbnail: linkImgToAttachmentThumbnail,
+};
+}); \ No newline at end of file
diff --git a/addons/web_editor/static/src/js/backend/field_html.js b/addons/web_editor/static/src/js/backend/field_html.js
new file mode 100644
index 00000000..37f2a8ba
--- /dev/null
+++ b/addons/web_editor/static/src/js/backend/field_html.js
@@ -0,0 +1,536 @@
+odoo.define('web_editor.field.html', function (require) {
+'use strict';
+
+var ajax = require('web.ajax');
+var basic_fields = require('web.basic_fields');
+var config = require('web.config');
+var core = require('web.core');
+var Wysiwyg = require('web_editor.wysiwyg.root');
+var field_registry = require('web.field_registry');
+// must wait for web/ to add the default html widget, otherwise it would override the web_editor one
+require('web._field_registry');
+
+var _lt = core._lt;
+var TranslatableFieldMixin = basic_fields.TranslatableFieldMixin;
+var QWeb = core.qweb;
+var assetsLoaded;
+
+var jinjaRegex = /(^|\n)\s*%\s(end|set\s)/;
+
+/**
+ * FieldHtml Widget
+ * Intended to display HTML content. This widget uses the wysiwyg editor
+ * improved by odoo.
+ *
+ * nodeOptions:
+ * - style-inline => convert class to inline style (no re-edition) => for sending by email
+ * - no-attachment
+ * - cssEdit
+ * - cssReadonly
+ * - snippets
+ * - wrapper
+ */
+var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, {
+ description: _lt("Html"),
+ className: 'oe_form_field oe_form_field_html',
+ supportedFieldTypes: ['html'],
+
+ custom_events: {
+ wysiwyg_focus: '_onWysiwygFocus',
+ wysiwyg_blur: '_onWysiwygBlur',
+ wysiwyg_change: '_onChange',
+ wysiwyg_attachment: '_onAttachmentChange',
+ },
+
+ /**
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ this.isRendered = false;
+ this._onUpdateIframeId = 'onLoad_' + _.uniqueId('FieldHtml');
+ var defAsset;
+ if (this.nodeOptions.cssReadonly) {
+ defAsset = ajax.loadAsset(this.nodeOptions.cssReadonly);
+ }
+
+ if (!assetsLoaded) { // avoid flickering when begin to edit
+ assetsLoaded = new Promise(function (resolve) {
+ var wysiwyg = new Wysiwyg(self, {});
+ wysiwyg.attachTo($('<textarea>')).then(function () {
+ wysiwyg.destroy();
+ resolve();
+ });
+ });
+ }
+
+ return Promise.all([this._super(), assetsLoaded, defAsset]);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ delete window.top[this._onUpdateIframeId];
+ if (this.$iframe) {
+ this.$iframe.remove();
+ }
+ this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ activate: function (options) {
+ if (this.wysiwyg) {
+ this.wysiwyg.focus();
+ return true;
+ }
+ },
+ /**
+ * Wysiwyg doesn't notify for changes done in code mode. We override
+ * commitChanges to manually switch back to normal mode before committing
+ * changes, so that the widget is aware of the changes done in code mode.
+ *
+ * @override
+ */
+ commitChanges: function () {
+ var self = this;
+ if (config.isDebug() && this.mode === 'edit') {
+ var layoutInfo = $.summernote.core.dom.makeLayoutInfo(this.wysiwyg.$editor);
+ $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false);
+ }
+ if (this.mode == "readonly" || !this.isRendered) {
+ return this._super();
+ }
+ var _super = this._super.bind(this);
+ return this.wysiwyg.saveModifiedImages(this.$content).then(function () {
+ return self.wysiwyg.save(self.nodeOptions).then(function (result) {
+ self._isDirty = result.isDirty;
+ _super();
+ });
+ });
+ },
+ /**
+ * @override
+ */
+ isSet: function () {
+ var value = this.value && this.value.split('&nbsp;').join('').replace(/\s/g, ''); // Removing spaces & html spaces
+ return value && value !== "<p></p>" && value !== "<p><br></p>" && value.match(/\S/);
+ },
+ /**
+ * @override
+ */
+ getFocusableElement: function () {
+ return this.$target || $();
+ },
+ /**
+ * Do not re-render this field if it was the origin of the onchange call.
+ *
+ * @override
+ */
+ reset: function (record, event) {
+ this._reset(record, event);
+ var value = this.value;
+ if (this.nodeOptions.wrapper) {
+ value = this._wrap(value);
+ }
+ value = this._textToHtml(value);
+ if (!event || event.target !== this) {
+ if (this.mode === 'edit') {
+ this.wysiwyg.setValue(value);
+ } else {
+ this.$content.html(value);
+ }
+ }
+ return Promise.resolve();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getValue: function () {
+ var value = this.$target.val();
+ if (this.nodeOptions.wrapper) {
+ return this._unWrap(value);
+ }
+ return value;
+ },
+ /**
+ * Create the wysiwyg instance with the target (this.$target)
+ * then add the editable content (this.$content).
+ *
+ * @private
+ * @returns {$.Promise}
+ */
+ _createWysiwygIntance: function () {
+ var self = this;
+ this.wysiwyg = new Wysiwyg(this, this._getWysiwygOptions());
+ this.wysiwyg.__extraAssetsForIframe = this.__extraAssetsForIframe || [];
+
+ // by default this is synchronous because the assets are already loaded in willStart
+ // but it can be async in the case of options such as iframe, snippets...
+ return this.wysiwyg.attachTo(this.$target).then(function () {
+ self.$content = self.wysiwyg.$editor.closest('body, odoo-wysiwyg-container');
+ self._onLoadWysiwyg();
+ self.isRendered = true;
+ });
+ },
+ /**
+ * Get wysiwyg options to create wysiwyg instance.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getWysiwygOptions: function () {
+ var self = this;
+ return Object.assign({}, this.nodeOptions, {
+ recordInfo: {
+ context: this.record.getContext(this.recordParams),
+ res_model: this.model,
+ res_id: this.res_id,
+ },
+ noAttachment: this.nodeOptions['no-attachment'],
+ inIframe: !!this.nodeOptions.cssEdit,
+ iframeCssAssets: this.nodeOptions.cssEdit,
+ snippets: this.nodeOptions.snippets,
+
+ tabsize: 0,
+ height: 180,
+ generateOptions: function (options) {
+ var toolbar = options.toolbar || options.airPopover || {};
+ var para = _.find(toolbar, function (item) {
+ return item[0] === 'para';
+ });
+ if (para && para[1] && para[1].indexOf('checklist') === -1) {
+ para[1].splice(2, 0, 'checklist');
+ }
+ if (config.isDebug()) {
+ options.codeview = true;
+ var view = _.find(toolbar, function (item) {
+ return item[0] === 'view';
+ });
+ if (view) {
+ if (!view[1].includes('codeview')) {
+ view[1].splice(-1, 0, 'codeview');
+ }
+ } else {
+ toolbar.splice(-1, 0, ['view', ['codeview']]);
+ }
+ }
+ if (self.model === "mail.compose.message" || self.model === "mailing.mailing") {
+ options.noVideos = true;
+ }
+ options.prettifyHtml = false;
+ return options;
+ },
+ });
+ },
+ /**
+ * trigger_up 'field_changed' add record into the "ir.attachment" field found in the view.
+ * This method is called when an image is uploaded via the media dialog.
+ *
+ * For e.g. when sending email, this allows people to add attachments with the content
+ * editor interface and that they appear in the attachment list.
+ * The new documents being attached to the email, they will not be erased by the CRON
+ * when closing the wizard.
+ *
+ * @private
+ * @param {Object} attachments
+ */
+ _onAttachmentChange: function (attachments) {
+ if (!this.fieldNameAttachment) {
+ return;
+ }
+ this.trigger_up('field_changed', {
+ dataPointID: this.dataPointID,
+ changes: _.object([this.fieldNameAttachment], [{
+ operation: 'ADD_M2M',
+ ids: attachments
+ }])
+ });
+ },
+ /**
+ * @override
+ */
+ _renderEdit: function () {
+ var value = this._textToHtml(this.value);
+ if (this.nodeOptions.wrapper) {
+ value = this._wrap(value);
+ }
+ this.$target = $('<textarea>').val(value).hide();
+ this.$target.appendTo(this.$el);
+
+ var fieldNameAttachment = _.chain(this.recordData)
+ .pairs()
+ .find(function (value) {
+ return _.isObject(value[1]) && value[1].model === "ir.attachment";
+ })
+ .first()
+ .value();
+ if (fieldNameAttachment) {
+ this.fieldNameAttachment = fieldNameAttachment;
+ }
+
+ if (this.nodeOptions.cssEdit) {
+ // must be async because the target must be append in the DOM
+ this._createWysiwygIntance();
+ } else {
+ return this._createWysiwygIntance();
+ }
+ },
+ /**
+ * @override
+ */
+ _renderReadonly: function () {
+ var self = this;
+ var value = this._textToHtml(this.value);
+ if (this.nodeOptions.wrapper) {
+ value = this._wrap(value);
+ }
+
+ this.$el.empty();
+ var resolver;
+ var def = new Promise(function (resolve) {
+ resolver = resolve;
+ });
+ if (this.nodeOptions.cssReadonly) {
+ this.$iframe = $('<iframe class="o_readonly"/>');
+ this.$iframe.appendTo(this.$el);
+
+ var avoidDoubleLoad = 0; // this bug only appears on some computers with some chrome version.
+
+ // inject content in iframe
+
+ this.$iframe.data('loadDef', def); // for unit test
+ window.top[this._onUpdateIframeId] = function (_avoidDoubleLoad) {
+ if (_avoidDoubleLoad !== avoidDoubleLoad) {
+ console.warn('Wysiwyg iframe double load detected');
+ return;
+ }
+ self.$content = $('#iframe_target', self.$iframe[0].contentWindow.document.body);
+ resolver();
+ };
+
+ this.$iframe.on('load', function onLoad() {
+ var _avoidDoubleLoad = ++avoidDoubleLoad;
+ ajax.loadAsset(self.nodeOptions.cssReadonly).then(function (asset) {
+ if (_avoidDoubleLoad !== avoidDoubleLoad) {
+ console.warn('Wysiwyg immediate iframe double load detected');
+ return;
+ }
+ var cwindow = self.$iframe[0].contentWindow;
+ cwindow.document
+ .open("text/html", "replace")
+ .write(
+ '<head>' +
+ '<meta charset="utf-8"/>' +
+ '<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>\n' +
+ '<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>\n' +
+ _.map(asset.cssLibs, function (cssLib) {
+ return '<link type="text/css" rel="stylesheet" href="' + cssLib + '"/>';
+ }).join('\n') + '\n' +
+ _.map(asset.cssContents, function (cssContent) {
+ return '<style type="text/css">' + cssContent + '</style>';
+ }).join('\n') + '\n' +
+ '</head>\n' +
+ '<body class="o_in_iframe o_readonly">\n' +
+ '<div id="iframe_target">' + value + '</div>\n' +
+ '<script type="text/javascript">' +
+ 'if (window.top.' + self._onUpdateIframeId + ') {' +
+ 'window.top.' + self._onUpdateIframeId + '(' + _avoidDoubleLoad + ')' +
+ '}' +
+ '</script>\n' +
+ '</body>');
+
+ var height = cwindow.document.body.scrollHeight;
+ self.$iframe.css('height', Math.max(30, Math.min(height, 500)) + 'px');
+ });
+ });
+ } else {
+ this.$content = $('<div class="o_readonly"/>').html(value);
+ this.$content.appendTo(this.$el);
+ resolver();
+ }
+
+ def.then(function () {
+ self.$content.on('click', 'ul.o_checklist > li', self._onReadonlyClickChecklist.bind(self));
+ });
+ },
+ /**
+ * @private
+ * @param {string} text
+ * @returns {string} the text converted to html
+ */
+ _textToHtml: function (text) {
+ var value = text || "";
+ if (jinjaRegex.test(value)) { // is jinja
+ return value;
+ }
+ try {
+ $(text)[0].innerHTML; // crashes if text isn't html
+ } catch (e) {
+ if (value.match(/^\s*$/)) {
+ value = '<p><br/></p>';
+ } else {
+ value = "<p>" + value.split(/<br\/?>/).join("<br/></p><p>") + "</p>";
+ value = value
+ .replace(/<p><\/p>/g, '')
+ .replace('<p><p>', '<p>')
+ .replace('<p><p ', '<p ')
+ .replace('</p></p>', '</p>');
+ }
+ }
+ return value;
+ },
+ /**
+ * Move HTML contents out of their wrapper.
+ *
+ * @private
+ * @param {string} html content
+ * @returns {string} html content
+ */
+ _unWrap: function (html) {
+ var $wrapper = $(html).find('#wrapper');
+ return $wrapper.length ? $wrapper.html() : html;
+ },
+ /**
+ * Wrap HTML in order to create a custom display.
+ *
+ * The wrapper (this.nodeOptions.wrapper) must be a static
+ * XML template with content id="wrapper".
+ *
+ * @private
+ * @param {string} html content
+ * @returns {string} html content
+ */
+ _wrap: function (html) {
+ return $(QWeb.render(this.nodeOptions.wrapper))
+ .find('#wrapper').html(html)
+ .end().prop('outerHTML');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * Method called when wysiwyg triggers a change.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onChange: function (ev) {
+ this._doDebouncedAction.apply(this, arguments);
+
+ var $lis = this.$content.find('.note-editable ul.o_checklist > li:not(:has(> ul.o_checklist))');
+ if (!$lis.length) {
+ return;
+ }
+ var max = 0;
+ var ids = [];
+ $lis.map(function () {
+ var checklistId = parseInt(($(this).attr('id') || '0').replace(/^checklist-id-/, ''));
+ if (ids.indexOf(checklistId) === -1) {
+ if (checklistId > max) {
+ max = checklistId;
+ }
+ ids.push(checklistId);
+ } else {
+ $(this).removeAttr('id');
+ }
+ });
+ $lis.not('[id]').each(function () {
+ $(this).attr('id', 'checklist-id-' + (++max));
+ });
+ },
+ /**
+ * Allows Enter keypress in a textarea (source mode)
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onKeydown: function (ev) {
+ if (ev.which === $.ui.keyCode.ENTER) {
+ ev.stopPropagation();
+ return;
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * Method called when wysiwyg triggers a change.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onReadonlyClickChecklist: function (ev) {
+ var self = this;
+ if (ev.offsetX > 0) {
+ return;
+ }
+ ev.stopPropagation();
+ ev.preventDefault();
+ var checked = $(ev.target).hasClass('o_checked');
+ var checklistId = parseInt(($(ev.target).attr('id') || '0').replace(/^checklist-id-/, ''));
+
+ this._rpc({
+ route: '/web_editor/checklist',
+ params: {
+ res_model: this.model,
+ res_id: this.res_id,
+ filename: this.name,
+ checklistId: checklistId,
+ checked: !checked,
+ },
+ }).then(function (value) {
+ self._setValue(value);
+ });
+ },
+ /**
+ * Method called when the wysiwyg instance is loaded.
+ *
+ * @private
+ */
+ _onLoadWysiwyg: function () {
+ var $button = this._renderTranslateButton();
+ $button.css({
+ 'font-size': '15px',
+ position: 'absolute',
+ right: '+5px',
+ top: '+5px',
+ });
+ this.$el.append($button);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onWysiwygBlur: function (ev) {
+ ev.stopPropagation();
+ this._doAction();
+ if (ev.data.key === 'TAB') {
+ this.trigger_up('navigation_move', {
+ direction: ev.data.shiftKey ? 'left' : 'right',
+ });
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onWysiwygFocus: function (ev) {},
+});
+
+
+field_registry.add('html', FieldHtml);
+
+
+return FieldHtml;
+});
diff --git a/addons/web_editor/static/src/js/base.js b/addons/web_editor/static/src/js/base.js
new file mode 100644
index 00000000..7d807114
--- /dev/null
+++ b/addons/web_editor/static/src/js/base.js
@@ -0,0 +1,173 @@
+odoo.define('web_editor.base', function (require) {
+'use strict';
+
+// TODO this should be re-removed as soon as possible.
+
+var ajax = require('web.ajax');
+var session = require('web.session');
+
+var domReady = new Promise(function(resolve) {
+ $(resolve);
+});
+
+return {
+ /**
+ * Retrieves all the CSS rules which match the given parser (Regex).
+ *
+ * @param {Regex} filter
+ * @returns {Object[]} Array of CSS rules descriptions (objects). A rule is
+ * defined by 3 values: 'selector', 'css' and 'names'. 'selector'
+ * is a string which contains the whole selector, 'css' is a string
+ * which contains the css properties and 'names' is an array of the
+ * first captured groups for each selector part. E.g.: if the
+ * filter is set to match .fa-* rules and capture the icon names,
+ * the rule:
+ * '.fa-alias1::before, .fa-alias2::before { hello: world; }'
+ * will be retrieved as
+ * {
+ * selector: '.fa-alias1::before, .fa-alias2::before',
+ * css: 'hello: world;',
+ * names: ['.fa-alias1', '.fa-alias2'],
+ * }
+ */
+ cacheCssSelectors: {},
+ getCssSelectors: function (filter) {
+ if (this.cacheCssSelectors[filter]) {
+ return this.cacheCssSelectors[filter];
+ }
+ this.cacheCssSelectors[filter] = [];
+ var sheets = document.styleSheets;
+ for (var i = 0; i < sheets.length; i++) {
+ var rules;
+ try {
+ // try...catch because Firefox not able to enumerate
+ // document.styleSheets[].cssRules[] for cross-domain
+ // stylesheets.
+ rules = sheets[i].rules || sheets[i].cssRules;
+ } catch (e) {
+ console.warn("Can't read the css rules of: " + sheets[i].href, e);
+ continue;
+ }
+ if (!rules) {
+ continue;
+ }
+
+ for (var r = 0 ; r < rules.length ; r++) {
+ var selectorText = rules[r].selectorText;
+ if (!selectorText) {
+ continue;
+ }
+ var selectors = selectorText.split(/\s*,\s*/);
+ var data = null;
+ for (var s = 0; s < selectors.length; s++) {
+ var match = selectors[s].trim().match(filter);
+ if (!match) {
+ continue;
+ }
+ if (!data) {
+ data = {
+ selector: match[0],
+ css: rules[r].cssText.replace(/(^.*\{\s*)|(\s*\}\s*$)/g, ''),
+ names: [match[1]]
+ };
+ } else {
+ data.selector += (', ' + match[0]);
+ data.names.push(match[1]);
+ }
+ }
+ if (data) {
+ this.cacheCssSelectors[filter].push(data);
+ }
+ }
+ }
+ return this.cacheCssSelectors[filter];
+ },
+ /**
+ * List of font icons to load by editor. The icons are displayed in the media
+ * editor and identified like font and image (can be colored, spinned, resized
+ * with fa classes).
+ * To add font, push a new object {base, parser}
+ *
+ * - base: class who appear on all fonts
+ * - parser: regular expression used to select all font in css stylesheets
+ *
+ * @type Array
+ */
+ fontIcons: [{base: 'fa', parser: /\.(fa-(?:\w|-)+)::?before/i}],
+ /**
+ * Searches the fonts described by the @see fontIcons variable.
+ */
+ computeFonts: _.once(function () {
+ var self = this;
+ _.each(this.fontIcons, function (data) {
+ data.cssData = self.getCssSelectors(data.parser);
+ data.alias = _.flatten(_.map(data.cssData, _.property('names')));
+ });
+ }),
+ /**
+ * If a widget needs to be instantiated on page loading, it needs to wait
+ * for appropriate resources to be loaded. This function returns a Promise
+ * which is resolved when the dom is ready, the session is bound
+ * (translations loaded) and the XML is loaded. This should however not be
+ * necessary anymore as widgets should not be parentless and should then be
+ * instantiated (directly or not) by the page main component (webclient,
+ * website root, editor bar, ...). The DOM will be ready then, the main
+ * component is in charge of waiting for the session and the XML can be
+ * lazy loaded thanks to the @see Widget.xmlDependencies key.
+ *
+ * @returns {Promise}
+ */
+ ready: function () {
+ return Promise.all([domReady, session.is_bound, ajax.loadXML()]);
+ },
+};
+});
+
+//==============================================================================
+
+odoo.define('web_editor.context', function (require) {
+'use strict';
+
+// TODO this should be re-removed as soon as possible.
+
+function getContext(context) {
+ var html = document.documentElement;
+ return _.extend({
+ lang: (html.getAttribute('lang') || 'en_US').replace('-', '_'),
+
+ // Unfortunately this is a mention of 'website' in 'web_editor' as there
+ // was no other way to do it as this was restored in a stable version.
+ // Indeed, the editor is currently using this context at the root of JS
+ // module, so there is no way for website to hook itself before
+ // web_editor uses it (without a risky refactoring of web_editor in
+ // stable). As mentioned above, the editor should not use this context
+ // anymore anyway (this was restored by the saas-12.2 editor revert).
+ 'website_id': html.getAttribute('data-website-id') | 0,
+ }, context || {});
+}
+function getExtraContext(context) {
+ var html = document.documentElement;
+ return _.extend(getContext(), {
+ editable: !!(html.dataset.editable || $('[data-oe-model]').length), // temporary hack, this should be done in python
+ translatable: !!html.dataset.translatable,
+ edit_translations: !!html.dataset.edit_translations,
+ }, context || {});
+}
+
+return {
+ get: getContext,
+ getExtra: getExtraContext,
+};
+});
+
+//==============================================================================
+
+odoo.define('web_editor.ready', function (require) {
+'use strict';
+
+// TODO this should be re-removed as soon as possible.
+
+var base = require('web_editor.base');
+
+return base.ready();
+});
diff --git a/addons/web_editor/static/src/js/common/ace.js b/addons/web_editor/static/src/js/common/ace.js
new file mode 100644
index 00000000..0c3198de
--- /dev/null
+++ b/addons/web_editor/static/src/js/common/ace.js
@@ -0,0 +1,944 @@
+odoo.define('web_editor.ace', function (require) {
+'use strict';
+
+var ajax = require('web.ajax');
+var config = require('web.config');
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var dom = require('web.dom');
+var Dialog = require('web.Dialog');
+var Widget = require('web.Widget');
+var localStorage = require('web.local_storage');
+
+var _t = core._t;
+
+/**
+ * Formats a content-check result (@see checkXML, checkSCSS).
+ *
+ * @param {boolean} isValid
+ * @param {integer} [errorLine] needed if isValid is false
+ * @param {string} [errorMessage] needed if isValid is false
+ * @returns {Object}
+ */
+function _getCheckReturn(isValid, errorLine, errorMessage) {
+ return {
+ isValid: isValid,
+ error: isValid ? null : {
+ line: errorLine,
+ message: errorMessage,
+ },
+ };
+}
+/**
+ * Checks the syntax validity of some XML.
+ *
+ * @param {string} xml
+ * @returns {Object} @see _getCheckReturn
+ */
+function checkXML(xml) {
+ if (typeof window.DOMParser != 'undefined') {
+ var xmlDoc = (new window.DOMParser()).parseFromString(xml, 'text/xml');
+ var error = xmlDoc.getElementsByTagName('parsererror');
+ if (error.length > 0) {
+ return _getCheckReturn(false, parseInt(error[0].innerHTML.match(/[Ll]ine[^\d]+(\d+)/)[1], 10), error[0].innerHTML);
+ }
+ } else if (typeof window.ActiveXObject != 'undefined' && new window.ActiveXObject('Microsoft.XMLDOM')) {
+ var xmlDocIE = new window.ActiveXObject('Microsoft.XMLDOM');
+ xmlDocIE.async = 'false';
+ xmlDocIE.loadXML(xml);
+ if (xmlDocIE.parseError.line > 0) {
+ return _getCheckReturn(false, xmlDocIE.parseError.line, xmlDocIE.parseError.reason);
+ }
+ }
+ return _getCheckReturn(true);
+}
+/**
+ * Formats some XML so that it has proper indentation and structure.
+ *
+ * @param {string} xml
+ * @returns {string} formatted xml
+ */
+function formatXML(xml) {
+ // do nothing if an inline script is present to avoid breaking it
+ if (/<script(?: [^>]*)?>[^<][\s\S]*<\/script>/i.test(xml)) {
+ return xml;
+ }
+ return window.vkbeautify.xml(xml, 4);
+}
+/**
+ * Checks the syntax validity of some SCSS.
+ *
+ * @param {string} scss
+ * @returns {Object} @see _getCheckReturn
+ */
+var checkSCSS = (function () {
+ var mapping = {
+ '{': '}', '}': '{',
+ '(': ')', ')': '(',
+ '[': ']', ']': '[',
+ };
+ var openings = ['{', '(', '['];
+ var closings = ['}', ')', ']'];
+
+ return function (scss) {
+ var stack = [];
+ var line = 1;
+ for (var i = 0 ; i < scss.length ; i++) {
+ if (_.contains(openings, scss[i])) {
+ stack.push(scss[i]);
+ } else if (_.contains(closings, scss[i])) {
+ if (stack.pop() !== mapping[scss[i]]) {
+ return _getCheckReturn(false, line, _t("Unexpected ") + scss[i]);
+ }
+ } else if (scss[i] === '\n') {
+ line++;
+ }
+ }
+ if (stack.length > 0) {
+ return _getCheckReturn(false, line, _t("Expected ") + mapping[stack.pop()]);
+ }
+ return _getCheckReturn(true);
+ };
+})();
+/**
+ * Formats some SCSS so that it has proper indentation and structure.
+ *
+ * @todo Right now, this does return the given SCSS content, untouched.
+ * @param {string} scss
+ * @returns {string} formatted scss
+ */
+function formatSCSS(scss) {
+ return scss;
+}
+
+/**
+ * Allows to visualize resources (by default, XML views) and edit them.
+ */
+var ViewEditor = Widget.extend({
+ template: 'web_editor.ace_view_editor',
+ xmlDependencies: ['/web_editor/static/src/xml/ace.xml'],
+ jsLibs: [
+ '/web/static/lib/ace/ace.js',
+ [
+ '/web/static/lib/ace/javascript_highlight_rules.js',
+ '/web/static/lib/ace/mode-xml.js',
+ '/web/static/lib/ace/mode-scss.js',
+ '/web/static/lib/ace/mode-js.js',
+ '/web/static/lib/ace/theme-monokai.js'
+ ]
+ ],
+ events: {
+ 'click .o_ace_type_switcher_choice': '_onTypeChoice',
+ 'change .o_res_list': '_onResChange',
+ 'click .o_ace_filter': '_onFilterChange',
+ 'click button[data-action=save]': '_onSaveClick',
+ 'click button[data-action=reset]': '_onResetClick',
+ 'click button[data-action=format]': '_onFormatClick',
+ 'click button[data-action=close]': '_onCloseClick',
+ 'click #ace-view-id > .alert-warning .close': '_onCloseWarningClick'
+ },
+
+ /**
+ * Initializes the parameters so that the ace editor knows which information
+ * it has to load.
+ *
+ * @constructor
+ * @param {Widget} parent
+ * @param {string|integer} viewKey
+ * xml_id or id of the view whose linked resources have to be loaded.
+ * @param {Object} [options]
+ * @param {string|integer} [options.initialResID]
+ * a specific view ID / SCSS URL to load on start (otherwise the main
+ * view ID associated with the specified viewKey will be used)
+ * @param {string} [options.position=right]
+ * @param {boolean} [options.doNotLoadViews=false]
+ * @param {boolean} [options.doNotLoadSCSS=false]
+ * @param {boolean} [options.doNotLoadJS=false]
+ * @param {boolean} [options.includeBundles=false]
+ * @param {string} [options.filesFilter=custom]
+ * @param {string[]} [options.defaultBundlesRestriction]
+ */
+ init: function (parent, viewKey, options) {
+ this._super.apply(this, arguments);
+
+ this.context = options.context;
+
+ this.viewKey = viewKey;
+ this.options = _.defaults({}, options, {
+ position: 'right',
+ doNotLoadViews: false,
+ doNotLoadSCSS: false,
+ doNotLoadJS: false,
+ includeBundles: false,
+ filesFilter: 'custom',
+ defaultBundlesRestriction: [],
+ });
+
+ this.resources = {xml: {}, scss: {}, js: {}};
+ this.editingSessions = {xml: {}, scss: {}, js: {}};
+ this.currentType = 'xml';
+
+ // Alias
+ this.views = this.resources.xml;
+ this.scss = this.resources.scss;
+ this.js = this.resources.js;
+ },
+ /**
+ * Loads everything the ace library needs to work.
+ * It also loads the resources to visualize (@see _loadResources).
+ *
+ * @override
+ */
+ willStart: function () {
+ return Promise.all([
+ this._super.apply(this, arguments),
+ this._loadResources()
+ ]);
+ },
+ /**
+ * Initializes the library and initial view once the DOM is ready. It also
+ * initializes the resize feature of the ace editor.
+ *
+ * @override
+ */
+ start: function () {
+ this.$viewEditor = this.$('#ace-view-editor');
+
+ this.$typeSwitcherChoices = this.$('.o_ace_type_switcher_choice');
+ this.$typeSwitcherBtn = this.$('.o_ace_type_switcher > .dropdown-toggle');
+
+ this.$lists = {
+ xml: this.$('#ace-view-list'),
+ scss: this.$('#ace-scss-list'),
+ js: this.$('#ace-js-list'),
+ };
+ this.$includeBundlesArea = this.$('.oe_include_bundles');
+ this.$includeAllSCSSArea = this.$('.o_include_all_scss');
+ this.$viewID = this.$('#ace-view-id > span');
+ this.$warningMessage = this.$('#ace-view-id > .alert-warning');
+
+ this.$formatButton = this.$('button[data-action=format]');
+ this.$resetButton = this.$('button[data-action=reset]');
+
+ this.aceEditor = window.ace.edit(this.$viewEditor[0]);
+ this.aceEditor.setTheme('ace/theme/monokai');
+ this.$editor = this.$('.ace_editor');
+
+ var refX = 0;
+ var resizing = false;
+ var minWidth = 400;
+ var debounceStoreEditorWidth = _.debounce(storeEditorWidth, 500);
+
+ this._updateViewSelectDOM();
+
+ var initResID;
+ var initType;
+ if (this.options.initialResID) {
+ initResID = this.options.initialResID;
+ if (_.isString(initResID) && initResID[0] === '/') {
+ if (_.str.endsWith(initResID, '.scss')) {
+ initType = 'scss';
+ } else {
+ initType = 'js';
+ }
+ } else {
+ initType = 'xml';
+ }
+ } else {
+ if (!this.options.doNotLoadSCSS) {
+ initResID = this.sortedSCSS[0][1][0].url; // first bundle, scss files, first one
+ initType = 'scss';
+ }
+ if (!this.options.doNotLoadJS) {
+ initResID = this.sortedJS[0][1][0].url; // first bundle, js files, first one
+ initType = 'js';
+ }
+ if (!this.options.doNotLoadViews) {
+ if (typeof this.viewKey === "number") {
+ initResID = this.viewKey;
+ } else {
+ var view = _.findWhere(this.views, {xml_id: this.viewKey});
+ if (!view) {
+ view = _.findWhere(this.views, {key: this.viewKey});
+ }
+ initResID = view.id;
+ }
+ initType = 'xml';
+ }
+ }
+ if (initResID) {
+ this._displayResource(initResID, initType);
+ }
+
+ if (!this.sortedViews.length || !this.sortedSCSS.length) {
+ _.defer((function () {
+ this._switchType(this.sortedViews.length ? 'xml' : 'scss');
+ this.$typeSwitcherBtn.parent('.btn-group').addClass('d-none');
+ }).bind(this));
+ }
+
+ $(document).on('mouseup.ViewEditor', stopResizing.bind(this)).on('mousemove.ViewEditor', updateWidth.bind(this));
+ if (this.options.position === 'left') {
+ this.$('.ace_scroller').after($('<div>').addClass('ace_resize_bar'));
+ this.$('.ace_gutter').css({'cursor': 'default'});
+ this.$el.on('mousedown.ViewEditor', '.ace_resize_bar', startResizing.bind(this));
+ } else {
+ this.$el.on('mousedown.ViewEditor', '.ace_gutter', startResizing.bind(this));
+ }
+
+ resizeEditor.call(this, readEditorWidth.call(this));
+
+ return this._super.apply(this, arguments);
+
+ function resizeEditor(target) {
+ var width = Math.min(document.body.clientWidth, Math.max(parseInt(target, 10), minWidth));
+ this.$editor.width(width);
+ this.aceEditor.resize();
+ this.$el.width(width);
+ }
+ function storeEditorWidth() {
+ localStorage.setItem('ace_editor_width', this.$el.width());
+ }
+ function readEditorWidth() {
+ var width = localStorage.getItem('ace_editor_width');
+ return parseInt(width || 720, 10);
+ }
+ function startResizing(e) {
+ refX = e.pageX;
+ resizing = true;
+ }
+ function stopResizing() {
+ resizing = false;
+ }
+ function updateWidth(e) {
+ if (!resizing) return;
+
+ var offset = e.pageX - refX;
+ if (this.options.position === 'left') {
+ offset = - offset;
+ }
+ var width = this.$el.width() - offset;
+ refX = e.pageX;
+ resizeEditor.call(this, width);
+ debounceStoreEditorWidth.call(this);
+ }
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ this.$el.off('.ViewEditor');
+ $(document).off('.ViewEditor');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Initializes a text editor for the specified resource.
+ *
+ * @private
+ * @param {integer|string} resID - the ID/URL of the view/scss/js file
+ * @param {string} [type] (default to the currently selected one)
+ * @returns {ace.EditSession}
+ */
+ _buildEditingSession: function (resID, type) {
+ var self = this;
+ type = type || this.currentType;
+ var editingSession = new window.ace.EditSession(this.resources[type][resID].arch);
+ editingSession.setUseWorker(false);
+ editingSession.setMode('ace/mode/' + (type || this.currentType));
+ editingSession.setUndoManager(new window.ace.UndoManager());
+ editingSession.on('change', function () {
+ _.defer(function () {
+ self._toggleDirtyInfo(resID);
+ self._showErrorLine();
+ });
+ });
+ return editingSession;
+ },
+ /**
+ * Forces the view/scss/js file identified by its ID/URL to be displayed in the
+ * editor. The method will update the resource select DOM element as well if
+ * necessary.
+ *
+ * @private
+ * @param {integer|string} resID
+ * @param {string} [type] - the type of resource (either 'xml', 'scss' or 'js')
+ */
+ _displayResource: function (resID, type) {
+ if (type) {
+ this._switchType(type);
+ }
+
+ var editingSession = this.editingSessions[this.currentType][resID];
+ if (!editingSession) {
+ editingSession = this.editingSessions[this.currentType][resID] = this._buildEditingSession(resID);
+ }
+ this.aceEditor.setSession(editingSession);
+
+ if (this.currentType === 'xml') {
+ this.$viewID.text(_.str.sprintf(_t("Template ID: %s"), this.views[resID].key));
+ } else if (this.currentType === 'scss') {
+ this.$viewID.text(_.str.sprintf(_t("SCSS file: %s"), resID));
+ } else {
+ this.$viewID.text(_.str.sprintf(_t("JS file: %s"), resID));
+ }
+ const isCustomized = this._isCustomResource(resID);
+ this.$lists[this.currentType].select2('val', resID);
+
+ this.$resetButton.toggleClass('d-none', this.currentType === 'xml' || !isCustomized);
+
+ this.$warningMessage.toggleClass('d-none',
+ this.currentType !== 'xml' && (resID.indexOf('/user_custom_') >= 0) || isCustomized);
+
+ this.aceEditor.resize(true);
+ },
+ /**
+ * Formats the current resource being vizualized.
+ * (@see formatXML, formatSCSS)
+ *
+ * @private
+ */
+ _formatResource: function () {
+ var res = this.aceEditor.getValue();
+ var check = (this.currentType === 'xml' ? checkXML : checkSCSS)(res);
+ if (check.isValid) {
+ this.aceEditor.setValue((this.currentType === 'xml' ? formatXML : formatSCSS)(res));
+ } else {
+ this._showErrorLine(check.error.line, check.error.message, this._getSelectedResource());
+ }
+ },
+ /**
+ * Returns the currently selected resource data.
+ *
+ * @private
+ * @returns {integer|string} view ID or scss file URL
+ */
+ _getSelectedResource: function () {
+ var value = this.$lists[this.currentType].select2('val');
+ return parseInt(value, 10) || value;
+ },
+ /**
+ * Checks resource is customized or not.
+ *
+ * @private
+ * @param {integer|string} resID
+ */
+ _isCustomResource(resID) {
+ // TODO we should be able to detect if the XML template is customized
+ // to not show the warning in that case
+ let isCustomized = false;
+ if (this.currentType === 'scss') {
+ isCustomized = this.scss[resID].customized;
+ } else if (this.currentType === 'js') {
+ isCustomized = this.js[resID].customized;
+ }
+ return isCustomized;
+ },
+ /**
+ * Loads data the ace editor will vizualize and process it. Default behavior
+ * is loading the activate views, index them and build their hierarchy.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _loadResources: function () {
+ // Reset resources
+ this.resources = {xml: {}, scss: {}, js: {}};
+ this.editingSessions = {xml: {}, scss: {}, js: {}};
+ this.views = this.resources.xml;
+ this.scss = this.resources.scss;
+ this.js = this.resources.js;
+
+ // Load resources
+ return this._rpc({
+ route: '/web_editor/get_assets_editor_resources',
+ params: {
+ key: this.viewKey,
+ get_views: !this.options.doNotLoadViews,
+ get_scss: !this.options.doNotLoadSCSS,
+ get_js: !this.options.doNotLoadJS,
+ bundles: this.options.includeBundles,
+ bundles_restriction: this.options.filesFilter === 'all' ? [] : this.options.defaultBundlesRestriction,
+ only_user_custom_files: this.options.filesFilter === 'custom',
+ },
+ }).then((function (resources) {
+ _processViews.call(this, resources.views || []);
+ _processJSorSCSS.call(this, resources.scss || [], 'scss');
+ _processJSorSCSS.call(this, resources.js || [], 'js');
+ }).bind(this));
+
+ function _processViews(views) {
+ // Only keep the active views and index them by ID.
+ _.extend(this.views, _.indexBy(_.filter(views, function (view) {
+ return view.active;
+ }), 'id'));
+
+ // Initialize a 0 level for each view and assign them an array containing their children.
+ var self = this;
+ var roots = [];
+ _.each(this.views, function (view) {
+ view.level = 0;
+ view.children = [];
+ });
+ _.each(this.views, function (view) {
+ var parentId = view.inherit_id[0];
+ var parent = parentId && self.views[parentId];
+ if (parent) {
+ parent.children.push(view);
+ } else {
+ roots.push(view);
+ }
+ });
+
+ // Assign the correct level based on children key and save a sorted array where
+ // each view is followed by their children.
+ this.sortedViews = [];
+ function visit(view, level) {
+ view.level = level;
+ self.sortedViews.push(view);
+ _.each(view.children, function (child) {
+ visit(child, level + 1);
+ });
+ }
+ _.each(roots, function (root) {
+ visit(root, 0);
+ });
+ }
+
+ function _processJSorSCSS(data, type) {
+ // The received scss or js data is already sorted by bundle and DOM order
+ if (type === 'scss') {
+ this.sortedSCSS = data;
+ } else {
+ this.sortedJS = data;
+ }
+
+ // Store the URL ungrouped by bundle and use the URL as key (resource ID)
+ var resources = type === 'scss' ? this.scss : this.js;
+ _.each(data, function (bundleInfos) {
+ _.each(bundleInfos[1], function (info) { info.bundle_xmlid = bundleInfos[0].xmlid; });
+ _.extend(resources, _.indexBy(bundleInfos[1], 'url'));
+ });
+ }
+ },
+ /**
+ * Forces the view/scss/js file identified by its ID/URL to be reset to the way
+ * it was before the user started editing it.
+ *
+ * @todo views reset is not supported yet
+ *
+ * @private
+ * @param {integer|string} [resID] (default to the currently selected one)
+ * @param {string} [type] (default to the currently selected one)
+ * @returns {Promise}
+ */
+ _resetResource: function (resID, type) {
+ resID = resID || this._getSelectedResource();
+ type = type || this.currentType;
+
+ if (this.currentType === 'xml') {
+ return Promise.reject(_t("Reseting views is not supported yet"));
+ } else {
+ var resource = type === 'scss' ? this.scss[resID] : this.js[resID];
+ return this._rpc({
+ route: '/web_editor/reset_asset',
+ params: {
+ url: resID,
+ bundle_xmlid: resource.bundle_xmlid,
+ },
+ });
+ }
+ },
+ /**
+ * Saves a unique SCSS or JS file.
+ *
+ * @private
+ * @param {Object} session - contains the 'id' (url) and the 'text' of the
+ * SCSS or JS file to save.
+ * @return {Promise} status indicates if the save is finished or if an
+ * error occured.
+ */
+ _saveSCSSorJS: function (session) {
+ var self = this;
+ var sessionIdEndsWithJS = _.string.endsWith(session.id, '.js');
+ var bundleXmlID = sessionIdEndsWithJS ? this.js[session.id].bundle_xmlid : this.scss[session.id].bundle_xmlid;
+ var fileType = sessionIdEndsWithJS ? 'js' : 'scss';
+ return self._rpc({
+ route: '/web_editor/save_asset',
+ params: {
+ url: session.id,
+ bundle_xmlid: bundleXmlID,
+ content: session.text,
+ file_type: fileType,
+ },
+ }).then(function () {
+ self._toggleDirtyInfo(session.id, fileType, false);
+ });
+ },
+ /**
+ * Saves every resource that has been modified. If one cannot be saved, none
+ * is saved and an error message is displayed.
+ *
+ * @private
+ * @return {Promise} status indicates if the save is finished or if an
+ * error occured.
+ */
+ _saveResources: function () {
+ var self = this;
+ var toSave = {};
+ var errorFound = false;
+ _.each(this.editingSessions, (function (editingSessions, type) {
+ if (errorFound) return;
+
+ var dirtySessions = _.pick(editingSessions, function (session) {
+ return session.getUndoManager().hasUndo();
+ });
+ toSave[type] = _.map(dirtySessions, function (session, resID) {
+ return {
+ id: parseInt(resID, 10) || resID,
+ text: session.getValue(),
+ };
+ });
+
+ this._showErrorLine();
+ for (var i = 0 ; i < toSave[type].length && !errorFound ; i++) {
+ var check = (type === 'xml' ? checkXML : checkSCSS)(toSave[type][i].text);
+ if (!check.isValid) {
+ this._showErrorLine(check.error.line, check.error.message, toSave[type][i].id, type);
+ errorFound = toSave[type][i];
+ }
+ }
+ }).bind(this));
+ if (errorFound) return Promise.reject(errorFound);
+
+ var defs = [];
+ var mutex = new concurrency.Mutex();
+ _.each(toSave, (function (_toSave, type) {
+ // Child views first as COW on a parent would delete them
+ _toSave = _.sortBy(_toSave, 'id').reverse();
+ _.each(_toSave, function (session) {
+ defs.push(mutex.exec(function () {
+ return (type === 'xml' ? self._saveView(session) : self._saveSCSSorJS(session));
+ }));
+ });
+ }).bind(this));
+
+ var self = this;
+ return Promise.all(defs).guardedCatch(function (results) {
+ // some overrides handle errors themselves
+ if (results === undefined) {
+ return;
+ }
+ var error = results[1];
+ Dialog.alert(self, '', {
+ title: _t("Server error"),
+ $content: $('<div/>').html(
+ _t("A server error occured. Please check you correctly signed in and that the file you are saving is correctly formatted.")
+ + '<br/>'
+ + error
+ )
+ });
+ });
+ },
+ /**
+ * Saves an unique XML view.
+ *
+ * @private
+ * @param {Object} session - the 'id' and the 'text' of the view to save.
+ * @returns {Promise} status indicates if the save is finished or if an
+ * error occured.
+ */
+ _saveView: function (session) {
+ var self = this;
+ return new Promise(function (resolve, reject) {
+ self._rpc({
+ model: 'ir.ui.view',
+ method: 'write',
+ args: [[session.id], {arch: session.text}],
+ }, {
+ noContextKeys: 'lang',
+ }).then(function () {
+ self._toggleDirtyInfo(session.id, 'xml', false);
+ resolve();
+ }, function (source, error) {
+ reject(session, error);
+ });
+ });
+ },
+ /**
+ * Shows a line which produced an error. Red color is added to the editor,
+ * the cursor move to the line and a message is opened on click on the line
+ * number. If called without argument, the effects are removed.
+ *
+ * @private
+ * @param {integer} [line] - the line number to highlight
+ * @param {string} [message] - to show on click on the line number
+ * @param {integer|string} [resID]
+ * @param {string} [type]
+ */
+ _showErrorLine: function (line, message, resID, type) {
+ if (line === undefined || line <= 0) {
+ if (this.$errorLine) {
+ this.$errorLine.removeClass('o_error');
+ this.$errorLine.off('.o_error');
+ this.$errorLine = undefined;
+ this.$errorContent.removeClass('o_error');
+ this.$errorContent = undefined;
+ }
+ return;
+ }
+
+ if (type) this._switchType(type);
+
+ if (this._getSelectedResource() === resID) {
+ __showErrorLine.call(this, line);
+ } else {
+ var onChangeSession = (function () {
+ this.aceEditor.off('changeSession', onChangeSession);
+ _.delay(__showErrorLine.bind(this, line), 400);
+ }).bind(this);
+ this.aceEditor.on('changeSession', onChangeSession);
+ this._displayResource(resID, this.currentType);
+ }
+
+ function __showErrorLine(line) {
+ this.aceEditor.gotoLine(line);
+ this.$errorLine = this.$viewEditor.find('.ace_gutter-cell').filter(function () {
+ return parseInt($(this).text()) === line;
+ }).addClass('o_error');
+ this.$errorLine.addClass('o_error').on('click.o_error', function () {
+ var $message = $('<div/>').html(message);
+ $message.text($message.text());
+ Dialog.alert(this, "", {$content: $message});
+ });
+ this.$errorContent = this.$viewEditor.find('.ace_scroller').addClass('o_error');
+ }
+ },
+ /**
+ * Switches to the SCSS, XML or JS edition. Calling this method will adapt all
+ * DOM elements to keep the editor consistent.
+ *
+ * @private
+ * @param {string} type - either 'xml', 'scss' or 'js'
+ */
+ _switchType: function (type) {
+ this.currentType = type;
+ this.$typeSwitcherBtn.html(this.$typeSwitcherChoices.filter('[data-type=' + type + ']').html());
+ _.each(this.$lists, function ($list, _type) { $list.toggleClass('d-none', type !== _type); });
+ this.$lists[type].change();
+
+ this.$includeBundlesArea.toggleClass('d-none', this.currentType !== 'xml' || !config.isDebug());
+ this.$includeAllSCSSArea.toggleClass('d-none', this.currentType !== 'scss' || !config.isDebug());
+ this.$includeAllSCSSArea.find('[data-value="restricted"]').toggleClass('d-none', this.options.defaultBundlesRestriction.length === 0);
+ this.$formatButton.toggleClass('d-none', this.currentType !== 'xml');
+ },
+ /**
+ * Updates the select option DOM element associated with a particular resID
+ * to indicate if the option is dirty or not.
+ *
+ * @private
+ * @param {integer|string} resID
+ * @param {string} [type] (default to the currently selected one)
+ * @param {boolean} [isDirty] true if the view is dirty, default to content
+ * of UndoManager
+ */
+ _toggleDirtyInfo: function (resID, type, isDirty) {
+ type = type || this.currentType;
+
+ if (!resID || !this.editingSessions[type][resID]) return;
+
+ var $option = this.$lists[type].find('[value="' + resID + '"]');
+ if (isDirty === undefined) {
+ isDirty = this.editingSessions[type][resID].getUndoManager().hasUndo();
+ }
+ $option.data('dirty', isDirty);
+ },
+ /**
+ * Renders the content of the view/file <select/> DOM element according to
+ * current widget data.
+ *
+ * @private
+ */
+ _updateViewSelectDOM: function () {
+ var currentId = this._getSelectedResource();
+
+ var self = this;
+ this.$lists.xml.empty();
+ _.each(this.sortedViews, function (view) {
+ self.$lists.xml.append($('<option/>', {
+ value: view.id,
+ text: view.name,
+ selected: currentId === view.id,
+ 'data-level': view.level,
+ 'data-debug': view.xml_id,
+ }));
+ });
+
+ this.$lists.scss.empty();
+ _populateList(this.sortedSCSS, this.$lists.scss, 5);
+
+ this.$lists.js.empty();
+ _populateList(this.sortedJS, this.$lists.js, 3);
+
+ this.$lists.xml.select2('destroy');
+ this.$lists.xml.select2({
+ formatResult: _formatDisplay.bind(this, false),
+ formatSelection: _formatDisplay.bind(this, true),
+ });
+ this.$lists.xml.data('select2').dropdown.addClass('o_ace_select2_dropdown');
+ this.$lists.scss.select2('destroy');
+ this.$lists.scss.select2({
+ formatResult: _formatDisplay.bind(this, false),
+ formatSelection: _formatDisplay.bind(this, true),
+ });
+ this.$lists.scss.data('select2').dropdown.addClass('o_ace_select2_dropdown');
+ this.$lists.js.select2('destroy');
+ this.$lists.js.select2({
+ formatResult: _formatDisplay.bind(this, false),
+ formatSelection: _formatDisplay.bind(this, true),
+ });
+ this.$lists.js.data('select2').dropdown.addClass('o_ace_select2_dropdown');
+
+ function _populateList(sortedData, $list, lettersToRemove) {
+ _.each(sortedData, function (bundleInfos) {
+ var $optgroup = $('<optgroup/>', {
+ label: bundleInfos[0].name,
+ }).appendTo($list);
+ _.each(bundleInfos[1], function (dataInfo) {
+ var name = dataInfo.url.substring(_.lastIndexOf(dataInfo.url, '/') + 1, dataInfo.url.length - lettersToRemove);
+ $optgroup.append($('<option/>', {
+ value: dataInfo.url,
+ text: name,
+ selected: currentId === dataInfo.url,
+ 'data-debug': dataInfo.url,
+ 'data-customized': dataInfo.customized
+ }));
+ });
+ });
+ }
+
+ function _formatDisplay(isSelected, data) {
+ var $elem = $(data.element);
+
+ var text = data.text || '';
+ if (!isSelected) {
+ text = Array(($elem.data('level') || 0) + 1).join('-') + ' ' + text;
+ }
+ var $div = $('<div/>', {
+ text: text,
+ class: 'o_ace_select2_result',
+ });
+
+ if ($elem.data('dirty') || $elem.data('customized')) {
+ $div.prepend($('<span/>', {
+ class: 'mr8 fa fa-floppy-o ' + ($elem.data('dirty') ? 'text-warning' : 'text-success'),
+ }));
+ }
+
+ if (!isSelected && config.isDebug() && $elem.data('debug')) {
+ $div.append($('<span/>', {
+ text: ' (' + $elem.data('debug') + ')',
+ class: 'ml4 small text-muted',
+ }));
+ }
+
+ return $div;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the close button is clicked -> hides the ace editor.
+ *
+ * @private
+ */
+ _onCloseClick: function () {
+ this.do_hide();
+ },
+ /**
+ * Called when the format button is clicked -> format the current resource.
+ *
+ * @private
+ */
+ _onFormatClick: function () {
+ this._formatResource();
+ },
+ /**
+ * Called when a filter dropdown item is cliked. Reload the resources
+ * according to the new filter and make it visually active.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onFilterChange: function (ev) {
+ var $item = $(ev.target);
+ $item.addClass('active').siblings().removeClass('active');
+ if ($item.data('type') === 'xml') {
+ this.options.includeBundles = $(ev.target).data('value') === 'all';
+ } else {
+ this.options.filesFilter = $item.data('value');
+ }
+ this._loadResources().then(this._updateViewSelectDOM.bind(this));
+ },
+ /**
+ * Called when another resource is selected -> displays it.
+ *
+ * @private
+ */
+ _onResChange: function () {
+ this._displayResource(this._getSelectedResource());
+ },
+ /**
+ * Called when the reset button is clicked -> resets the resources to its
+ * original standard odoo state.
+ *
+ * @private
+ */
+ _onResetClick: function () {
+ var self = this;
+ Dialog.confirm(this, _t("If you reset this file, all your customizations will be lost as it will be reverted to the default file."), {
+ title: _t("Careful !"),
+ confirm_callback: function () {
+ self._resetResource(self._getSelectedResource());
+ },
+ });
+ },
+ /**
+ * Called when the save button is clicked -> saves the dirty resources and
+ * reloads.
+ *
+ * @private
+ */
+ _onSaveClick: function (ev) {
+ const restore = dom.addButtonLoadingEffect(ev.currentTarget);
+ this._saveResources().then(restore).guardedCatch(restore);
+ },
+ /**
+ * Called when the user wants to switch from xml to scss or vice-versa ->
+ * adapt resources choices and displays a resource of that type.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onTypeChoice: function (ev) {
+ ev.preventDefault();
+ this._switchType($(ev.target).data('type'));
+ },
+ /**
+ * Allows to hide the warning message without removing it from the DOM
+ * -> by default Bootstrap removes alert from the DOM
+ */
+ _onCloseWarningClick: function () {
+ this.$warningMessage.addClass('d-none');
+ },
+});
+
+return ViewEditor;
+});
diff --git a/addons/web_editor/static/src/js/common/utils.js b/addons/web_editor/static/src/js/common/utils.js
new file mode 100644
index 00000000..1cd10318
--- /dev/null
+++ b/addons/web_editor/static/src/js/common/utils.js
@@ -0,0 +1,266 @@
+odoo.define('web_editor.utils', function (require) {
+'use strict';
+
+const {ColorpickerWidget} = require('web.Colorpicker');
+
+/**
+ * window.getComputedStyle cannot work properly with CSS shortcuts (like
+ * 'border-width' which is a shortcut for the top + right + bottom + left border
+ * widths. If an option wants to customize such a shortcut, it should be listed
+ * here with the non-shortcuts property it stands for, in order.
+ *
+ * @type {Object<string[]>}
+ */
+const CSS_SHORTHANDS = {
+ 'border-width': ['border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'],
+ 'border-radius': ['border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius'],
+ 'border-color': ['border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color'],
+ 'border-style': ['border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style'],
+};
+/**
+ * Key-value mapping to list converters from an unit A to an unit B.
+ * - The key is a string in the format '$1-$2' where $1 is the CSS symbol of
+ * unit A and $2 is the CSS symbol of unit B.
+ * - The value is a function that converts the received value (expressed in
+ * unit A) to another value expressed in unit B. Two other parameters is
+ * received: the css property on which the unit applies and the jQuery element
+ * on which that css property may change.
+ */
+const CSS_UNITS_CONVERSION = {
+ 's-ms': () => 1000,
+ 'ms-s': () => 0.001,
+ 'rem-px': () => _computePxByRem(),
+ 'px-rem': () => _computePxByRem(true),
+};
+/**
+ * Colors of the default palette, used for substitution in shapes/illustrations.
+ * key: number of the color in the palette (ie, o-color-<1-5>)
+ * value: color hex code
+ */
+const DEFAULT_PALETTE = {
+ '1': '#3AADAA',
+ '2': '#7C6576',
+ '3': '#F6F6F6',
+ '4': '#FFFFFF',
+ '5': '#383E45',
+};
+
+/**
+ * Computes the number of "px" needed to make a "rem" unit. Subsequent calls
+ * returns the cached computed value.
+ *
+ * @param {boolean} [toRem=false]
+ * @returns {float} - number of px by rem if 'toRem' is false
+ * - the inverse otherwise
+ */
+function _computePxByRem(toRem) {
+ if (_computePxByRem.PX_BY_REM === undefined) {
+ const htmlStyle = window.getComputedStyle(document.documentElement);
+ _computePxByRem.PX_BY_REM = parseFloat(htmlStyle['font-size']);
+ }
+ return toRem ? (1 / _computePxByRem.PX_BY_REM) : _computePxByRem.PX_BY_REM;
+}
+/**
+ * Converts the given (value + unit) string to a numeric value expressed in
+ * the other given css unit.
+ *
+ * e.g. fct('400ms', 's') -> 0.4
+ *
+ * @param {string} value
+ * @param {string} unitTo
+ * @param {string} [cssProp] - the css property on which the unit applies
+ * @param {jQuery} [$target] - the jQuery element on which that css property
+ * may change
+ * @returns {number}
+ */
+function _convertValueToUnit(value, unitTo, cssProp, $target) {
+ const m = _getNumericAndUnit(value);
+ if (!m) {
+ return NaN;
+ }
+ const numValue = parseFloat(m[0]);
+ const valueUnit = m[1];
+ return _convertNumericToUnit(numValue, valueUnit, unitTo, cssProp, $target);
+}
+/**
+ * Converts the given numeric value expressed in the given css unit into
+ * the corresponding numeric value expressed in the other given css unit.
+ *
+ * e.g. fct(400, 'ms', 's') -> 0.4
+ *
+ * @param {number} value
+ * @param {string} unitFrom
+ * @param {string} unitTo
+ * @param {string} [cssProp] - the css property on which the unit applies
+ * @param {jQuery} [$target] - the jQuery element on which that css property
+ * may change
+ * @returns {number}
+ */
+function _convertNumericToUnit(value, unitFrom, unitTo, cssProp, $target) {
+ if (Math.abs(value) < Number.EPSILON || unitFrom === unitTo) {
+ return value;
+ }
+ const converter = CSS_UNITS_CONVERSION[`${unitFrom}-${unitTo}`];
+ if (converter === undefined) {
+ throw new Error(`Cannot convert '${unitFrom}' units into '${unitTo}' units !`);
+ }
+ return value * converter(cssProp, $target);
+}
+/**
+ * Returns the numeric value and unit of a css value.
+ *
+ * e.g. fct('400ms') -> [400, 'ms']
+ *
+ * @param {string} value
+ * @returns {Array|null}
+ */
+function _getNumericAndUnit(value) {
+ const m = value.trim().match(/^(-?[0-9.]+)([A-Za-z% -]*)$/);
+ if (!m) {
+ return null;
+ }
+ return [m[1].trim(), m[2].trim()];
+}
+/**
+ * Checks if two css values are equal.
+ *
+ * @param {string} value1
+ * @param {string} value2
+ * @param {string} [cssProp] - the css property on which the unit applies
+ * @param {jQuery} [$target] - the jQuery element on which that css property
+ * may change
+ * @returns {boolean}
+ */
+function _areCssValuesEqual(value1, value2, cssProp, $target) {
+ // String comparison first
+ if (value1 === value2) {
+ return true;
+ }
+
+ // It could be a CSS variable, in that case the actual value has to be
+ // retrieved before comparing.
+ if (value1.startsWith('var(--')) {
+ value1 = _getCSSVariableValue(value1.substring(6, value1.length - 1));
+ }
+ if (value2.startsWith('var(--')) {
+ value2 = _getCSSVariableValue(value2.substring(6, value2.length - 1));
+ }
+ if (value1 === value2) {
+ return true;
+ }
+
+ // They may be colors, normalize then re-compare the resulting string
+ const color1 = ColorpickerWidget.normalizeCSSColor(value1);
+ const color2 = ColorpickerWidget.normalizeCSSColor(value2);
+ if (color1 === color2) {
+ return true;
+ }
+
+ // Convert the second value in the unit of the first one and compare
+ // floating values
+ const data = _getNumericAndUnit(value1);
+ if (!data) {
+ return false;
+ }
+ const numValue1 = data[0];
+ const numValue2 = _convertValueToUnit(value2, data[1], cssProp, $target);
+ return (Math.abs(numValue1 - numValue2) < Number.EPSILON);
+}
+/**
+ * @param {string|number} name
+ * @returns {boolean}
+ */
+function _isColorCombinationName(name) {
+ const number = parseInt(name);
+ return (!isNaN(number) && number % 100 !== 0);
+}
+/**
+ * @param {string[]} colorNames
+ * @param {string} [prefix='bg-']
+ * @returns {string[]}
+ */
+function _computeColorClasses(colorNames, prefix = 'bg-') {
+ let hasCCClasses = false;
+ const isBgPrefix = (prefix === 'bg-');
+ const classes = colorNames.map(c => {
+ if (isBgPrefix && _isColorCombinationName(c)) {
+ hasCCClasses = true;
+ return `o_cc${c}`;
+ }
+ return (prefix + c);
+ });
+ if (hasCCClasses) {
+ classes.push('o_cc');
+ }
+ return classes;
+}
+/**
+ * @param {string} key
+ * @param {CSSStyleDeclaration} [htmlStyle] if not provided, it is computed
+ * @returns {string}
+ */
+function _getCSSVariableValue(key, htmlStyle) {
+ if (htmlStyle === undefined) {
+ htmlStyle = window.getComputedStyle(document.documentElement);
+ }
+ // Get trimmed value from the HTML element
+ let value = htmlStyle.getPropertyValue(`--${key}`).trim();
+ // If it is a color value, it needs to be normalized
+ value = ColorpickerWidget.normalizeCSSColor(value);
+ // Normally scss-string values are "printed" single-quoted. That way no
+ // magic conversation is needed when customizing a variable: either save it
+ // quoted for strings or non quoted for colors, numbers, etc. However,
+ // Chrome has the annoying behavior of changing the single-quotes to
+ // double-quotes when reading them through getPropertyValue...
+ return value.replace(/"/g, "'");
+}
+/**
+ * Normalize a color in case it is a variable name so it can be used outside of
+ * css.
+ *
+ * @param {string} color the color to normalize into a css value
+ * @returns {string} the normalized color
+ */
+function _normalizeColor(color) {
+ if (ColorpickerWidget.isCSSColor(color)) {
+ return color;
+ }
+ return _getCSSVariableValue(color);
+}
+/**
+ * Parse an element's background-image's url.
+ *
+ * @param {string} string a css value in the form 'url("...")'
+ * @returns {string|false} the src of the image or false if not parsable
+ */
+function _getBgImageURL(el) {
+ const string = $(el).css('background-image');
+ const match = string.match(/^url\((['"])(.*?)\1\)$/);
+ if (!match) {
+ return '';
+ }
+ const matchedURL = match[2];
+ // Make URL relative if possible
+ const fullURL = new URL(matchedURL, window.location.origin);
+ if (fullURL.origin === window.location.origin) {
+ return fullURL.href.slice(fullURL.origin.length);
+ }
+ return matchedURL;
+}
+
+return {
+ CSS_SHORTHANDS: CSS_SHORTHANDS,
+ CSS_UNITS_CONVERSION: CSS_UNITS_CONVERSION,
+ DEFAULT_PALETTE: DEFAULT_PALETTE,
+ computePxByRem: _computePxByRem,
+ convertValueToUnit: _convertValueToUnit,
+ convertNumericToUnit: _convertNumericToUnit,
+ getNumericAndUnit: _getNumericAndUnit,
+ areCssValuesEqual: _areCssValuesEqual,
+ isColorCombinationName: _isColorCombinationName,
+ computeColorClasses: _computeColorClasses,
+ getCSSVariableValue: _getCSSVariableValue,
+ normalizeColor: _normalizeColor,
+ getBgImageURL: _getBgImageURL,
+};
+});
diff --git a/addons/web_editor/static/src/js/editor/custom_colors.js b/addons/web_editor/static/src/js/editor/custom_colors.js
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/custom_colors.js
diff --git a/addons/web_editor/static/src/js/editor/editor.js b/addons/web_editor/static/src/js/editor/editor.js
new file mode 100644
index 00000000..1d6f34aa
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/editor.js
@@ -0,0 +1,289 @@
+odoo.define('web_editor.editor', function (require) {
+'use strict';
+
+var Dialog = require('web.Dialog');
+var Widget = require('web.Widget');
+var core = require('web.core');
+var rte = require('web_editor.rte');
+var snippetsEditor = require('web_editor.snippet.editor');
+var summernoteCustomColors = require('web_editor.rte.summernote_custom_colors');
+
+var _t = core._t;
+
+var EditorMenuBar = Widget.extend({
+ template: 'web_editor.editorbar',
+ xmlDependencies: ['/web_editor/static/src/xml/editor.xml'],
+ events: {
+ 'click button[data-action=save]': '_onSaveClick',
+ 'click button[data-action=cancel]': '_onCancelClick',
+ },
+ custom_events: {
+ request_editable: '_onRequestEditable',
+ request_history_undo_record: '_onHistoryUndoRecordRequest',
+ request_save: '_onSaveRequest',
+ },
+
+ /**
+ * Initializes RTE and snippets menu.
+ *
+ * @constructor
+ */
+ init: function (parent, options) {
+ var self = this;
+ var res = this._super.apply(this, arguments);
+ var Editor = options.Editor || rte.Class;
+ this.rte = new Editor(this, {
+ getConfig: function ($editable) {
+ var param = self._getDefaultConfig($editable);
+ if (options.generateOptions) {
+ param = options.generateOptions(param);
+ }
+ return param;
+ },
+ saveElement: options.saveElement,
+ });
+ this.rte.on('rte:start', this, function () {
+ self.trigger('rte:start');
+ });
+
+ // Snippets edition
+ var $editable = this.rte.editable();
+ window.__EditorMenuBar_$editable = $editable; // TODO remove this hack asap
+
+ if (options.snippets) {
+ this.snippetsMenu = new snippetsEditor.Class(this, Object.assign({
+ $el: $editable,
+ selectorEditableArea: '.o_editable',
+ }, options));
+ }
+
+ return res;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ var defs = [this._super.apply(this, arguments)];
+
+ core.bus.on('editor_save_request', this, this.save);
+ core.bus.on('editor_discard_request', this, this.cancel);
+
+ $('.dropdown-toggle').dropdown();
+
+ $(document).on('keyup', function (event) {
+ if ((event.keyCode === 8 || event.keyCode === 46)) {
+ var $target = $(event.target).closest('.o_editable');
+ if (!$target.is(':has(*:not(p):not(br))') && !$target.text().match(/\S/)) {
+ $target.empty();
+ }
+ }
+ });
+ $(document).on('click', '.note-editable', function (ev) {
+ ev.preventDefault();
+ });
+ $(document).on('submit', '.note-editable form .btn', function (ev) {
+ ev.preventDefault(); // Disable form submition in editable mode
+ });
+ $(document).on('hide.bs.dropdown', '.dropdown', function (ev) {
+ // Prevent dropdown closing when a contenteditable children is focused
+ if (ev.originalEvent
+ && $(ev.target).has(ev.originalEvent.target).length
+ && $(ev.originalEvent.target).is('[contenteditable]')) {
+ ev.preventDefault();
+ }
+ });
+
+ this.rte.start();
+
+ var flag = false;
+ window.onbeforeunload = function (event) {
+ if (rte.history.getEditableHasUndo().length && !flag) {
+ flag = true;
+ _.defer(function () { flag=false; });
+ return _t('This document is not saved!');
+ }
+ };
+
+ // Snippets menu
+ if (self.snippetsMenu) {
+ defs.push(this.snippetsMenu.insertAfter(this.$el));
+ }
+ this.rte.editable().find('*').off('mousedown mouseup click');
+
+ return Promise.all(defs).then(function () {
+ self.trigger_up('edit_mode');
+ });
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ core.bus.off('editor_save_request', this, this._onSaveRequest);
+ core.bus.off('editor_discard_request', this, this._onDiscardRequest);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Asks the user if he really wants to discard its changes (if there are
+ * some of them), then simply reload the page if he wants to.
+ *
+ * @param {boolean} [reload=true]
+ * true if the page has to be reloaded when the user answers yes
+ * (do nothing otherwise but add this to allow class extension)
+ * @returns {Promise}
+ */
+ cancel: function (reload) {
+ var self = this;
+ return new Promise(function(resolve, reject) {
+ if (!rte.history.getEditableHasUndo().length) {
+ resolve();
+ } else {
+ var confirm = Dialog.confirm(this, _t("If you discard the current edits, all unsaved changes will be lost. You can cancel to return to edit mode."), {
+ confirm_callback: resolve,
+ });
+ confirm.on('closed', self, reject);
+ }
+ }).then(function () {
+ if (reload !== false) {
+ window.onbeforeunload = null;
+ return self._reload();
+ }
+ });
+ },
+ /**
+ * Asks the snippets to clean themself, then saves the page, then reloads it
+ * if asked to.
+ *
+ * @param {boolean} [reload=true]
+ * true if the page has to be reloaded after the save
+ * @returns {Promise}
+ */
+ save: async function (reload) {
+ var defs = [];
+ this.trigger_up('ready_to_save', {defs: defs});
+ await Promise.all(defs);
+
+ if (this.snippetsMenu) {
+ await this.snippetsMenu.cleanForSave();
+ }
+ await this.getParent().saveModifiedImages(this.rte.editable());
+ await this.rte.save();
+
+ if (reload !== false) {
+ return this._reload();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _getDefaultConfig: function ($editable) {
+ return {
+ 'airMode' : true,
+ 'focus': false,
+ 'airPopover': [
+ ['style', ['style']],
+ ['font', ['bold', 'italic', 'underline', 'clear']],
+ ['fontsize', ['fontsize']],
+ ['color', ['color']],
+ ['para', ['ul', 'ol', 'paragraph']],
+ ['table', ['table']],
+ ['insert', ['link', 'picture']],
+ ['history', ['undo', 'redo']],
+ ],
+ 'styleWithSpan': false,
+ 'inlinemedia' : ['p'],
+ 'lang': 'odoo',
+ 'onChange': function (html, $editable) {
+ $editable.trigger('content_changed');
+ },
+ 'colors': summernoteCustomColors,
+ };
+ },
+ /**
+ * Reloads the page in non-editable mode, with the right scrolling.
+ *
+ * @private
+ * @returns {Promise} (never resolved, the page is reloading anyway)
+ */
+ _reload: function () {
+ window.location.hash = 'scrollTop=' + window.document.body.scrollTop;
+ if (window.location.search.indexOf('enable_editor') >= 0) {
+ window.location.href = window.location.href.replace(/&?enable_editor(=[^&]*)?/g, '');
+ } else {
+ window.location.reload(true);
+ }
+ return new Promise(function(){});
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the "Discard" button is clicked -> discards the changes.
+ *
+ * @private
+ */
+ _onCancelClick: function () {
+ this.cancel();
+ },
+ /**
+ * Called when an element askes to record an history undo -> records it.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onHistoryUndoRecordRequest: function (ev) {
+ this.rte.historyRecordUndo(ev.data.$target, ev.data.event);
+ },
+ /**
+ * Called when the "Save" button is clicked -> saves the changes.
+ *
+ * @private
+ */
+ _onSaveClick: function () {
+ this.save();
+ },
+ /**
+ * Called when a discard request is received -> discard the page content
+ * changes.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onDiscardRequest: function (ev) {
+ this.cancel(ev.data.reload).then(ev.data.onSuccess, ev.data.onFailure);
+ },
+ /**
+ * Called when a save request is received -> saves the page content.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSaveRequest: function (ev) {
+ ev.stopPropagation();
+ this.save(ev.data.reload).then(ev.data.onSuccess, ev.data.onFailure);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onRequestEditable: function (ev) {
+ ev.data.callback(this.rte.editable());
+ },
+});
+
+return {
+ Class: EditorMenuBar,
+};
+});
diff --git a/addons/web_editor/static/src/js/editor/image_processing.js b/addons/web_editor/static/src/js/editor/image_processing.js
new file mode 100644
index 00000000..e3497634
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/image_processing.js
@@ -0,0 +1,335 @@
+odoo.define('web_editor.image_processing', function (require) {
+'use strict';
+
+// Fields returned by cropperjs 'getData' method, also need to be passed when
+// initializing the cropper to reuse the previous crop.
+const cropperDataFields = ['x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY'];
+const modifierFields = [
+ 'filter',
+ 'quality',
+ 'mimetype',
+ 'glFilter',
+ 'originalId',
+ 'originalSrc',
+ 'resizeWidth',
+ 'aspectRatio',
+];
+
+// webgl color filters
+const _applyAll = (result, filter, filters) => {
+ filters.forEach(f => {
+ if (f[0] === 'blend') {
+ const cv = f[1];
+ const ctx = result.getContext('2d');
+ ctx.globalCompositeOperation = f[2];
+ ctx.globalAlpha = f[3];
+ ctx.drawImage(cv, 0, 0);
+ ctx.globalCompositeOperation = 'source-over';
+ ctx.globalAlpha = 1.0;
+ } else {
+ filter.addFilter(...f);
+ }
+ });
+};
+let applyAll;
+
+const glFilters = {
+ blur: filter => filter.addFilter('blur', 10),
+
+ '1977': (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = 'rgb(243, 106, 188)';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'screen', .3],
+ ['brightness', .1],
+ ['contrast', .1],
+ ['saturation', .3],
+ ]);
+ },
+
+ aden: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = 'rgb(66, 10, 14)';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'darken', .2],
+ ['brightness', .2],
+ ['contrast', -.1],
+ ['saturation', -.15],
+ ['hue', 20],
+ ]);
+ },
+
+ brannan: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = 'rgb(161, 44, 191)';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'lighten', .31],
+ ['sepia', .5],
+ ['contrast', .4],
+ ]);
+ },
+
+ earlybird: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ const gradient = ctx.createRadialGradient(
+ cv.width / 2, cv.height / 2, 0,
+ cv.width / 2, cv.height / 2, Math.hypot(cv.width, cv.height) / 2
+ );
+ gradient.addColorStop(.2, '#D0BA8E');
+ gradient.addColorStop(1, '#1D0210');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'overlay', .2],
+ ['sepia', .2],
+ ['contrast', -.1],
+ ]);
+ },
+
+ inkwell: (filter, cv) => {
+ applyAll(filter, [
+ ['sepia', .3],
+ ['brightness', .1],
+ ['contrast', -.1],
+ ['desaturateLuminance'],
+ ]);
+ },
+
+ // Needs hue blending mode for perfect reproduction. Close enough?
+ maven: (filter, cv) => {
+ applyAll(filter, [
+ ['sepia', .25],
+ ['brightness', -.05],
+ ['contrast', -.05],
+ ['saturation', .5],
+ ]);
+ },
+
+ toaster: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ const gradient = ctx.createRadialGradient(
+ cv.width / 2, cv.height / 2, 0,
+ cv.width / 2, cv.height / 2, Math.hypot(cv.width, cv.height) / 2
+ );
+ gradient.addColorStop(0, '#0F4E80');
+ gradient.addColorStop(1, '#3B003B');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'screen', .5],
+ ['brightness', -.1],
+ ['contrast', .5],
+ ]);
+ },
+
+ walden: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = '#CC4400';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'screen', .3],
+ ['sepia', .3],
+ ['brightness', .1],
+ ['saturation', .6],
+ ['hue', 350],
+ ]);
+ },
+
+ valencia: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = '#3A0339';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'exclusion', .5],
+ ['sepia', .08],
+ ['brightness', .08],
+ ['contrast', .08],
+ ]);
+ },
+
+ xpro: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ const gradient = ctx.createRadialGradient(
+ cv.width / 2, cv.height / 2, 0,
+ cv.width / 2, cv.height / 2, Math.hypot(cv.width, cv.height) / 2
+ );
+ gradient.addColorStop(.4, '#E0E7E6');
+ gradient.addColorStop(1, '#2B2AA1');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'color-burn', .7],
+ ['sepia', .3],
+ ]);
+ },
+
+ custom: (filter, cv, filterOptions) => {
+ const options = Object.assign({
+ blend: 'normal',
+ filterColor: '',
+ blur: '0',
+ desaturateLuminance: '0',
+ saturation: '0',
+ contrast: '0',
+ brightness: '0',
+ sepia: '0',
+ }, JSON.parse(filterOptions || "{}"));
+ const filters = [];
+ if (options.filterColor) {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = options.filterColor;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ filters.push(['blend', cv, options.blend, 1]);
+ }
+ delete options.blend;
+ delete options.filterColor;
+ filters.push(...Object.entries(options).map(([filter, amount]) => [filter, parseInt(amount) / 100]));
+ applyAll(filter, filters);
+ },
+};
+/**
+ * Applies data-attributes modifications to an img tag and returns a dataURL
+ * containing the result. This function does not modify the original image.
+ *
+ * @param {HTMLImageElement} img the image to which modifications are applied
+ * @returns {string} dataURL of the image with the applied modifications
+ */
+async function applyModifications(img) {
+ const data = Object.assign({
+ glFilter: '',
+ filter: '#0000',
+ quality: '75',
+ }, img.dataset);
+ let {
+ width,
+ height,
+ resizeWidth,
+ quality,
+ filter,
+ mimetype,
+ originalSrc,
+ glFilter,
+ filterOptions,
+ } = data;
+ [width, height, resizeWidth] = [width, height, resizeWidth].map(s => parseFloat(s));
+ quality = parseInt(quality);
+
+ // Crop
+ const container = document.createElement('div');
+ const original = await loadImage(originalSrc);
+ container.appendChild(original);
+ await activateCropper(original, 0, data);
+ const croppedImg = $(original).cropper('getCroppedCanvas', {width, height});
+ $(original).cropper('destroy');
+
+ // Width
+ const result = document.createElement('canvas');
+ result.width = resizeWidth || croppedImg.width;
+ result.height = croppedImg.height * result.width / croppedImg.width;
+ const ctx = result.getContext('2d');
+ ctx.drawImage(croppedImg, 0, 0, croppedImg.width, croppedImg.height, 0, 0, result.width, result.height);
+
+ // GL filter
+ if (glFilter) {
+ const glf = new window.WebGLImageFilter();
+ const cv = document.createElement('canvas');
+ cv.width = result.width;
+ cv.height = result.height;
+ applyAll = _applyAll.bind(null, result);
+ glFilters[glFilter](glf, cv, filterOptions);
+ const filtered = glf.apply(result);
+ ctx.drawImage(filtered, 0, 0, filtered.width, filtered.height, 0, 0, result.width, result.height);
+ }
+
+ // Color filter
+ ctx.fillStyle = filter || '#0000';
+ ctx.fillRect(0, 0, result.width, result.height);
+
+ // Quality
+ return result.toDataURL(mimetype, quality / 100);
+}
+
+/**
+ * Loads an src into an HTMLImageElement.
+ *
+ * @param {String} src URL of the image to load
+ * @param {HTMLImageElement} [img] img element in which to load the image
+ * @returns {Promise<HTMLImageElement>} Promise that resolves to the loaded img
+ */
+function loadImage(src, img = new Image()) {
+ return new Promise((resolve, reject) => {
+ img.addEventListener('load', () => resolve(img), {once: true});
+ img.addEventListener('error', reject, {once: true});
+ img.src = src;
+ });
+}
+
+// Because cropperjs acquires images through XHRs on the image src and we don't
+// want to load big images over the network many times when adjusting quality
+// and filter, we create a local cache of the images using object URLs.
+const imageCache = new Map();
+/**
+ * Activates the cropper on a given image.
+ *
+ * @param {jQuery} $image the image on which to activate the cropper
+ * @param {Number} aspectRatio the aspectRatio of the crop box
+ * @param {DOMStringMap} dataset dataset containing the cropperDataFields
+ */
+async function activateCropper(image, aspectRatio, dataset) {
+ const src = image.getAttribute('src');
+ if (!imageCache.has(src)) {
+ const res = await fetch(src);
+ imageCache.set(src, URL.createObjectURL(await res.blob()));
+ }
+ image.src = imageCache.get(src);
+ $(image).cropper({
+ viewMode: 2,
+ dragMode: 'move',
+ autoCropArea: 1.0,
+ aspectRatio: aspectRatio,
+ data: _.mapObject(_.pick(dataset, ...cropperDataFields), value => parseFloat(value)),
+ // Can't use 0 because it's falsy and cropperjs will then use its defaults (200x100)
+ minContainerWidth: 1,
+ minContainerHeight: 1,
+ });
+ return new Promise(resolve => image.addEventListener('ready', resolve, {once: true}));
+}
+/**
+ * Marks an <img> with its attachment data (originalId, originalSrc, mimetype)
+ *
+ * @param {HTMLImageElement} img the image whose attachment data should be found
+ * @param {Function} rpc a function that can be used to make the RPC. Typically
+ * this would be passed as 'this._rpc.bind(this)' from widgets.
+ */
+async function loadImageInfo(img, rpc) {
+ const src = img.getAttribute('src');
+ // If there is a marked originalSrc, the data is already loaded.
+ if (img.dataset.originalSrc || !src) {
+ return;
+ }
+
+ const {original} = await rpc({
+ route: '/web_editor/get_image_info',
+ params: {src: src.split(/[?#]/)[0]},
+ });
+ // Check that url is local.
+ const isLocal = original && new URL(original.image_src, window.location.origin).origin === window.location.origin;
+ if (isLocal && original.image_src) {
+ img.dataset.originalId = original.id;
+ img.dataset.originalSrc = original.image_src;
+ img.dataset.mimetype = original.mimetype;
+ }
+}
+
+return {
+ applyModifications,
+ cropperDataFields,
+ activateCropper,
+ loadImageInfo,
+ loadImage,
+ removeOnImageChangeAttrs: [...cropperDataFields, ...modifierFields, 'aspectRatio'],
+};
+});
diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js
new file mode 100644
index 00000000..baded863
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/rte.js
@@ -0,0 +1,816 @@
+odoo.define('web_editor.rte', function (require) {
+'use strict';
+
+var fonts = require('wysiwyg.fonts');
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var Widget = require('web.Widget');
+var weContext = require('web_editor.context');
+var summernote = require('web_editor.summernote');
+var summernoteCustomColors = require('web_editor.rte.summernote_custom_colors');
+
+var _t = core._t;
+
+// Summernote Lib (neek change to make accessible: method and object)
+var dom = summernote.core.dom;
+var range = summernote.core.range;
+
+// Change History to have a global History for all summernote instances
+var History = function History($editable) {
+ var aUndo = [];
+ var pos = 0;
+ var toSnap;
+
+ this.makeSnap = function (event, rng) {
+ rng = rng || range.create();
+ var elEditable = $(rng && rng.sc).closest('.o_editable')[0];
+ if (!elEditable) {
+ return false;
+ }
+ return {
+ event: event,
+ editable: elEditable,
+ contents: elEditable.innerHTML,
+ bookmark: rng && rng.bookmark(elEditable),
+ scrollTop: $(elEditable).scrollTop()
+ };
+ };
+
+ this.applySnap = function (oSnap) {
+ var $editable = $(oSnap.editable);
+
+ if (document.documentMode) {
+ $editable.removeAttr('contentEditable').removeProp('contentEditable');
+ }
+
+ $editable.trigger('content_will_be_destroyed');
+ var $tempDiv = $('<div/>', {html: oSnap.contents});
+ _.each($tempDiv.find('.o_temp_auto_element'), function (el) {
+ var $el = $(el);
+ var originalContent = $el.attr('data-temp-auto-element-original-content');
+ if (originalContent) {
+ $el.after(originalContent);
+ }
+ $el.remove();
+ });
+ $editable.html($tempDiv.html()).scrollTop(oSnap.scrollTop);
+ $editable.trigger('content_was_recreated');
+
+ $('.oe_overlay').remove();
+ $('.note-control-selection').hide();
+
+ $editable.trigger('content_changed');
+
+ try {
+ var r = oSnap.editable.innerHTML === '' ? range.create(oSnap.editable, 0) : range.createFromBookmark(oSnap.editable, oSnap.bookmark);
+ r.select();
+ } catch (e) {
+ console.error(e);
+ return;
+ }
+
+ $(document).trigger('click');
+ $('.o_editable *').filter(function () {
+ var $el = $(this);
+ if ($el.data('snippet-editor')) {
+ $el.removeData();
+ }
+ });
+
+
+ _.defer(function () {
+ var target = dom.isBR(r.sc) ? r.sc.parentNode : dom.node(r.sc);
+ if (!target) {
+ return;
+ }
+
+ $editable.trigger('applySnap');
+
+ var evt = document.createEvent('MouseEvents');
+ evt.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, target);
+ target.dispatchEvent(evt);
+
+ $editable.trigger('keyup');
+ });
+ };
+
+ this.undo = function () {
+ if (!pos) { return; }
+ var _toSnap = toSnap;
+ if (_toSnap) {
+ this.saveSnap();
+ }
+ if (!aUndo[pos] && (!aUndo[pos] || aUndo[pos].event !== 'undo')) {
+ var temp = this.makeSnap('undo');
+ if (temp && (!pos || temp.contents !== aUndo[pos-1].contents)) {
+ aUndo[pos] = temp;
+ } else {
+ pos--;
+ }
+ } else if (_toSnap) {
+ pos--;
+ }
+ this.applySnap(aUndo[Math.max(--pos,0)]);
+ while (pos && (aUndo[pos].event === 'blur' || (aUndo[pos+1].editable === aUndo[pos].editable && aUndo[pos+1].contents === aUndo[pos].contents))) {
+ this.applySnap(aUndo[--pos]);
+ }
+ };
+
+ this.hasUndo = function () {
+ return (toSnap && (toSnap.event !== 'blur' && toSnap.event !== 'activate' && toSnap.event !== 'undo')) ||
+ !!_.find(aUndo.slice(0, pos+1), function (undo) {
+ return undo.event !== 'blur' && undo.event !== 'activate' && undo.event !== 'undo';
+ });
+ };
+
+ this.getEditableHasUndo = function () {
+ var editable = [];
+ if ((toSnap && (toSnap.event !== 'blur' && toSnap.event !== 'activate' && toSnap.event !== 'undo'))) {
+ editable.push(toSnap.editable);
+ }
+ _.each(aUndo.slice(0, pos+1), function (undo) {
+ if (undo.event !== 'blur' && undo.event !== 'activate' && undo.event !== 'undo') {
+ editable.push(undo.editable);
+ }
+ });
+ return _.uniq(editable);
+ };
+
+ this.redo = function () {
+ if (!aUndo[pos+1]) { return; }
+ this.applySnap(aUndo[++pos]);
+ while (aUndo[pos+1] && aUndo[pos].event === 'active') {
+ this.applySnap(aUndo[pos++]);
+ }
+ };
+
+ this.hasRedo = function () {
+ return aUndo.length > pos+1;
+ };
+
+ this.recordUndo = function ($editable, event, internal_history) {
+ var self = this;
+ if (!$editable) {
+ var rng = range.create();
+ if (!rng) return;
+ $editable = $(rng.sc).closest('.o_editable');
+ }
+
+ if (aUndo[pos] && (event === 'applySnap' || event === 'activate')) {
+ return;
+ }
+
+ if (!internal_history) {
+ if (!event || !toSnap || !aUndo[pos-1] || toSnap.event === 'activate') { // don't trigger change for all keypress
+ setTimeout(function () {
+ $editable.trigger('content_changed');
+ },0);
+ }
+ }
+
+ if (aUndo[pos]) {
+ pos = Math.min(pos, aUndo.length);
+ aUndo.splice(pos, aUndo.length);
+ }
+
+ // => make a snap when the user change editable zone (because: don't make snap for each keydown)
+ if (toSnap && (toSnap.split || !event || toSnap.event !== event || toSnap.editable !== $editable[0])) {
+ this.saveSnap();
+ }
+
+ if (pos && aUndo[pos-1].editable !== $editable[0]) {
+ var snap = this.makeSnap('blur', range.create(aUndo[pos-1].editable, 0));
+ pos++;
+ aUndo.push(snap);
+ }
+
+ if (range.create()) {
+ toSnap = self.makeSnap(event);
+ } else {
+ toSnap = false;
+ }
+ };
+
+ this.splitNext = function () {
+ if (toSnap) {
+ toSnap.split = true;
+ }
+ };
+
+ this.saveSnap = function () {
+ if (toSnap) {
+ if (!aUndo[pos]) {
+ pos++;
+ }
+ aUndo.push(toSnap);
+ delete toSnap.split;
+ toSnap = null;
+ }
+ };
+};
+var history = new History();
+
+// jQuery extensions
+$.extend($.expr[':'], {
+ o_editable: function (node, i, m) {
+ while (node) {
+ if (node.className && _.isString(node.className)) {
+ if (node.className.indexOf('o_not_editable')!==-1 ) {
+ return false;
+ }
+ if (node.className.indexOf('o_editable')!==-1 ) {
+ return true;
+ }
+ }
+ node = node.parentNode;
+ }
+ return false;
+ },
+});
+$.fn.extend({
+ focusIn: function () {
+ if (this.length) {
+ range.create(dom.firstChild(this[0]), 0).select();
+ }
+ return this;
+ },
+ focusInEnd: function () {
+ if (this.length) {
+ var last = dom.lastChild(this[0]);
+ range.create(last, dom.nodeLength(last)).select();
+ }
+ return this;
+ },
+ selectContent: function () {
+ if (this.length) {
+ var next = dom.lastChild(this[0]);
+ range.create(dom.firstChild(this[0]), 0, next, next.textContent.length).select();
+ }
+ return this;
+ },
+});
+
+// RTE
+var RTEWidget = Widget.extend({
+ /**
+ * @constructor
+ */
+ init: function (parent, params) {
+ var self = this;
+ this._super.apply(this, arguments);
+
+ this.init_bootstrap_carousel = $.fn.carousel;
+ this.edit_bootstrap_carousel = function () {
+ var res = self.init_bootstrap_carousel.apply(this, arguments);
+ // off bootstrap keydown event to remove event.preventDefault()
+ // and allow to change cursor position
+ $(this).off('keydown.bs.carousel');
+ return res;
+ };
+
+ this._getConfig = params && params.getConfig || this._getDefaultConfig;
+ this._saveElement = params && params.saveElement || this._saveElement;
+
+ fonts.computeFonts();
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+
+ this.saving_mutex = new concurrency.Mutex();
+
+ $.fn.carousel = this.edit_bootstrap_carousel;
+
+ $(document).on('click.rte keyup.rte', function () {
+ var current_range = {};
+ try {
+ current_range = range.create() || {};
+ } catch (e) {
+ // if range is on Restricted element ignore error
+ }
+ var $popover = $(current_range.sc).closest('[contenteditable]');
+ var popover_history = ($popover.data()||{}).NoteHistory;
+ if (!popover_history || popover_history === history) return;
+ var editor = $popover.parent('.note-editor');
+ $('button[data-event="undo"]', editor).attr('disabled', !popover_history.hasUndo());
+ $('button[data-event="redo"]', editor).attr('disabled', !popover_history.hasRedo());
+ });
+ $(document).on('mousedown.rte activate.rte', this, this._onMousedown.bind(this));
+ $(document).on('mouseup.rte', this, this._onMouseup.bind(this));
+
+ $('.o_not_editable').attr('contentEditable', false);
+
+ var $editable = this.editable();
+
+ // When a undo/redo is performed, the whole DOM is changed so we have
+ // to prepare for it (website will restart animations for example)
+ // TODO should be better handled
+ $editable.on('content_will_be_destroyed', function (ev) {
+ self.trigger_up('content_will_be_destroyed', {
+ $target: $(ev.currentTarget),
+ });
+ });
+ $editable.on('content_was_recreated', function (ev) {
+ self.trigger_up('content_was_recreated', {
+ $target: $(ev.currentTarget),
+ });
+ });
+
+ $editable.addClass('o_editable')
+ .data('rte', this)
+ .each(function () {
+ var $node = $(this);
+
+ // fallback for firefox iframe display:none see https://github.com/odoo/odoo/pull/22610
+ var computedStyles = window.getComputedStyle(this) || window.parent.getComputedStyle(this);
+ // add class to display inline-block for empty t-field
+ if (computedStyles.display === 'inline' && $node.data('oe-type') !== 'image') {
+ $node.addClass('o_is_inline_editable');
+ }
+ });
+
+ // start element observation
+ $(document).on('content_changed', function (ev) {
+ self.trigger_up('rte_change', {target: ev.target});
+
+ // Add the dirty flag to the element that changed by either adding
+ // it on the highest editable ancestor or, if there is no editable
+ // ancestor, on the element itself (that element may not be editable
+ // but if it received a content_changed event, it should be marked
+ // as dirty to allow for custom savings).
+ if (!ev.__isDirtyHandled) {
+ ev.__isDirtyHandled = true;
+
+ var el = ev.target;
+ var dirty = el.closest('.o_editable') || el;
+ dirty.classList.add('o_dirty');
+ }
+ });
+
+ $('#wrapwrap, .o_editable').on('click.rte', '*', this, this._onClick.bind(this));
+
+ $('body').addClass('editor_enable');
+
+ $(document.body)
+ .tooltip({
+ selector: '[data-oe-readonly]',
+ container: 'body',
+ trigger: 'hover',
+ delay: { 'show': 1000, 'hide': 100 },
+ placement: 'bottom',
+ title: _t("Readonly field")
+ })
+ .on('click', function () {
+ $(this).tooltip('hide');
+ });
+
+ $(document).trigger('mousedown');
+ this.trigger('rte:start');
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this.cancel();
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Stops the RTE.
+ */
+ cancel: function () {
+ if (this.$last) {
+ this.$last.destroy();
+ this.$last = null;
+ }
+
+ $.fn.carousel = this.init_bootstrap_carousel;
+
+ $(document).off('.rte');
+ $('#wrapwrap, .o_editable').off('.rte');
+
+ $('.o_not_editable').removeAttr('contentEditable');
+
+ $(document).off('click.rte keyup.rte mousedown.rte activate.rte mouseup.rte');
+ $(document).off('content_changed').removeClass('o_is_inline_editable').removeData('rte');
+ $(document).tooltip('dispose');
+ $('body').removeClass('editor_enable');
+ this.trigger('rte:stop');
+ },
+ /**
+ * Returns the editable areas on the page.
+ *
+ * @returns {jQuery}
+ */
+ editable: function () {
+ return $('#wrapwrap [data-oe-model]')
+ .not('.o_not_editable')
+ .filter(function () {
+ return !$(this).closest('.o_not_editable').length;
+ })
+ .not('link, script')
+ .not('[data-oe-readonly]')
+ .not('img[data-oe-field="arch"], br[data-oe-field="arch"], input[data-oe-field="arch"]')
+ .not('.oe_snippet_editor')
+ .add('.o_editable');
+ },
+ /**
+ * Records the current state of the given $target to be able to undo future
+ * changes.
+ *
+ * @see History.recordUndo
+ * @param {jQuery} $target
+ * @param {string} event
+ * @param {boolean} internal_history
+ */
+ historyRecordUndo: function ($target, event, internal_history) {
+ const initialActiveElement = document.activeElement;
+ const initialSelectionStart = initialActiveElement && initialActiveElement.selectionStart;
+ const initialSelectionEnd = initialActiveElement && initialActiveElement.selectionEnd;
+
+ $target = $($target);
+ var rng = range.create();
+ var $editable = $(rng && rng.sc).closest('.o_editable');
+ if (!rng || !$editable.length) {
+ $editable = $target.closest('.o_editable');
+ rng = range.create($target.closest('*')[0],0);
+ } else {
+ rng = $editable.data('range') || rng;
+ }
+ try {
+ // TODO this line might break for unknown reasons. I suppose that
+ // the created range is an invalid one. As it might be tricky to
+ // adapt that line and that it is not a critical one, temporary fix
+ // is to ignore the errors that this generates.
+ rng.select();
+ } catch (e) {
+ console.log('error', e);
+ }
+ history.recordUndo($editable, event, internal_history);
+
+ if (initialActiveElement && initialActiveElement !== document.activeElement) {
+ initialActiveElement.focus();
+ // Range inputs don't support selection
+ if (initialActiveElement.matches('input[type=range]')) {
+ return;
+ }
+ try {
+ initialActiveElement.selectionStart = initialSelectionStart;
+ initialActiveElement.selectionEnd = initialSelectionEnd;
+ } catch (e) {
+ // The active element might be of a type that
+ // does not support selection.
+ console.log('error', e);
+ }
+ }
+ },
+ /**
+ * Searches all the dirty element on the page and saves them one by one. If
+ * one cannot be saved, this notifies it to the user and restarts rte
+ * edition.
+ *
+ * @param {Object} [context] - the context to use for saving rpc, default to
+ * the editor context found on the page
+ * @return {Promise} rejected if the save cannot be done
+ */
+ save: function (context) {
+ var self = this;
+
+ $('.o_editable')
+ .destroy()
+ .removeClass('o_editable o_is_inline_editable o_editable_date_field_linked o_editable_date_field_format_changed');
+
+ var $dirty = $('.o_dirty');
+ $dirty
+ .removeAttr('contentEditable')
+ .removeClass('o_dirty oe_carlos_danger o_is_inline_editable');
+ var defs = _.map($dirty, function (el) {
+ var $el = $(el);
+
+ $el.find('[class]').filter(function () {
+ if (!this.getAttribute('class').match(/\S/)) {
+ this.removeAttribute('class');
+ }
+ });
+
+ // TODO: Add a queue with concurrency limit in webclient
+ // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
+ return self.saving_mutex.exec(function () {
+ return self._saveElement($el, context || weContext.get())
+ .then(function () {
+ $el.removeClass('o_dirty');
+ }).guardedCatch(function (response) {
+ // because ckeditor regenerates all the dom, we can't just
+ // setup the popover here as everything will be destroyed by
+ // the DOM regeneration. Add markings instead, and returns a
+ // new rejection with all relevant info
+ var id = _.uniqueId('carlos_danger_');
+ $el.addClass('o_dirty oe_carlos_danger ' + id);
+ $('.o_editable.' + id)
+ .removeClass(id)
+ .popover({
+ trigger: 'hover',
+ content: response.message.data.message || '',
+ placement: 'auto top',
+ })
+ .popover('show');
+ });
+ });
+ });
+
+ return Promise.all(defs).then(function () {
+ window.onbeforeunload = null;
+ }).guardedCatch(function (failed) {
+ // If there were errors, re-enable edition
+ self.cancel();
+ self.start();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * When the users clicks on an editable element, this function allows to add
+ * external behaviors.
+ *
+ * @private
+ * @param {jQuery} $editable
+ */
+ _enableEditableArea: function ($editable) {
+ if ($editable.data('oe-type') === "datetime" || $editable.data('oe-type') === "date") {
+ var selector = '[data-oe-id="' + $editable.data('oe-id') + '"]';
+ selector += '[data-oe-field="' + $editable.data('oe-field') + '"]';
+ selector += '[data-oe-model="' + $editable.data('oe-model') + '"]';
+ var $linkedFieldNodes = this.editable().find(selector).addBack(selector);
+ $linkedFieldNodes.not($editable).addClass('o_editable_date_field_linked');
+ if (!$editable.hasClass('o_editable_date_field_format_changed')) {
+ $linkedFieldNodes.html($editable.data('oe-original-with-format'));
+ $linkedFieldNodes.addClass('o_editable_date_field_format_changed');
+ }
+ }
+ if ($editable.data('oe-type') === "monetary") {
+ $editable.attr('contenteditable', false);
+ $editable.find('.oe_currency_value').attr('contenteditable', true);
+ }
+ if ($editable.is('[data-oe-model]') && !$editable.is('[data-oe-model="ir.ui.view"]') && !$editable.is('[data-oe-type="html"]')) {
+ $editable.data('layoutInfo').popover().find('.btn-group:not(.note-history)').remove();
+ }
+ if ($editable.data('oe-type') === "image") {
+ $editable.attr('contenteditable', false);
+ $editable.find('img').attr('contenteditable', true);
+ }
+ },
+ /**
+ * When an element enters edition, summernote is initialized on it. This
+ * function returns the default configuration for the summernote instance.
+ *
+ * @see _getConfig
+ * @private
+ * @param {jQuery} $editable
+ * @returns {Object}
+ */
+ _getDefaultConfig: function ($editable) {
+ return {
+ 'airMode' : true,
+ 'focus': false,
+ 'airPopover': [
+ ['style', ['style']],
+ ['font', ['bold', 'italic', 'underline', 'clear']],
+ ['fontsize', ['fontsize']],
+ ['color', ['color']],
+ ['para', ['ul', 'ol', 'paragraph']],
+ ['table', ['table']],
+ ['insert', ['link', 'picture']],
+ ['history', ['undo', 'redo']],
+ ],
+ 'styleWithSpan': false,
+ 'inlinemedia' : ['p'],
+ 'lang': 'odoo',
+ 'onChange': function (html, $editable) {
+ $editable.trigger('content_changed');
+ },
+ 'colors': summernoteCustomColors,
+ };
+ },
+ /**
+ * Gets jQuery cloned element with internal text nodes escaped for XML
+ * storage.
+ *
+ * @private
+ * @param {jQuery} $el
+ * @return {jQuery}
+ */
+ _getEscapedElement: function ($el) {
+ var escaped_el = $el.clone();
+ var to_escape = escaped_el.find('*').addBack();
+ to_escape = to_escape.not(to_escape.filter('object,iframe,script,style,[data-oe-model][data-oe-model!="ir.ui.view"]').find('*').addBack());
+ to_escape.contents().each(function () {
+ if (this.nodeType === 3) {
+ this.nodeValue = $('<div />').text(this.nodeValue).html();
+ }
+ });
+ return escaped_el;
+ },
+ /**
+ * Saves one (dirty) element of the page.
+ *
+ * @private
+ * @param {jQuery} $el - the element to save
+ * @param {Object} context - the context to use for the saving rpc
+ * @param {boolean} [withLang=false]
+ * false if the lang must be omitted in the context (saving "master"
+ * page element)
+ */
+ _saveElement: function ($el, context, withLang) {
+ var viewID = $el.data('oe-id');
+ if (!viewID) {
+ return Promise.resolve();
+ }
+
+ return this._rpc({
+ model: 'ir.ui.view',
+ method: 'save',
+ args: [
+ viewID,
+ this._getEscapedElement($el).prop('outerHTML'),
+ $el.data('oe-xpath') || null,
+ ],
+ context: context,
+ }, withLang ? undefined : {
+ noContextKeys: 'lang',
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when any editable element is clicked -> Prevents default browser
+ * action for the element.
+ *
+ * @private
+ * @param {Event} e
+ */
+ _onClick: function (e) {
+ e.preventDefault();
+ },
+ /**
+ * Called when the mouse is pressed on the document -> activate element
+ * edition.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMousedown: function (ev) {
+ var $target = $(ev.target);
+ var $editable = $target.closest('.o_editable');
+ var isLink = $target.is('a');
+
+ if (this && this.$last && this.$last.length && this.$last[0] !== $target[0]) {
+ $('.o_editable_date_field_linked').removeClass('o_editable_date_field_linked');
+ }
+ if (!$editable.length || (!isLink && $.summernote.core.dom.isContentEditableFalse($target))) {
+ return;
+ }
+
+ // Removes strange _moz_abspos attribute when it appears. Cannot
+ // find another solution which works in all cases. A grabber still
+ // appears at the same time which I did not manage to remove.
+ // TODO find a complete and better solution
+ _.defer(function () {
+ $editable.find('[_moz_abspos]').removeAttr('_moz_abspos');
+ });
+
+ if (isLink && !$target.closest('.o_not_editable').length) {
+ /**
+ * Remove content editable everywhere and add it on the link only so that characters can be added
+ * and removed at the start and at the end of it.
+ */
+ let hasContentEditable = $target.attr('contenteditable');
+ $target.attr('contenteditable', true);
+ _.defer(function () {
+ $editable.not($target).attr('contenteditable', false);
+ $target.focus();
+ });
+
+ // Once clicked outside, remove contenteditable on link and reactive all
+ $(document).on('mousedown.reactivate_contenteditable', function (e) {
+ if ($target.is(e.target)) return;
+ if (!hasContentEditable) {
+ $target.removeAttr('contenteditable');
+ }
+ $editable.attr('contenteditable', true);
+ $(document).off('mousedown.reactivate_contenteditable');
+ });
+ }
+
+ if (this && this.$last && (!$editable.length || this.$last[0] !== $editable[0])) {
+ var $destroy = this.$last;
+ history.splitNext();
+ // In some special cases, we need to clear the timeout.
+ var lastTimerId = _.delay(function () {
+ var id = $destroy.data('note-id');
+ $destroy.destroy().removeData('note-id').removeAttr('data-note-id');
+ $('#note-popover-'+id+', #note-handle-'+id+', #note-dialog-'+id+'').remove();
+ }, 150); // setTimeout to remove flickering when change to editable zone (re-create an editor)
+ this.$last = null;
+ // for modal dialogs (eg newsletter popup), when we close the dialog, the modal is
+ // destroyed immediately and so after the delayed execution due to timeout, dialog will
+ // not be available, leading to trace-back, so we need to clearTimeout for the dialogs.
+ if ($destroy.hasClass('modal-body')) {
+ clearTimeout(lastTimerId);
+ }
+ }
+
+ if ($editable.length && (!this.$last || this.$last[0] !== $editable[0])) {
+ $editable.summernote(this._getConfig($editable));
+
+ $editable.data('NoteHistory', history);
+ this.$last = $editable;
+
+ // firefox & IE fix
+ try {
+ document.execCommand('enableObjectResizing', false, false);
+ document.execCommand('enableInlineTableEditing', false, false);
+ document.execCommand('2D-position', false, false);
+ } catch (e) { /* */ }
+ document.body.addEventListener('resizestart', function (evt) {evt.preventDefault(); return false;});
+ document.body.addEventListener('movestart', function (evt) {evt.preventDefault(); return false;});
+ document.body.addEventListener('dragstart', function (evt) {evt.preventDefault(); return false;});
+
+ if (!range.create()) {
+ $editable.focusIn();
+ }
+
+ if (dom.isImg($target[0])) {
+ $target.trigger('mousedown'); // for activate selection on picture
+ }
+
+ this._enableEditableArea($editable);
+ }
+ },
+ /**
+ * Called when the mouse is unpressed on the document.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseup: function (ev) {
+ var $target = $(ev.target);
+ var $editable = $target.closest('.o_editable');
+
+ if (!$editable.length) {
+ return;
+ }
+
+ var self = this;
+ _.defer(function () {
+ self.historyRecordUndo($target, 'activate', true);
+ });
+
+ // Browsers select different content from one to another after a
+ // triple click (especially: if triple-clicking on a paragraph on
+ // Chrome, blank characters of the element following the paragraph are
+ // selected too)
+ //
+ // The triple click behavior is reimplemented for all browsers here
+ if (ev.originalEvent && ev.originalEvent.detail === 3) {
+ // Select the whole content inside the deepest DOM element that was
+ // triple-clicked
+ range.create(ev.target, 0, ev.target, ev.target.childNodes.length).select();
+ }
+ },
+});
+
+return {
+ Class: RTEWidget,
+ history: history,
+};
+});
+
+odoo.define('web_editor.rte.summernote_custom_colors', function (require) {
+'use strict';
+
+// These colors are already normalized as per normalizeCSSColor in web.Colorpicker
+return [
+ ['#000000', '#424242', '#636363', '#9C9C94', '#CEC6CE', '#EFEFEF', '#F7F7F7', '#FFFFFF'],
+ ['#FF0000', '#FF9C00', '#FFFF00', '#00FF00', '#00FFFF', '#0000FF', '#9C00FF', '#FF00FF'],
+ ['#F7C6CE', '#FFE7CE', '#FFEFC6', '#D6EFD6', '#CEDEE7', '#CEE7F7', '#D6D6E7', '#E7D6DE'],
+ ['#E79C9C', '#FFC69C', '#FFE79C', '#B5D6A5', '#A5C6CE', '#9CC6EF', '#B5A5D6', '#D6A5BD'],
+ ['#E76363', '#F7AD6B', '#FFD663', '#94BD7B', '#73A5AD', '#6BADDE', '#8C7BC6', '#C67BA5'],
+ ['#CE0000', '#E79439', '#EFC631', '#6BA54A', '#4A7B8C', '#3984C6', '#634AA5', '#A54A7B'],
+ ['#9C0000', '#B56308', '#BD9400', '#397B21', '#104A5A', '#085294', '#311873', '#731842'],
+ ['#630000', '#7B3900', '#846300', '#295218', '#083139', '#003163', '#21104A', '#4A1031']
+];
+});
diff --git a/addons/web_editor/static/src/js/editor/rte.summernote.js b/addons/web_editor/static/src/js/editor/rte.summernote.js
new file mode 100644
index 00000000..76d86d47
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/rte.summernote.js
@@ -0,0 +1,1280 @@
+odoo.define('web_editor.rte.summernote', function (require) {
+'use strict';
+
+var Class = require('web.Class');
+const concurrency = require('web.concurrency');
+var core = require('web.core');
+// Use the top window's core.bus for dialog events so that they take the whole window
+// instead of being confined to an iframe. This means that the event triggered on
+// the bus by summernote in an iframe will be caught by the wysiwyg's SummernoteManager
+// outside the iframe.
+const topBus = window.top.odoo.__DEBUG__.services['web.core'].bus;
+const {ColorpickerWidget} = require('web.Colorpicker');
+var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget;
+var mixins = require('web.mixins');
+var fonts = require('wysiwyg.fonts');
+var rte = require('web_editor.rte');
+var ServicesMixin = require('web.ServicesMixin');
+var weWidgets = require('wysiwyg.widgets');
+
+var _t = core._t;
+
+// Summernote Lib (neek change to make accessible: method and object)
+var dom = $.summernote.core.dom;
+var range = $.summernote.core.range;
+var eventHandler = $.summernote.eventHandler;
+var renderer = $.summernote.renderer;
+
+// Summernote uses execCommand and, worth, obsolete queryCommandState function
+// to customize the edited content. Here we try to hack the function to solve
+// some problems by making the DOM and style easier to understand for the
+// base function for the duration of their executions. This won't obviously
+// solves all problems but this is an improvement while waiting for the new
+// Odoo editor coming in future versions.
+function protectCommand(callback) {
+ return function () {
+ var rng = range.create();
+ var $sc = (rng && rng.sc) ? $(rng.sc).parents(':o_editable').last() : $();
+ var $ec = (rng && rng.ec) ? $(rng.ec).parents(':o_editable').last() : $();
+ $sc.addClass('o_we_command_protector');
+ $ec.addClass('o_we_command_protector');
+ var restore = function () {
+ $sc.removeClass('o_we_command_protector');
+ $ec.removeClass('o_we_command_protector');
+ };
+ var result;
+ try {
+ result = callback.apply(this, arguments);
+ } catch (err) {
+ restore();
+ throw err;
+ }
+ restore();
+ return result;
+ };
+}
+document.execCommand = protectCommand(document.execCommand);
+document.queryCommandState = protectCommand(document.queryCommandState);
+
+var tplButton = renderer.getTemplate().button;
+var tplIconButton = renderer.getTemplate().iconButton;
+var tplDropdown = renderer.getTemplate().dropdown;
+
+const processAndApplyColor = function (target, eventName, color, preview) {
+ if (!color) {
+ color = 'inherit';
+ } else if (!ColorpickerWidget.isCSSColor(color)) {
+ color = (eventName === "foreColor" ? 'text-' : 'bg-') + color;
+ }
+ var layoutInfo = dom.makeLayoutInfo(target);
+ $.summernote.pluginEvents[eventName](undefined, eventHandler.modules.editor, layoutInfo, color, preview);
+};
+// Update and change the popovers content, and add history button
+renderer.createPalette = function ($container, options) {
+ const $dropdownContent = $container.find(".colorPalette");
+ // The editor's root widget can be website or web's root widget and cannot be properly retrieved...
+ const parent = odoo.__DEBUG__.services['root.widget'];
+ _.each($dropdownContent, elem => {
+ const eventName = elem.dataset.eventName;
+ let colorpicker = null;
+ const mutex = new concurrency.MutexedDropPrevious();
+ const $dropdown = $(elem).closest('.btn-group, .dropdown');
+ let manualOpening = false;
+ // Prevent dropdown closing on colorpicker click
+ $dropdown.on('hide.bs.dropdown', ev => {
+ return !(ev.clickEvent && ev.clickEvent.originalEvent && ev.clickEvent.originalEvent.__isColorpickerClick);
+ });
+ $dropdown.on('show.bs.dropdown', () => {
+ if (manualOpening) {
+ return true;
+ }
+ mutex.exec(() => {
+ const oldColorpicker = colorpicker;
+ const hookEl = oldColorpicker ? oldColorpicker.el : elem;
+
+ const r = range.create();
+ const targetNode = r.sc;
+ const targetElement = targetNode.nodeType === Node.ELEMENT_NODE ? targetNode : targetNode.parentNode;
+ colorpicker = new ColorPaletteWidget(parent, {
+ excluded: ['transparent_grayscale'],
+ $editable: rte.Class.prototype.editable(), // Our parent is the root widget, we can't retrieve the editable section from it...
+ selectedColor: $(targetElement).css(eventName === "foreColor" ? 'color' : 'backgroundColor'),
+ });
+ colorpicker.on('custom_color_picked color_picked', null, ev => {
+ processAndApplyColor(ev.data.target, eventName, ev.data.color);
+ });
+ colorpicker.on('color_hover color_leave', null, ev => {
+ processAndApplyColor(ev.data.target, eventName, ev.data.color, true);
+ });
+ colorpicker.on('enter_key_color_colorpicker', null, () => {
+ $dropdown.children('.dropdown-toggle').dropdown('hide');
+ });
+ return colorpicker.replace(hookEl).then(() => {
+ if (oldColorpicker) {
+ oldColorpicker.destroy();
+ }
+ manualOpening = true;
+ $dropdown.children('.dropdown-toggle').dropdown('show');
+ manualOpening = false;
+ });
+ });
+ return false;
+ });
+ });
+};
+
+var fn_tplPopovers = renderer.tplPopovers;
+renderer.tplPopovers = function (lang, options) {
+ var $popover = $(fn_tplPopovers.call(this, lang, options));
+
+ var $imagePopover = $popover.find('.note-image-popover');
+ var $linkPopover = $popover.find('.note-link-popover');
+ var $airPopover = $popover.find('.note-air-popover');
+
+ //////////////// image popover
+
+ // add center button for images
+ $(tplIconButton('fa fa-align-center', {
+ title: _t('Center'),
+ event: 'floatMe',
+ value: 'center'
+ })).insertAfter($imagePopover.find('[data-event="floatMe"][data-value="left"]'));
+ $imagePopover.find('button[data-event="removeMedia"]').parent().remove();
+ $imagePopover.find('button[data-event="floatMe"][data-value="none"]').remove();
+
+ // padding button
+ var $padding = $('<div class="btn-group"/>');
+ $padding.insertBefore($imagePopover.find('.btn-group:first'));
+ var dropdown_content = [
+ '<li><a class="dropdown-item" data-event="padding" href="#" data-value="">'+_t('None')+'</a></li>',
+ '<li><a class="dropdown-item" data-event="padding" href="#" data-value="small">'+_t('Small')+'</a></li>',
+ '<li><a class="dropdown-item" data-event="padding" href="#" data-value="medium">'+_t('Medium')+'</a></li>',
+ '<li><a class="dropdown-item" data-event="padding" href="#" data-value="large">'+_t('Large')+'</a></li>',
+ '<li><a class="dropdown-item" data-event="padding" href="#" data-value="xl">'+_t('Xl')+'</a></li>',
+ ];
+ $(tplIconButton('fa fa-plus-square-o', {
+ title: _t('Padding'),
+ dropdown: tplDropdown(dropdown_content)
+ })).appendTo($padding);
+
+ // circle, boxed... options became toggled
+ $imagePopover.find('[data-event="imageShape"]:not([data-value])').remove();
+ var $button = $(tplIconButton('fa fa-sun-o', {
+ title: _t('Shadow'),
+ event: 'imageShape',
+ value: 'shadow'
+ })).insertAfter($imagePopover.find('[data-event="imageShape"][data-value="rounded-circle"]'));
+
+ // add spin for fa
+ var $spin = $('<div class="btn-group d-none only_fa"/>').insertAfter($button.parent());
+ $(tplIconButton('fa fa-refresh', {
+ title: _t('Spin'),
+ event: 'imageShape',
+ value: 'fa-spin'
+ })).appendTo($spin);
+
+ // resize for fa
+ var $resizefa = $('<div class="btn-group d-none only_fa"/>')
+ .insertAfter($imagePopover.find('.btn-group:has([data-event="resize"])'));
+ for (var size=1; size<=5; size++) {
+ $(tplButton('<span class="note-fontsize-10">'+size+'x</span>', {
+ title: size+"x",
+ event: 'resizefa',
+ value: size+''
+ })).appendTo($resizefa);
+ }
+ var $colorfa = $airPopover.find('.note-color').clone();
+ $colorfa.find(".dropdown-menu").css('min-width', '172px');
+ $resizefa.after($colorfa);
+
+ // show dialog box and delete
+ var $imageprop = $('<div class="btn-group"/>');
+ $imageprop.appendTo($imagePopover.find('.popover-body'));
+ $(tplIconButton('fa fa-file-image-o', {
+ title: _t('Edit'),
+ event: 'showImageDialog'
+ })).appendTo($imageprop);
+ $(tplIconButton('fa fa-trash-o', {
+ title: _t('Remove'),
+ event: 'delete'
+ })).appendTo($imageprop);
+
+ $(tplIconButton('fa fa-crop', {
+ title: _t('Crop Image'),
+ event: 'cropImage',
+ })).insertAfter($imagePopover.find('[data-event="imageShape"][data-value="img-thumbnail"]'));
+
+ $imagePopover.find('.popover-body').append($airPopover.find(".note-history").clone());
+
+ $imagePopover.find('[data-event="showImageDialog"]').before($airPopover.find('[data-event="showLinkDialog"]').clone());
+
+ var $alt = $('<div class="btn-group"/>');
+ $alt.appendTo($imagePopover.find('.popover-body'));
+ $alt.append('<button class="btn btn-secondary" data-event="alt"><strong>' + _t('Description') + ': </strong><span class="o_image_alt"/></button>');
+
+ //////////////// link popover
+
+ $linkPopover.find('.popover-body').append($airPopover.find(".note-history").clone());
+
+ $linkPopover.find('button[data-event="showLinkDialog"] i').attr("class", "fa fa-link");
+ $linkPopover.find('button[data-event="unlink"]').before($airPopover.find('button[data-event="showImageDialog"]').clone());
+
+ //////////////// text/air popover
+
+ //// highlight the text format
+ $airPopover.find('.note-style .dropdown-toggle').on('mousedown', function () {
+ var $format = $airPopover.find('[data-event="formatBlock"]');
+ var node = range.create().sc;
+ var formats = $format.map(function () { return $(this).data("value"); }).get();
+ while (node && (!node.tagName || (!node.tagName || formats.indexOf(node.tagName.toLowerCase()) === -1))) {
+ node = node.parentNode;
+ }
+ $format.removeClass('active');
+ $format.filter('[data-value="'+(node ? node.tagName.toLowerCase() : "p")+'"]')
+ .addClass("active");
+ });
+
+ //////////////// tooltip
+
+ setTimeout(function () {
+ $airPopover.add($linkPopover).add($imagePopover).find("button")
+ .tooltip('dispose')
+ .tooltip({
+ container: 'body',
+ trigger: 'hover',
+ placement: 'bottom'
+ }).on('click', function () {$(this).tooltip('hide');});
+ });
+
+ return $popover;
+};
+
+var fn_boutton_update = eventHandler.modules.popover.button.update;
+eventHandler.modules.popover.button.update = function ($container, oStyle) {
+ // stop animation when edit content
+ var previous = $(".note-control-selection").data('target');
+ if (previous) {
+ var $previous = $(previous);
+ $previous.css({"-webkit-animation-play-state": "", "animation-play-state": "", "-webkit-transition": "", "transition": "", "-webkit-animation": "", "animation": ""});
+ $previous.find('.o_we_selected_image').addBack('.o_we_selected_image').removeClass('o_we_selected_image');
+ }
+ // end
+
+ fn_boutton_update.call(this, $container, oStyle);
+
+ $container.find('.note-color').removeClass('d-none');
+
+ if (oStyle.image) {
+ $container.find('[data-event]').removeClass("active");
+
+ $container.find('a[data-event="padding"][data-value="small"]').toggleClass("active", $(oStyle.image).hasClass("padding-small"));
+ $container.find('a[data-event="padding"][data-value="medium"]').toggleClass("active", $(oStyle.image).hasClass("padding-medium"));
+ $container.find('a[data-event="padding"][data-value="large"]').toggleClass("active", $(oStyle.image).hasClass("padding-large"));
+ $container.find('a[data-event="padding"][data-value="xl"]').toggleClass("active", $(oStyle.image).hasClass("padding-xl"));
+ $container.find('a[data-event="padding"][data-value=""]').toggleClass("active", !$container.find('li a.active[data-event="padding"]').length);
+
+ $(oStyle.image).addClass('o_we_selected_image');
+
+ if (dom.isImgFont(oStyle.image)) {
+ $container.find('.note-fore-color-preview > button > .caret').css('border-bottom-color', $(oStyle.image).css('color'));
+ $container.find('.note-back-color-preview > button > .caret').css('border-bottom-color', $(oStyle.image).css('background-color'));
+
+ $container.find('.btn-group:not(.only_fa):has(button[data-event="resize"],button[data-value="img-thumbnail"])').addClass('d-none');
+ $container.find('.only_fa').removeClass('d-none');
+ $container.find('button[data-event="resizefa"][data-value="2"]').toggleClass("active", $(oStyle.image).hasClass("fa-2x"));
+ $container.find('button[data-event="resizefa"][data-value="3"]').toggleClass("active", $(oStyle.image).hasClass("fa-3x"));
+ $container.find('button[data-event="resizefa"][data-value="4"]').toggleClass("active", $(oStyle.image).hasClass("fa-4x"));
+ $container.find('button[data-event="resizefa"][data-value="5"]').toggleClass("active", $(oStyle.image).hasClass("fa-5x"));
+ $container.find('button[data-event="resizefa"][data-value="1"]').toggleClass("active", !$container.find('.active[data-event="resizefa"]').length);
+ $container.find('button[data-event="cropImage"]').addClass('d-none');
+
+ $container.find('button[data-event="imageShape"][data-value="fa-spin"]').toggleClass("active", $(oStyle.image).hasClass("fa-spin"));
+ $container.find('button[data-event="imageShape"][data-value="shadow"]').toggleClass("active", $(oStyle.image).hasClass("shadow"));
+ $container.find('.btn-group:has(button[data-event="imageShape"])').removeClass("d-none");
+
+ } else {
+ $container.find('.d-none:not(.only_fa, .note-recent-color)').removeClass('d-none');
+ $container.find('button[data-event="cropImage"]').removeClass('d-none');
+ $container.find('.only_fa').addClass('d-none');
+ var width = ($(oStyle.image).attr('style') || '').match(/(^|;|\s)width:\s*([0-9]+%)/);
+ if (width) {
+ width = width[2];
+ }
+ $container.find('button[data-event="resize"][data-value="auto"]').toggleClass("active", width !== "100%" && width !== "50%" && width !== "25%");
+ $container.find('button[data-event="resize"][data-value="1"]').toggleClass("active", width === "100%");
+ $container.find('button[data-event="resize"][data-value="0.5"]').toggleClass("active", width === "50%");
+ $container.find('button[data-event="resize"][data-value="0.25"]').toggleClass("active", width === "25%");
+
+ $container.find('button[data-event="imageShape"][data-value="shadow"]').toggleClass("active", $(oStyle.image).hasClass("shadow"));
+
+ if (!$(oStyle.image).is("img")) {
+ $container.find('.btn-group:has(button[data-event="imageShape"])').addClass('d-none');
+ }
+
+ $container.find('.note-color').addClass('d-none');
+ }
+
+ $container.find('button[data-event="floatMe"][data-value="left"]').toggleClass("active", $(oStyle.image).hasClass("float-left"));
+ $container.find('button[data-event="floatMe"][data-value="center"]').toggleClass("active", $(oStyle.image).hasClass("d-block mx-auto"));
+ $container.find('button[data-event="floatMe"][data-value="right"]').toggleClass("active", $(oStyle.image).hasClass("float-right"));
+
+ $(oStyle.image).trigger('attributes_change');
+ } else {
+ $container.find('.note-fore-color-preview > button > .caret').css('border-bottom-color', oStyle.color);
+ $container.find('.note-back-color-preview > button > .caret').css('border-bottom-color', oStyle['background-color']);
+ }
+};
+
+var fn_toolbar_boutton_update = eventHandler.modules.toolbar.button.update;
+eventHandler.modules.toolbar.button.update = function ($container, oStyle) {
+ fn_toolbar_boutton_update.call(this, $container, oStyle);
+
+ $container.find('button[data-event="insertUnorderedList"]').toggleClass("active", $(oStyle.ancestors).is('ul:not(.o_checklist)'));
+ $container.find('button[data-event="insertOrderedList"]').toggleClass("active", $(oStyle.ancestors).is('ol'));
+ $container.find('button[data-event="insertCheckList"]').toggleClass("active", $(oStyle.ancestors).is('ul.o_checklist'));
+};
+
+var fn_popover_update = eventHandler.modules.popover.update;
+eventHandler.modules.popover.update = function ($popover, oStyle, isAirMode) {
+ var $imagePopover = $popover.find('.note-image-popover');
+ var $linkPopover = $popover.find('.note-link-popover');
+ var $airPopover = $popover.find('.note-air-popover');
+
+ fn_popover_update.call(this, $popover, oStyle, isAirMode);
+
+ if (oStyle.image) {
+ if (oStyle.image.parentNode.className.match(/(^|\s)media_iframe_video(\s|$)/i)) {
+ oStyle.image = oStyle.image.parentNode;
+ }
+ var alt = $(oStyle.image).attr("alt");
+
+ $imagePopover.find('.o_image_alt').text( (alt || "").replace(/&quot;/g, '"') ).parent().toggle(oStyle.image.tagName === "IMG");
+ $imagePopover.show();
+
+ // for video tag (non-void) we select the range over the tag,
+ // for other media types we get the first descendant leaf element
+ var target_node = oStyle.image;
+ if (!oStyle.image.className.match(/(^|\s)media_iframe_video(\s|$)/i)) {
+ target_node = dom.firstChild(target_node);
+ }
+ range.createFromNode(target_node).select();
+ // save range on the editor so it is not lost if restored
+ eventHandler.modules.editor.saveRange(dom.makeLayoutInfo(target_node).editable());
+ } else {
+ $(".note-control-selection").hide();
+ }
+
+ if (oStyle.image || (oStyle.range && (!oStyle.range.isCollapsed() || (oStyle.range.sc.tagName && !dom.isAnchor(oStyle.range.sc)))) || (oStyle.image && !$(oStyle.image).closest('a').length)) {
+ $linkPopover.hide();
+ oStyle.anchor = false;
+ }
+
+ if (oStyle.image || oStyle.anchor || (oStyle.range && !$(oStyle.range.sc).closest('.note-editable').length)) {
+ $airPopover.hide();
+ } else {
+ $airPopover.show();
+ }
+
+ const $externalHistoryButtons = $('.o_we_external_history_buttons');
+ if ($externalHistoryButtons.length) {
+ const $noteHistory = $('.note-history');
+ $noteHistory.addClass('d-none');
+ $externalHistoryButtons.find(':first-child').prop('disabled', $noteHistory.find('[data-event=undo]').prop('disabled'));
+ $externalHistoryButtons.find(':last-child').prop('disabled', $noteHistory.find('[data-event=redo]').prop('disabled'));
+ }
+ $popover.trigger('summernote_popover_update_call');
+};
+
+var fn_handle_update = eventHandler.modules.handle.update;
+eventHandler.modules.handle.update = function ($handle, oStyle, isAirMode) {
+ fn_handle_update.call(this, $handle, oStyle, isAirMode);
+ if (oStyle.image) {
+ $handle.find('.note-control-selection').hide();
+ }
+};
+
+// Hack for image and link editor
+function getImgTarget($editable) {
+ var $handle = $editable ? dom.makeLayoutInfo($editable).handle() : undefined;
+ return $(".note-control-selection", $handle).data('target');
+}
+eventHandler.modules.editor.padding = function ($editable, sValue) {
+ var $target = $(getImgTarget($editable));
+ var paddings = "small medium large xl".split(/\s+/);
+ $editable.data('NoteHistory').recordUndo();
+ if (sValue.length) {
+ paddings.splice(paddings.indexOf(sValue),1);
+ $target.toggleClass('padding-'+sValue);
+ }
+ $target.removeClass("padding-" + paddings.join(" padding-"));
+};
+eventHandler.modules.editor.resize = function ($editable, sValue) {
+ var $target = $(getImgTarget($editable));
+ $editable.data('NoteHistory').recordUndo();
+ var width = ($target.attr('style') || '').match(/(^|;|\s)width:\s*([0-9]+)%/);
+ if (width) {
+ width = width[2]/100;
+ }
+ $target.css('width', (width !== sValue && sValue !== "auto") ? (sValue * 100) + '%' : '');
+};
+eventHandler.modules.editor.resizefa = function ($editable, sValue) {
+ var $target = $(getImgTarget($editable));
+ $editable.data('NoteHistory').recordUndo();
+ $target.attr('class', $target.attr('class').replace(/\s*fa-[0-9]+x/g, ''));
+ if (+sValue > 1) {
+ $target.addClass('fa-'+sValue+'x');
+ }
+};
+eventHandler.modules.editor.floatMe = function ($editable, sValue) {
+ var $target = $(getImgTarget($editable));
+ $editable.data('NoteHistory').recordUndo();
+ switch (sValue) {
+ case 'center': $target.toggleClass('d-block mx-auto').removeClass('float-right float-left'); break;
+ case 'left': $target.toggleClass('float-left').removeClass('float-right d-block mx-auto'); break;
+ case 'right': $target.toggleClass('float-right').removeClass('float-left d-block mx-auto'); break;
+ }
+};
+eventHandler.modules.editor.imageShape = function ($editable, sValue) {
+ var $target = $(getImgTarget($editable));
+ $editable.data('NoteHistory').recordUndo();
+ $target.toggleClass(sValue);
+};
+
+eventHandler.modules.linkDialog.showLinkDialog = function ($editable, $dialog, linkInfo) {
+ $editable.data('range').select();
+ $editable.data('NoteHistory').recordUndo();
+
+ var commonAncestor = linkInfo.range.commonAncestor();
+ if (commonAncestor && commonAncestor.closest) {
+ var link = commonAncestor.closest('a');
+ linkInfo.className = link && link.className;
+ }
+
+ var def = new $.Deferred();
+ topBus.trigger('link_dialog_demand', {
+ $editable: $editable,
+ linkInfo: linkInfo,
+ onSave: function (linkInfo) {
+ linkInfo.range.select();
+ $editable.data('range', linkInfo.range);
+ def.resolve(linkInfo);
+ $editable.trigger('keyup');
+ $('.note-popover .note-link-popover').show();
+ },
+ onCancel: def.reject.bind(def),
+ });
+ return def;
+};
+var originalShowImageDialog = eventHandler.modules.imageDialog.showImageDialog;
+eventHandler.modules.imageDialog.showImageDialog = function ($editable) {
+ var options = $editable.closest('.o_editable, .note-editor').data('options');
+ if (options.disableFullMediaDialog) {
+ return originalShowImageDialog.apply(this, arguments);
+ }
+ var r = $editable.data('range');
+ if (r.sc.tagName && r.sc.childNodes.length) {
+ r.sc = r.sc.childNodes[r.so];
+ }
+ var media = $(r.sc).parents().addBack().filter(function (i, el) {
+ return dom.isImg(el);
+ })[0];
+ topBus.trigger('media_dialog_demand', {
+ $editable: $editable,
+ media: media,
+ options: {
+ onUpload: $editable.data('callbacks').onUpload,
+ noVideos: options && options.noVideos,
+ },
+ onSave: function (newMedia) {
+ if (!newMedia) {
+ return;
+ }
+ if (media) {
+ $(media).replaceWith(newMedia);
+ } else {
+ r.insertNode(newMedia);
+ }
+ },
+ });
+ return new $.Deferred().reject();
+};
+$.summernote.pluginEvents.alt = function (event, editor, layoutInfo, sorted) {
+ var $editable = layoutInfo.editable();
+ var $selection = layoutInfo.handle().find('.note-control-selection');
+ topBus.trigger('alt_dialog_demand', {
+ $editable: $editable,
+ media: $selection.data('target'),
+ });
+};
+$.summernote.pluginEvents.cropImage = function (event, editor, layoutInfo, sorted) {
+ var $editable = layoutInfo.editable();
+ var $selection = layoutInfo.handle().find('.note-control-selection');
+ topBus.trigger('crop_image_demand', {
+ $editable: $editable,
+ media: $selection.data('target'),
+ });
+};
+
+// Utils
+var fn_is_void = dom.isVoid || function () {};
+dom.isVoid = function (node) {
+ return fn_is_void(node) || dom.isImgFont(node) || (node && node.className && node.className.match(/(^|\s)media_iframe_video(\s|$)/i));
+};
+var fn_is_img = dom.isImg || function () {};
+dom.isImg = function (node) {
+ return fn_is_img(node) || dom.isImgFont(node) || (node && (node.nodeName === "IMG" || (node.className && node.className.match(/(^|\s)(media_iframe_video|o_image)(\s|$)/i)) ));
+};
+var fn_is_forbidden_node = dom.isForbiddenNode || function () {};
+dom.isForbiddenNode = function (node) {
+ if (node.tagName === "BR") {
+ return false;
+ }
+ return fn_is_forbidden_node(node) || $(node).is(".media_iframe_video");
+};
+var fn_is_img_font = dom.isImgFont || function () {};
+dom.isImgFont = function (node) {
+ if (fn_is_img_font(node)) return true;
+
+ var nodeName = node && node.nodeName.toUpperCase();
+ var className = (node && node.className || "");
+ if (node && (nodeName === "SPAN" || nodeName === "I") && className.length) {
+ var classNames = className.split(/\s+/);
+ for (var k=0; k<fonts.fontIcons.length; k++) {
+ if (_.intersection(fonts.fontIcons[k].alias, classNames).length) {
+ return true;
+ }
+ }
+ }
+ return false;
+};
+var fn_is_font = dom.isFont; // re-overwrite font to include theme icons
+dom.isFont = function (node) {
+ return fn_is_font(node) || dom.isImgFont(node);
+};
+
+var fn_visible = $.summernote.pluginEvents.visible;
+$.summernote.pluginEvents.visible = function (event, editor, layoutInfo) {
+ var res = fn_visible.apply(this, arguments);
+ var rng = range.create();
+ if (!rng) return res;
+ var $node = $(dom.node(rng.sc));
+ if (($node.is('[data-oe-type="html"]') || $node.is('[data-oe-field="arch"]')) &&
+ $node.hasClass("o_editable") &&
+ !$node[0].children.length &&
+ "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small pre th td span label".toUpperCase().indexOf($node[0].nodeName) === -1) {
+ var p = $('<p><br/></p>')[0];
+ $node.append( p );
+ range.createFromNode(p.firstChild).select();
+ }
+ return res;
+};
+
+function prettify_html(html) {
+ html = html.trim();
+ var result = '',
+ level = 0,
+ get_space = function (level) {
+ var i = level, space = '';
+ while (i--) space += ' ';
+ return space;
+ },
+ reg = /^<\/?(a|span|font|u|em|i|strong|b)(\s|>)/i,
+ inline_level = Infinity,
+ tokens = _.compact(_.flatten(_.map(html.split(/</), function (value) {
+ value = value.replace(/\s+/g, ' ').split(/>/);
+ value[0] = /\S/.test(value[0]) ? '<' + value[0] + '>' : '';
+ return value;
+ })));
+
+ // reduce => merge inline style + text
+
+ for (var i = 0, l = tokens.length; i < l; i++) {
+ var token = tokens[i];
+ var inline_tag = reg.test(token);
+ var inline = inline_tag || inline_level <= level;
+
+ if (token[0] === '<' && token[1] === '/') {
+ if (inline_tag && inline_level === level) {
+ inline_level = Infinity;
+ }
+ level--;
+ }
+
+ if (!inline && !/\S/.test(token)) {
+ continue;
+ }
+ if (!inline || (token[1] !== '/' && inline_level > level)) {
+ result += get_space(level);
+ }
+
+ if (token[0] === '<' && token[1] !== '/') {
+ level++;
+ if (inline_tag && inline_level > level) {
+ inline_level = level;
+ }
+ }
+
+ if (token.match(/^<(img|hr|br)/)) {
+ level--;
+ }
+
+ // don't trim inline content (which could change appearance)
+ if (!inline) {
+ token = token.trim();
+ }
+
+ result += token.replace(/\s+/, ' ');
+
+ if (inline_level > level) {
+ result += '\n';
+ }
+ }
+ return result;
+}
+
+/*
+ * This override when clicking on the 'Code View' button has two aims:
+ *
+ * - have our own code view implementation for FieldTextHtml
+ * - add an 'enable' paramater to call the function directly and allow us to
+ * disable (false) or enable (true) the code view mode.
+ */
+$.summernote.pluginEvents.codeview = function (event, editor, layoutInfo, enable) {
+ if (!layoutInfo) {
+ return;
+ }
+ if (layoutInfo.toolbar) {
+ // if editor inline (FieldTextHtmlSimple)
+ var is_activated = $.summernote.eventHandler.modules.codeview.isActivated(layoutInfo);
+ if (is_activated === enable) {
+ return;
+ }
+ return eventHandler.modules.codeview.toggle(layoutInfo);
+ } else {
+ // if editor iframe (FieldTextHtml)
+ var $editor = layoutInfo.editor();
+ var $textarea = $editor.prev('textarea');
+ if ($textarea.is('textarea') === enable) {
+ return;
+ }
+
+ if (!$textarea.length) {
+ // init and create texarea
+ var html = prettify_html($editor.prop("innerHTML"));
+ $editor.parent().css({
+ 'position': 'absolute',
+ 'top': 0,
+ 'bottom': 0,
+ 'left': 0,
+ 'right': 0
+ });
+ $textarea = $('<textarea/>').css({
+ 'margin': '0 -4px',
+ 'padding': '0 4px',
+ 'border': 0,
+ 'top': '51px',
+ 'left': '620px',
+ 'width': '100%',
+ 'font-family': 'sans-serif',
+ 'font-size': '13px',
+ 'height': '98%',
+ 'white-space': 'pre',
+ 'word-wrap': 'normal'
+ }).val(html).data('init', html);
+ $editor.before($textarea);
+ $editor.hide();
+ } else {
+ // save changes
+ $editor.prop('innerHTML', $textarea.val().replace(/\s*\n\s*/g, '')).trigger('content_changed');
+ $textarea.remove();
+ $editor.show();
+ }
+ }
+};
+
+// Fix ie and re-range to don't break snippet
+var last_div;
+var last_div_change;
+var last_editable;
+var initial_data = {};
+function reRangeSelectKey(event) {
+ initial_data.range = null;
+ if (event.shiftKey && event.keyCode >= 37 && event.keyCode <= 40 && !$(event.target).is("input, textarea, select")) {
+ var r = range.create();
+ if (r) {
+ var rng = r.reRange(event.keyCode <= 38);
+ if (r !== rng) {
+ rng.select();
+ }
+ }
+ }
+}
+function reRangeSelect(event, dx, dy) {
+ var r = range.create();
+ if (!r || r.isCollapsed()) return;
+
+ // check if the user move the caret on up or down
+ var data = r.reRange(dy < 0 || (dy === 0 && dx < 0));
+
+ if (data.sc !== r.sc || data.so !== r.so || data.ec !== r.ec || data.eo !== r.eo) {
+ setTimeout(function () {
+ data.select();
+ $(data.sc.parentNode).closest('.note-popover');
+ },0);
+ }
+
+ $(data.sc).closest('.o_editable').data('range', r);
+ return r;
+}
+function summernote_mouseup(event) {
+ if ($(event.target).closest("#web_editor-top-navbar, .note-popover").length) {
+ return;
+ }
+ // don't rerange if simple click
+ if (initial_data.event) {
+ var dx = event.clientX - (event.shiftKey && initial_data.rect ? initial_data.rect.left : initial_data.event.clientX);
+ var dy = event.clientY - (event.shiftKey && initial_data.rect ? initial_data.rect.top : initial_data.event.clientY);
+ if (10 < Math.pow(dx, 2)+Math.pow(dy, 2)) {
+ reRangeSelect(event, dx, dy);
+ }
+ }
+
+ if (!$(event.target).closest(".o_editable").length) {
+ return;
+ }
+ if (!initial_data.range || !event.shiftKey) {
+ setTimeout(function () {
+ initial_data.range = range.create();
+ },0);
+ }
+}
+var remember_selection;
+function summernote_mousedown(event) {
+ rte.history.splitNext();
+
+ var $editable = $(event.target).closest(".o_editable, .note-editor");
+ var r;
+
+ if (document.documentMode) {
+ summernote_ie_fix(event, function (node) { return node.tagName === "DIV" || node.tagName === "IMG" || (node.dataset && node.dataset.oeModel); });
+ } else if (last_div && event.target !== last_div) {
+ if (last_div.tagName === "A") {
+ summernote_ie_fix(event, function (node) { return node.dataset && node.dataset.oeModel; });
+ } else if ($editable.length) {
+ if (summernote_ie_fix(event, function (node) { return node.tagName === "A"; })) {
+ r = range.create();
+ r.select();
+ }
+ }
+ }
+
+ // restore range if range lost after clicking on non-editable area
+ try {
+ r = range.create();
+ } catch (e) {
+ // If this code is running inside an iframe-editor and that the range
+ // is outside of this iframe, this will fail as the iframe does not have
+ // the permission to check the outside content this way. In that case,
+ // we simply ignore the exception as it is as if there was no range.
+ return;
+ }
+ var editables = $(".o_editable[contenteditable], .note-editable[contenteditable]");
+ var r_editable = editables.has((r||{}).sc).addBack(editables.filter((r||{}).sc));
+ if (!r_editable.closest('.note-editor').is($editable) && !r_editable.filter('.o_editable').is(editables)) {
+ var saved_editable = editables.has((remember_selection||{}).sc);
+ if ($editable.length && !saved_editable.closest('.o_editable, .note-editor').is($editable)) {
+ remember_selection = range.create(dom.firstChild($editable[0]), 0);
+ } else if (!saved_editable.length) {
+ remember_selection = undefined;
+ }
+ if (remember_selection) {
+ try {
+ remember_selection.select();
+ } catch (e) {
+ console.warn(e);
+ }
+ }
+ } else if (r_editable.length) {
+ remember_selection = r;
+ }
+
+ initial_data.event = event;
+
+ // keep selection when click with shift
+ if (event.shiftKey && $editable.length) {
+ if (initial_data.range) {
+ initial_data.range.select();
+ }
+ var rect = r && r.getClientRects();
+ initial_data.rect = rect && rect.length ? rect[0] : { top: 0, left: 0 };
+ }
+}
+
+function summernote_ie_fix(event, pred) {
+ var editable;
+ var div;
+ var node = event.target;
+ while (node.parentNode) {
+ if (!div && pred(node)) {
+ div = node;
+ }
+ if (last_div !== node && (node.getAttribute('contentEditable')==='false' || node.className && (node.className.indexOf('o_not_editable') !== -1))) {
+ break;
+ }
+ if (node.className && node.className.indexOf('o_editable') !== -1) {
+ if (!div) {
+ div = node;
+ }
+ editable = node;
+ break;
+ }
+ node = node.parentNode;
+ }
+
+ if (!editable) {
+ $(last_div_change).removeAttr("contentEditable").removeProp("contentEditable");
+ $(last_editable).attr("contentEditable", "true").prop("contentEditable", "true");
+ last_div_change = null;
+ last_editable = null;
+ return;
+ }
+
+ if (div === last_div) {
+ return;
+ }
+
+ last_div = div;
+
+ $(last_div_change).removeAttr("contentEditable").removeProp("contentEditable");
+
+ if (last_editable !== editable) {
+ if ($(editable).is("[contentEditable='true']")) {
+ $(editable).removeAttr("contentEditable").removeProp("contentEditable");
+ last_editable = editable;
+ } else {
+ last_editable = null;
+ }
+ }
+ if (!$(div).attr("contentEditable") && !$(div).is("[data-oe-type='many2one'], [data-oe-type='contact']")) {
+ $(div).attr("contentEditable", "true").prop("contentEditable", "true");
+ last_div_change = div;
+ } else {
+ last_div_change = null;
+ }
+ return editable !== div ? div : null;
+}
+
+var fn_attach = eventHandler.attach;
+eventHandler.attach = function (oLayoutInfo, options) {
+ fn_attach.call(this, oLayoutInfo, options);
+
+ oLayoutInfo.editor().on('dragstart', 'img', function (e) { e.preventDefault(); });
+ $(document).on('mousedown', summernote_mousedown).on('mouseup', summernote_mouseup);
+ oLayoutInfo.editor().off('click').on('click', function (e) {e.preventDefault();}); // if the content editable is a link
+ oLayoutInfo.editor().find('.note-image-dialog').on('click', '.note-image-input', function (e) {
+ e.stopPropagation(); // let browser default happen for image file input
+ });
+
+ /**
+ * Open Media Dialog on double click on an image/video/icon.
+ * Shows a tooltip on click to say to the user he can double click.
+ */
+ create_dblclick_feature("img, .media_iframe_video, i.fa, span.fa, a.o_image", function () {
+ eventHandler.modules.imageDialog.show(oLayoutInfo);
+ });
+
+ /**
+ * Open Link Dialog on double click on a link/button.
+ * Shows a tooltip on click to say to the user he can double click.
+ */
+ create_dblclick_feature("a[href], a.btn, button.btn", function () {
+ eventHandler.modules.linkDialog.show(oLayoutInfo);
+ });
+
+ oLayoutInfo.editable().on('mousedown', function (e) {
+ if (dom.isImg(e.target) && dom.isContentEditable(e.target)) {
+ range.createFromNode(e.target).select();
+ }
+ });
+ $(document).on("keyup", reRangeSelectKey);
+
+ var clone_data = false;
+
+ if (options.model) {
+ oLayoutInfo.editable().data({'oe-model': options.model, 'oe-id': options.id});
+ }
+ if (options.getMediaDomain) {
+ oLayoutInfo.editable().data('oe-media-domain', options.getMediaDomain);
+ }
+
+ var $node = oLayoutInfo.editor();
+ if ($node.data('oe-model') || $node.data('oe-translation-id')) {
+ $node.on('content_changed', function () {
+ var $nodes = $('[data-oe-model], [data-oe-translation-id]')
+ .filter(function () { return this !== $node[0];});
+
+ if ($node.data('oe-model')) {
+ $nodes = $nodes.filter('[data-oe-model="'+$node.data('oe-model')+'"]')
+ .filter('[data-oe-id="'+$node.data('oe-id')+'"]')
+ .filter('[data-oe-field="'+$node.data('oe-field')+'"]');
+ }
+ if ($node.data('oe-translation-id')) $nodes = $nodes.filter('[data-oe-translation-id="'+$node.data('oe-translation-id')+'"]');
+ if ($node.data('oe-type')) $nodes = $nodes.filter('[data-oe-type="'+$node.data('oe-type')+'"]');
+ if ($node.data('oe-expression')) $nodes = $nodes.filter('[data-oe-expression="'+$node.data('oe-expression')+'"]');
+ if ($node.data('oe-xpath')) $nodes = $nodes.filter('[data-oe-xpath="'+$node.data('oe-xpath')+'"]');
+ if ($node.data('oe-contact-options')) $nodes = $nodes.filter('[data-oe-contact-options="'+$node.data('oe-contact-options')+'"]');
+
+ var nodes = $node.get();
+
+ if ($node.data('oe-type') === "many2one") {
+ $nodes = $nodes.add($('[data-oe-model]')
+ .filter(function () { return this !== $node[0] && nodes.indexOf(this) === -1; })
+ .filter('[data-oe-many2one-model="'+$node.data('oe-many2one-model')+'"]')
+ .filter('[data-oe-many2one-id="'+$node.data('oe-many2one-id')+'"]')
+ .filter('[data-oe-type="many2one"]'));
+
+ $nodes = $nodes.add($('[data-oe-model]')
+ .filter(function () { return this !== $node[0] && nodes.indexOf(this) === -1; })
+ .filter('[data-oe-model="'+$node.data('oe-many2one-model')+'"]')
+ .filter('[data-oe-id="'+$node.data('oe-many2one-id')+'"]')
+ .filter('[data-oe-field="name"]'));
+ }
+
+ if (!clone_data) {
+ clone_data = true;
+ $nodes.html(this.innerHTML);
+ clone_data = false;
+ }
+ });
+ }
+
+ var custom_toolbar = oLayoutInfo.toolbar ? oLayoutInfo.toolbar() : undefined;
+ var $toolbar = $(oLayoutInfo.popover()).add(custom_toolbar);
+ $('button[data-event="undo"], button[data-event="redo"]', $toolbar).attr('disabled', true);
+
+ $(oLayoutInfo.editor())
+ .add(oLayoutInfo.handle())
+ .add(oLayoutInfo.popover())
+ .add(custom_toolbar)
+ .on('click content_changed', function () {
+ $('button[data-event="undo"]', $toolbar).attr('disabled', !oLayoutInfo.editable().data('NoteHistory').hasUndo());
+ $('button[data-event="redo"]', $toolbar).attr('disabled', !oLayoutInfo.editable().data('NoteHistory').hasRedo());
+ });
+
+ function create_dblclick_feature(selector, callback) {
+ var show_tooltip = true;
+
+ oLayoutInfo.editor().on("dblclick", selector, function (e) {
+ var $target = $(e.target);
+ if (!dom.isContentEditable($target)) {
+ // Prevent edition of non editable parts
+ return;
+ }
+
+ show_tooltip = false;
+ callback();
+ e.stopImmediatePropagation();
+ });
+
+ oLayoutInfo.editor().on("click", selector, function (e) {
+ var $target = $(e.target);
+ if (!dom.isContentEditable($target)) {
+ // Prevent edition of non editable parts
+ return;
+ }
+
+ show_tooltip = true;
+ setTimeout(function () {
+ // Do not show tooltip on double-click and if there is already one
+ if (!show_tooltip || $target.attr('title') !== undefined) {
+ return;
+ }
+ $target.tooltip({title: _t('Double-click to edit'), trigger: 'manuel', container: 'body'}).tooltip('show');
+ setTimeout(function () {
+ $target.tooltip('dispose');
+ }, 800);
+ }, 400);
+ });
+ }
+};
+var fn_detach = eventHandler.detach;
+eventHandler.detach = function (oLayoutInfo, options) {
+ fn_detach.call(this, oLayoutInfo, options);
+ oLayoutInfo.editable().off('mousedown');
+ oLayoutInfo.editor().off("dragstart");
+ oLayoutInfo.editor().off('click');
+ $(document).off('mousedown', summernote_mousedown);
+ $(document).off('mouseup', summernote_mouseup);
+ oLayoutInfo.editor().off("dblclick");
+ $(document).off("keyup", reRangeSelectKey);
+};
+
+// Translation for odoo
+$.summernote.lang.odoo = {
+ font: {
+ bold: _t('Bold'),
+ italic: _t('Italic'),
+ underline: _t('Underline'),
+ strikethrough: _t('Strikethrough'),
+ subscript: _t('Subscript'),
+ superscript: _t('Superscript'),
+ clear: _t('Remove Font Style'),
+ height: _t('Line Height'),
+ name: _t('Font Family'),
+ size: _t('Font Size')
+ },
+ image: {
+ image: _t('File / Image'),
+ insert: _t('Insert Image'),
+ resizeFull: _t('Resize Full'),
+ resizeHalf: _t('Resize Half'),
+ resizeQuarter: _t('Resize Quarter'),
+ floatLeft: _t('Float Left'),
+ floatRight: _t('Float Right'),
+ floatNone: _t('Float None'),
+ dragImageHere: _t('Drag an image here'),
+ selectFromFiles: _t('Select from files'),
+ url: _t('Image URL'),
+ remove: _t('Remove Image')
+ },
+ link: {
+ link: _t('Link'),
+ insert: _t('Insert Link'),
+ unlink: _t('Unlink'),
+ edit: _t('Edit'),
+ textToDisplay: _t('Text to display'),
+ url: _t('To what URL should this link go?'),
+ openInNewWindow: _t('Open in new window')
+ },
+ video: {
+ video: _t('Video'),
+ videoLink: _t('Video Link'),
+ insert: _t('Insert Video'),
+ url: _t('Video URL?'),
+ providers: _t('(YouTube, Vimeo, Vine, Instagram, DailyMotion or Youku)')
+ },
+ table: {
+ table: _t('Table')
+ },
+ hr: {
+ insert: _t('Insert Horizontal Rule')
+ },
+ style: {
+ style: _t('Style'),
+ normal: _t('Normal'),
+ blockquote: _t('Quote'),
+ pre: _t('Code'),
+ small: _t('Small'),
+ h1: _t('Header 1'),
+ h2: _t('Header 2'),
+ h3: _t('Header 3'),
+ h4: _t('Header 4'),
+ h5: _t('Header 5'),
+ h6: _t('Header 6')
+ },
+ lists: {
+ unordered: _t('Unordered list'),
+ ordered: _t('Ordered list')
+ },
+ options: {
+ help: _t('Help'),
+ fullscreen: _t('Full Screen'),
+ codeview: _t('Code View')
+ },
+ paragraph: {
+ paragraph: _t('Paragraph'),
+ outdent: _t('Outdent'),
+ indent: _t('Indent'),
+ left: _t('Align left'),
+ center: _t('Align center'),
+ right: _t('Align right'),
+ justify: _t('Justify full')
+ },
+ color: {
+ custom: _t('Custom Color'),
+ background: _t('Background Color'),
+ foreground: _t('Font Color'),
+ transparent: _t('Transparent'),
+ setTransparent: _t('None'),
+ },
+ shortcut: {
+ shortcuts: _t('Keyboard shortcuts'),
+ close: _t('Close'),
+ textFormatting: _t('Text formatting'),
+ action: _t('Action'),
+ paragraphFormatting: _t('Paragraph formatting'),
+ documentStyle: _t('Document Style')
+ },
+ history: {
+ undo: _t('Undo'),
+ redo: _t('Redo')
+ }
+};
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+/**
+ * @todo get rid of this. This has been implemented as a fix to be able to
+ * instantiate media, link and alt dialogs outside the main editor: in the
+ * simple HTML fields and forum textarea.
+ */
+var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, ServicesMixin, {
+ /**
+ * @constructor
+ */
+ init: function (parent) {
+ mixins.EventDispatcherMixin.init.call(this);
+ this.setParent(parent);
+
+ topBus.on('alt_dialog_demand', this, this._onAltDialogDemand);
+ topBus.on('crop_image_demand', this, this._onCropImageDemand);
+ topBus.on('link_dialog_demand', this, this._onLinkDialogDemand);
+ topBus.on('media_dialog_demand', this, this._onMediaDialogDemand);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ mixins.EventDispatcherMixin.destroy.call(this);
+
+ topBus.off('alt_dialog_demand', this, this._onAltDialogDemand);
+ topBus.off('crop_image_demand', this, this._onCropImageDemand);
+ topBus.off('link_dialog_demand', this, this._onLinkDialogDemand);
+ topBus.off('media_dialog_demand', this, this._onMediaDialogDemand);
+ },
+
+ /**
+ * Create modified image attachments.
+ *
+ * @param {jQuery} $editable
+ * @returns {Promise}
+ */
+ saveModifiedImages: function ($editable) {
+ const defs = _.map($editable, async editableEl => {
+ const {oeModel: resModel, oeId: resId} = editableEl.dataset;
+ const proms = [...editableEl.querySelectorAll('.o_modified_image_to_save')].map(async el => {
+ const isBackground = !el.matches('img');
+ el.classList.remove('o_modified_image_to_save');
+ // Modifying an image always creates a copy of the original, even if
+ // it was modified previously, as the other modified image may be used
+ // elsewhere if the snippet was duplicated or was saved as a custom one.
+ const newAttachmentSrc = await this._rpc({
+ route: `/web_editor/modify_image/${el.dataset.originalId}`,
+ params: {
+ res_model: resModel,
+ res_id: parseInt(resId),
+ data: (isBackground ? el.dataset.bgSrc : el.getAttribute('src')).split(',')[1],
+ },
+ });
+ if (isBackground) {
+ $(el).css('background-image', `url('${newAttachmentSrc}')`);
+ delete el.dataset.bgSrc;
+ } else {
+ el.setAttribute('src', newAttachmentSrc);
+ }
+ });
+ return Promise.all(proms);
+ });
+ return Promise.all(defs);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a demand to open a alt dialog is received on the bus.
+ *
+ * @private
+ * @param {Object} data
+ */
+ _onAltDialogDemand: function (data) {
+ if (data.__alreadyDone) {
+ return;
+ }
+ data.__alreadyDone = true;
+ var altDialog = new weWidgets.AltDialog(this,
+ data.options || {},
+ data.media
+ );
+ if (data.onSave) {
+ altDialog.on('save', this, data.onSave);
+ }
+ if (data.onCancel) {
+ altDialog.on('cancel', this, data.onCancel);
+ }
+ altDialog.open();
+ },
+ /**
+ * Called when a demand to crop an image is received on the bus.
+ *
+ * @private
+ * @param {Object} data
+ */
+ _onCropImageDemand: function (data) {
+ if (data.__alreadyDone) {
+ return;
+ }
+ data.__alreadyDone = true;
+ new weWidgets.ImageCropWidget(this, data.media)
+ .appendTo(data.$editable.parent());
+ },
+ /**
+ * Called when a demand to open a link dialog is received on the bus.
+ *
+ * @private
+ * @param {Object} data
+ */
+ _onLinkDialogDemand: function (data) {
+ if (data.__alreadyDone) {
+ return;
+ }
+ data.__alreadyDone = true;
+ var linkDialog = new weWidgets.LinkDialog(this,
+ data.options || {},
+ data.$editable,
+ data.linkInfo
+ );
+ if (data.onSave) {
+ linkDialog.on('save', this, data.onSave);
+ }
+ if (data.onCancel) {
+ linkDialog.on('cancel', this, data.onCancel);
+ }
+ linkDialog.open();
+ },
+ /**
+ * Called when a demand to open a media dialog is received on the bus.
+ *
+ * @private
+ * @param {Object} data
+ */
+ _onMediaDialogDemand: function (data) {
+ if (data.__alreadyDone) {
+ return;
+ }
+ data.__alreadyDone = true;
+
+ const model = data.$editable.data('oe-model');
+ const field = data.$editable.data('oe-field');
+ const type = data.$editable.data('oe-type');
+ var mediaDialog = new weWidgets.MediaDialog(this,
+ _.extend({
+ res_model: model,
+ res_id: data.$editable.data('oe-id'),
+ domain: data.$editable.data('oe-media-domain'),
+ useMediaLibrary: field && (model === 'ir.ui.view' && field === 'arch' || type === 'html'),
+ }, data.options),
+ data.media
+ );
+ if (data.onSave) {
+ mediaDialog.on('save', this, data.onSave);
+ }
+ if (data.onCancel) {
+ mediaDialog.on('cancel', this, data.onCancel);
+ }
+ mediaDialog.open();
+ },
+});
+return SummernoteManager;
+});
diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js
new file mode 100644
index 00000000..dc232e1d
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/snippets.editor.js
@@ -0,0 +1,2776 @@
+odoo.define('web_editor.snippet.editor', function (require) {
+'use strict';
+
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var dom = require('web.dom');
+var Widget = require('web.Widget');
+var options = require('web_editor.snippets.options');
+var Wysiwyg = require('web_editor.wysiwyg');
+const {ColorPaletteWidget} = require('web_editor.ColorPalette');
+const SmoothScrollOnDrag = require('web/static/src/js/core/smooth_scroll_on_drag.js');
+const {getCSSVariableValue} = require('web_editor.utils');
+
+var _t = core._t;
+
+var globalSelector = {
+ closest: () => $(),
+ all: () => $(),
+ is: () => false,
+};
+
+/**
+ * Management of the overlay and option list for a snippet.
+ */
+var SnippetEditor = Widget.extend({
+ template: 'web_editor.snippet_overlay',
+ xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'],
+ events: {
+ 'click .oe_snippet_remove': '_onRemoveClick',
+ 'wheel': '_onMouseWheel',
+ },
+ custom_events: {
+ 'option_update': '_onOptionUpdate',
+ 'user_value_widget_request': '_onUserValueWidgetRequest',
+ 'snippet_option_update': '_onSnippetOptionUpdate', // TODO remove me in master
+ 'snippet_option_visibility_update': '_onSnippetOptionVisibilityUpdate',
+ },
+ layoutElementsSelector: [
+ '.o_we_shape',
+ '.o_we_bg_filter',
+ ].join(','),
+
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Element} target
+ * @param {Object} templateOptions
+ * @param {jQuery} $editable
+ * @param {Object} options
+ */
+ init: function (parent, target, templateOptions, $editable, options) {
+ this._super.apply(this, arguments);
+ this.options = options;
+ this.$editable = $editable;
+ this.ownerDocument = this.$editable[0].ownerDocument;
+ this.$body = $(this.ownerDocument.body);
+ this.$target = $(target);
+ this.$target.data('snippet-editor', this);
+ this.templateOptions = templateOptions;
+ this.isTargetParentEditable = false;
+ this.isTargetMovable = false;
+ this.$scrollingElement = $().getScrollingElement();
+
+ this.__isStarted = new Promise(resolve => {
+ this.__isStartedResolveFunc = resolve;
+ });
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var defs = [this._super.apply(this, arguments)];
+
+ // Initialize the associated options (see snippets.options.js)
+ defs.push(this._initializeOptions());
+ var $customize = this._customize$Elements[this._customize$Elements.length - 1];
+
+ this.isTargetParentEditable = this.$target.parent().is(':o_editable');
+ this.isTargetMovable = this.isTargetParentEditable && this.isTargetMovable;
+ this.isTargetRemovable = this.isTargetParentEditable && !this.$target.parent().is('[data-oe-type="image"]');
+
+ // Initialize move/clone/remove buttons
+ if (this.isTargetMovable) {
+ this.dropped = false;
+ const smoothScrollOptions = this.options.getScrollOptions({
+ jQueryDraggableOptions: {
+ cursorAt: {
+ left: 10,
+ top: 10
+ },
+ handle: '.o_move_handle',
+ helper: () => {
+ var $clone = this.$el.clone().css({width: '24px', height: '24px', border: 0});
+ $clone.appendTo(this.$body).removeClass('d-none');
+ return $clone;
+ },
+ start: this._onDragAndDropStart.bind(this),
+ stop: (...args) => {
+ // Delay our stop handler so that some summernote handlers
+ // which occur on mouseup (and are themself delayed) are
+ // executed first (this prevents the library to crash
+ // because our stop handler may change the DOM).
+ setTimeout(() => {
+ this._onDragAndDropStop(...args);
+ }, 0);
+ },
+ },
+ });
+ this.draggableComponent = new SmoothScrollOnDrag(this, this.$el, $().getScrollingElement(), smoothScrollOptions);
+ } else {
+ this.$('.o_overlay_move_options').addClass('d-none');
+ $customize.find('.oe_snippet_clone').addClass('d-none');
+ }
+
+ if (!this.isTargetRemovable) {
+ this.$el.add($customize).find('.oe_snippet_remove').addClass('d-none');
+ }
+
+ var _animationsCount = 0;
+ var postAnimationCover = _.throttle(() => this.cover(), 100);
+ this.$target.on('transitionstart.snippet_editor, animationstart.snippet_editor', () => {
+ // We cannot rely on the fact each transition/animation start will
+ // trigger a transition/animation end as the element may be removed
+ // from the DOM before or it could simply be an infinite animation.
+ //
+ // By simplicity, for each start, we add a delayed operation that
+ // will decrease the animation counter after a fixed duration and
+ // do the post animation cover if none is registered anymore.
+ _animationsCount++;
+ setTimeout(() => {
+ if (!--_animationsCount) {
+ postAnimationCover();
+ }
+ }, 500); // This delay have to be huge enough to take care of long
+ // animations which will not trigger an animation end event
+ // but if it is too small for some, this is the job of the
+ // animation creator to manually ask for a re-cover
+ });
+ // On top of what is explained above, do the post animation cover for
+ // each detected transition/animation end so that the user does not see
+ // a flickering when not needed.
+ this.$target.on('transitionend.snippet_editor, animationend.snippet_editor', postAnimationCover);
+
+ return Promise.all(defs).then(() => {
+ this.__isStartedResolveFunc(this);
+ });
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ // Before actually destroying a snippet editor, notify the parent
+ // about it so that it can update its list of alived snippet editors.
+ this.trigger_up('snippet_editor_destroyed');
+
+ this._super(...arguments);
+ this.$target.removeData('snippet-editor');
+ this.$target.off('.snippet_editor');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Checks whether the snippet options are shown or not.
+ *
+ * @returns {boolean}
+ */
+ areOptionsShown: function () {
+ const lastIndex = this._customize$Elements.length - 1;
+ return !!this._customize$Elements[lastIndex].parent().length;
+ },
+ /**
+ * Notifies all the associated snippet options that the snippet has just
+ * been dropped in the page.
+ */
+ buildSnippet: async function () {
+ for (var i in this.styles) {
+ this.styles[i].onBuilt();
+ }
+ await this.toggleTargetVisibility(true);
+ },
+ /**
+ * Notifies all the associated snippet options that the template which
+ * contains the snippet is about to be saved.
+ */
+ cleanForSave: async function () {
+ if (this.isDestroyed()) {
+ return;
+ }
+ await this.toggleTargetVisibility(!this.$target.hasClass('o_snippet_invisible'));
+ const proms = _.map(this.styles, option => {
+ return option.cleanForSave();
+ });
+ await Promise.all(proms);
+ },
+ /**
+ * Closes all widgets of all options.
+ */
+ closeWidgets: function () {
+ if (!this.styles || !this.areOptionsShown()) {
+ return;
+ }
+ Object.keys(this.styles).forEach(key => {
+ this.styles[key].closeWidgets();
+ });
+ },
+ /**
+ * Makes the editor overlay cover the associated snippet.
+ */
+ cover: function () {
+ if (!this.isShown() || !this.$target.length) {
+ return;
+ }
+
+ const $modal = this.$target.find('.modal');
+ const $target = $modal.length ? $modal : this.$target;
+ const targetEl = $target[0];
+
+ // Check first if the target is still visible, otherwise we have to
+ // hide it. When covering all element after scroll for instance it may
+ // have been hidden (part of an affixed header for example) or it may
+ // be outside of the viewport (the whole header during an effect for
+ // example).
+ const rect = targetEl.getBoundingClientRect();
+ const vpWidth = window.innerWidth || document.documentElement.clientWidth;
+ const vpHeight = window.innerHeight || document.documentElement.clientHeight;
+ const isInViewport = (
+ rect.bottom > -0.1 &&
+ rect.right > -0.1 &&
+ (vpHeight - rect.top) > -0.1 &&
+ (vpWidth - rect.left) > -0.1
+ );
+ const hasSize = ( // :visible not enough for images
+ Math.abs(rect.bottom - rect.top) > 0.01 &&
+ Math.abs(rect.right - rect.left) > 0.01
+ );
+ if (!isInViewport || !hasSize || !this.$target.is(`:visible`)) {
+ this.toggleOverlayVisibility(false);
+ return;
+ }
+
+ // Now cover the element
+ const offset = $target.offset();
+ var manipulatorOffset = this.$el.parent().offset();
+ offset.top -= manipulatorOffset.top;
+ offset.left -= manipulatorOffset.left;
+ this.$el.css({
+ width: $target.outerWidth(),
+ left: offset.left,
+ top: offset.top,
+ });
+ this.$('.o_handles').css('height', $target.outerHeight());
+
+ const editableOffsetTop = this.$editable.offset().top - manipulatorOffset.top;
+ this.$el.toggleClass('o_top_cover', offset.top - editableOffsetTop < 25);
+ },
+ /**
+ * DOMElements have a default name which appears in the overlay when they
+ * are being edited. This method retrieves this name; it can be defined
+ * directly in the DOM thanks to the `data-name` attribute.
+ */
+ getName: function () {
+ if (this.$target.data('name') !== undefined) {
+ return this.$target.data('name');
+ }
+ if (this.$target.is('img')) {
+ return _t("Image");
+ }
+ if (this.$target.parent('.row').length) {
+ return _t("Column");
+ }
+ return _t("Block");
+ },
+ /**
+ * @return {boolean}
+ */
+ isShown: function () {
+ return this.$el && this.$el.parent().length && this.$el.hasClass('oe_active');
+ },
+ /**
+ * @returns {boolean}
+ */
+ isSticky: function () {
+ return this.$el && this.$el.hasClass('o_we_overlay_sticky');
+ },
+ /**
+ * @returns {boolean}
+ */
+ isTargetVisible: function () {
+ return (this.$target[0].dataset.invisible !== '1');
+ },
+ /**
+ * Removes the associated snippet from the DOM and destroys the associated
+ * editor (itself).
+ *
+ * @returns {Promise}
+ */
+ removeSnippet: async function () {
+ this.toggleOverlay(false);
+ await this.toggleOptions(false);
+ // If it is an invisible element, we must close it before deleting it (e.g. modal)
+ await this.toggleTargetVisibility(!this.$target.hasClass('o_snippet_invisible'));
+
+ await new Promise(resolve => {
+ this.trigger_up('call_for_each_child_snippet', {
+ $snippet: this.$target,
+ callback: function (editor, $snippet) {
+ for (var i in editor.styles) {
+ editor.styles[i].onRemove();
+ }
+ resolve();
+ },
+ });
+ });
+
+ this.trigger_up('go_to_parent', {$snippet: this.$target});
+ var $parent = this.$target.parent();
+ this.$target.find('*').addBack().tooltip('dispose');
+ this.$target.remove();
+ this.$el.remove();
+
+ var node = $parent[0];
+ if (node && node.firstChild) {
+ if (!node.firstChild.tagName && node.firstChild.textContent === ' ') {
+ node.removeChild(node.firstChild);
+ }
+ }
+
+ if ($parent.closest(':data("snippet-editor")').length) {
+ const isEmptyAndRemovable = ($el, editor) => {
+ editor = editor || $el.data('snippet-editor');
+ const isEmpty = $el.text().trim() === ''
+ && $el.children().toArray().every(el => {
+ // Consider layout-only elements (like bg-shapes) as empty
+ return el.matches(this.layoutElementsSelector);
+ });
+ return isEmpty && !$el.hasClass('oe_structure')
+ && (!editor || editor.isTargetParentEditable);
+ };
+
+ var editor = $parent.data('snippet-editor');
+ while (!editor) {
+ var $nextParent = $parent.parent();
+ if (isEmptyAndRemovable($parent)) {
+ $parent.remove();
+ }
+ $parent = $nextParent;
+ editor = $parent.data('snippet-editor');
+ }
+ if (isEmptyAndRemovable($parent, editor)) {
+ // TODO maybe this should be part of the actual Promise being
+ // returned by the function ?
+ setTimeout(() => editor.removeSnippet());
+ }
+ }
+
+ // clean editor if they are image or table in deleted content
+ this.$body.find('.note-control-selection').hide();
+ this.$body.find('.o_table_handler').remove();
+
+ this.trigger_up('snippet_removed');
+ this.destroy();
+ $parent.trigger('content_changed');
+ // TODO Page content changed, some elements may need to be adapted
+ // according to it. While waiting for a better way to handle that this
+ // window trigger will handle most cases.
+ $(window).trigger('resize');
+ },
+ /**
+ * Displays/Hides the editor overlay.
+ *
+ * @param {boolean} show
+ * @param {boolean} [previewMode=false]
+ */
+ toggleOverlay: function (show, previewMode) {
+ if (!this.$el) {
+ return;
+ }
+
+ if (previewMode) {
+ // In preview mode, the sticky classes are left untouched, we only
+ // add/remove the preview class when toggling/untoggling
+ this.$el.toggleClass('o_we_overlay_preview', show);
+ } else {
+ // In non preview mode, the preview class is always removed, and the
+ // sticky class is added/removed when toggling/untoggling
+ this.$el.removeClass('o_we_overlay_preview');
+ this.$el.toggleClass('o_we_overlay_sticky', show);
+ }
+
+ // Show/hide overlay in preview mode or not
+ this.$el.toggleClass('oe_active', show);
+ this.cover();
+ },
+ /**
+ * Displays/Hides the editor (+ parent) options and call onFocus/onBlur if
+ * necessary.
+ *
+ * @param {boolean} show
+ * @returns {Promise}
+ */
+ async toggleOptions(show) {
+ if (!this.$el) {
+ return;
+ }
+
+ if (this.areOptionsShown() === show) {
+ return;
+ }
+ // TODO should update the panel after the items have been updated
+ this.trigger_up('update_customize_elements', {
+ customize$Elements: show ? this._customize$Elements : [],
+ });
+ // All onFocus before all ui updates as the onFocus of an option might
+ // affect another option (like updating the $target)
+ const editorUIsToUpdate = [];
+ const focusOrBlur = show
+ ? (editor, options) => {
+ for (const opt of options) {
+ opt.onFocus();
+ }
+ editorUIsToUpdate.push(editor);
+ }
+ : (editor, options) => {
+ for (const opt of options) {
+ opt.onBlur();
+ }
+ };
+ for (const $el of this._customize$Elements) {
+ const editor = $el.data('editor');
+ const styles = _.chain(editor.styles)
+ .values()
+ .sortBy('__order')
+ .value();
+ // TODO ideally: allow async parts in onFocus/onBlur
+ focusOrBlur(editor, styles);
+ }
+ await Promise.all(editorUIsToUpdate.map(editor => editor.updateOptionsUI()));
+ await Promise.all(editorUIsToUpdate.map(editor => editor.updateOptionsUIVisibility()));
+ },
+ /**
+ * @param {boolean} [show]
+ * @returns {Promise<boolean>}
+ */
+ toggleTargetVisibility: async function (show) {
+ show = this._toggleVisibilityStatus(show);
+ var styles = _.values(this.styles);
+ const proms = _.sortBy(styles, '__order').map(style => {
+ return show ? style.onTargetShow() : style.onTargetHide();
+ });
+ await Promise.all(proms);
+ return show;
+ },
+ /**
+ * @param {boolean} [show=false]
+ */
+ toggleOverlayVisibility: function (show) {
+ if (this.$el && !this.scrollingTimeout) {
+ this.$el.toggleClass('o_overlay_hidden', !show && this.isShown());
+ }
+ },
+ /**
+ * Updates the UI of all the options according to the status of their
+ * associated editable DOM. This does not take care of options *visibility*.
+ * For that @see updateOptionsUIVisibility, which should called when the UI
+ * is up-to-date thanks to the function here, as the visibility depends on
+ * the UI's status.
+ *
+ * @returns {Promise}
+ */
+ async updateOptionsUI() {
+ const proms = Object.values(this.styles).map(opt => {
+ return opt.updateUI({noVisibility: true});
+ });
+ return Promise.all(proms);
+ },
+ /**
+ * Updates the visibility of the UI of all the options according to the
+ * status of their associated dependencies and related editable DOM status.
+ *
+ * @returns {Promise}
+ */
+ async updateOptionsUIVisibility() {
+ const proms = Object.values(this.styles).map(opt => {
+ return opt.updateUIVisibility();
+ });
+ return Promise.all(proms);
+ },
+ /**
+ * Clones the current snippet.
+ *
+ * @private
+ * @param {boolean} recordUndo
+ */
+ clone: async function (recordUndo) {
+ this.trigger_up('snippet_will_be_cloned', {$target: this.$target});
+
+ var $clone = this.$target.clone(false);
+
+ if (recordUndo) {
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+ }
+
+ this.$target.after($clone);
+ await new Promise(resolve => {
+ this.trigger_up('call_for_each_child_snippet', {
+ $snippet: $clone,
+ callback: function (editor, $snippet) {
+ for (var i in editor.styles) {
+ editor.styles[i].onClone({
+ isCurrent: ($snippet.is($clone)),
+ });
+ }
+ resolve();
+ },
+ });
+ });
+ this.trigger_up('snippet_cloned', {$target: $clone, $origin: this.$target});
+
+ $clone.trigger('content_changed');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Instantiates the snippet's options.
+ *
+ * @private
+ */
+ _initializeOptions: function () {
+ this._customize$Elements = [];
+ this.styles = {};
+ this.selectorSiblings = [];
+ this.selectorChildren = [];
+
+ var $element = this.$target.parent();
+ while ($element.length) {
+ var parentEditor = $element.data('snippet-editor');
+ if (parentEditor) {
+ this._customize$Elements = this._customize$Elements
+ .concat(parentEditor._customize$Elements);
+ break;
+ }
+ $element = $element.parent();
+ }
+
+ var $optionsSection = $(core.qweb.render('web_editor.customize_block_options_section', {
+ name: this.getName(),
+ })).data('editor', this);
+ const $optionsSectionBtnGroup = $optionsSection.find('we-top-button-group');
+ $optionsSectionBtnGroup.contents().each((i, node) => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ node.parentNode.removeChild(node);
+ }
+ });
+ $optionsSection.on('mouseenter', this._onOptionsSectionMouseEnter.bind(this));
+ $optionsSection.on('mouseleave', this._onOptionsSectionMouseLeave.bind(this));
+ $optionsSection.on('click', 'we-title > span', this._onOptionsSectionClick.bind(this));
+ $optionsSection.on('click', '.oe_snippet_clone', this._onCloneClick.bind(this));
+ $optionsSection.on('click', '.oe_snippet_remove', this._onRemoveClick.bind(this));
+ this._customize$Elements.push($optionsSection);
+
+ // TODO get rid of this when possible (made as a fix to support old
+ // theme options)
+ this.$el.data('$optionsSection', $optionsSection);
+
+ var i = 0;
+ var defs = _.map(this.templateOptions, val => {
+ if (!val.selector.is(this.$target)) {
+ return;
+ }
+ if (val['drop-near']) {
+ this.selectorSiblings.push(val['drop-near']);
+ }
+ if (val['drop-in']) {
+ this.selectorChildren.push(val['drop-in']);
+ }
+
+ var optionName = val.option;
+ var option = new (options.registry[optionName] || options.Class)(
+ this,
+ val.$el.children(),
+ val.base_target ? this.$target.find(val.base_target).eq(0) : this.$target,
+ this.$el,
+ _.extend({
+ optionName: optionName,
+ snippetName: this.getName(),
+ }, val.data),
+ this.options
+ );
+ var key = optionName || _.uniqueId('option');
+ if (this.styles[key]) {
+ // If two snippet options use the same option name (and so use
+ // the same JS option), store the subsequent ones with a unique
+ // ID (TODO improve)
+ key = _.uniqueId(key);
+ }
+ this.styles[key] = option;
+ option.__order = i++;
+
+ if (option.forceNoDeleteButton) {
+ this.$el.add($optionsSection).find('.oe_snippet_remove').addClass('d-none');
+ }
+
+ return option.appendTo(document.createDocumentFragment());
+ });
+
+ this.isTargetMovable = (this.selectorSiblings.length > 0 || this.selectorChildren.length > 0);
+
+ this.$el.find('[data-toggle="dropdown"]').dropdown();
+
+ return Promise.all(defs).then(() => {
+ const options = _.sortBy(this.styles, '__order');
+ options.forEach(option => {
+ if (option.isTopOption) {
+ $optionsSectionBtnGroup.prepend(option.$el);
+ } else {
+ $optionsSection.append(option.$el);
+ }
+ });
+ $optionsSection.toggleClass('d-none', options.length === 0);
+ });
+ },
+ /**
+ * @private
+ * @param {boolean} [show]
+ */
+ _toggleVisibilityStatus: function (show) {
+ if (show === undefined) {
+ show = !this.isTargetVisible();
+ }
+ if (show) {
+ delete this.$target[0].dataset.invisible;
+ } else {
+ this.$target[0].dataset.invisible = '1';
+ }
+ return show;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the 'clone' button is clicked.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onCloneClick: function (ev) {
+ ev.preventDefault();
+ this.clone(true);
+ },
+ /**
+ * Called when the snippet is starting to be dragged thanks to the 'move'
+ * button.
+ *
+ * @private
+ */
+ _onDragAndDropStart: function () {
+ var self = this;
+ this.dropped = false;
+ self.size = {
+ width: self.$target.width(),
+ height: self.$target.height()
+ };
+ self.$target.after('<div class="oe_drop_clone" style="display: none;"/>');
+ self.$target.detach();
+ self.$el.addClass('d-none');
+
+ var $selectorSiblings;
+ for (var i = 0; i < self.selectorSiblings.length; i++) {
+ if (!$selectorSiblings) {
+ $selectorSiblings = self.selectorSiblings[i].all();
+ } else {
+ $selectorSiblings = $selectorSiblings.add(self.selectorSiblings[i].all());
+ }
+ }
+ var $selectorChildren;
+ for (i = 0; i < self.selectorChildren.length; i++) {
+ if (!$selectorChildren) {
+ $selectorChildren = self.selectorChildren[i].all();
+ } else {
+ $selectorChildren = $selectorChildren.add(self.selectorChildren[i].all());
+ }
+ }
+
+ this.trigger_up('go_to_parent', {$snippet: this.$target});
+ this.trigger_up('activate_insertion_zones', {
+ $selectorSiblings: $selectorSiblings,
+ $selectorChildren: $selectorChildren,
+ });
+
+ this.$body.addClass('move-important');
+
+ this.$editable.find('.oe_drop_zone').droppable({
+ over: function () {
+ if (self.dropped) {
+ self.$target.detach();
+ $('.oe_drop_zone').removeClass('invisible');
+ }
+ self.dropped = true;
+ $(this).first().after(self.$target).addClass('invisible');
+ },
+ out: function () {
+ var prev = self.$target.prev();
+ if (this === prev[0]) {
+ self.dropped = false;
+ self.$target.detach();
+ $(this).removeClass('invisible');
+ }
+ },
+ });
+
+ // If a modal is open, the scroll target must be that modal
+ const $openModal = self.$editable.find('.modal:visible');
+ self.draggableComponent.$scrollTarget = $openModal.length ? $openModal : self.$scrollingElement;
+
+ // Trigger a scroll on the draggable element so that jQuery updates
+ // the position of the drop zones.
+ self.draggableComponent.$scrollTarget.on('scroll.scrolling_element', function () {
+ self.$el.trigger('scroll');
+ });
+ },
+ /**
+ * Called when the snippet is dropped after being dragged thanks to the
+ * 'move' button.
+ *
+ * @private
+ * @param {Event} ev
+ * @param {Object} ui
+ */
+ _onDragAndDropStop: function (ev, ui) {
+ // TODO lot of this is duplicated code of the d&d feature of snippets
+ if (!this.dropped) {
+ var $el = $.nearest({x: ui.position.left, y: ui.position.top}, '.oe_drop_zone', {container: document.body}).first();
+ if ($el.length) {
+ $el.after(this.$target);
+ this.dropped = true;
+ }
+ }
+
+ this.$editable.find('.oe_drop_zone').droppable('destroy').remove();
+
+ var prev = this.$target.first()[0].previousSibling;
+ var next = this.$target.last()[0].nextSibling;
+ var $parent = this.$target.parent();
+
+ var $clone = this.$editable.find('.oe_drop_clone');
+ if (prev === $clone[0]) {
+ prev = $clone[0].previousSibling;
+ } else if (next === $clone[0]) {
+ next = $clone[0].nextSibling;
+ }
+ $clone.after(this.$target);
+ var $from = $clone.parent();
+
+ this.$el.removeClass('d-none');
+ this.$body.removeClass('move-important');
+ $clone.remove();
+
+ if (this.dropped) {
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+
+ if (prev) {
+ this.$target.insertAfter(prev);
+ } else if (next) {
+ this.$target.insertBefore(next);
+ } else {
+ $parent.prepend(this.$target);
+ }
+
+ for (var i in this.styles) {
+ this.styles[i].onMove();
+ }
+
+ this.$target.trigger('content_changed');
+ $from.trigger('content_changed');
+ }
+
+ this.trigger_up('drag_and_drop_stop', {
+ $snippet: this.$target,
+ });
+ this.draggableComponent.$scrollTarget.off('scroll.scrolling_element');
+ },
+ /**
+ * @private
+ */
+ _onOptionsSectionMouseEnter: function (ev) {
+ if (!this.$target.is(':visible')) {
+ return;
+ }
+ this.trigger_up('activate_snippet', {
+ $snippet: this.$target,
+ previewMode: true,
+ });
+ },
+ /**
+ * @private
+ */
+ _onOptionsSectionMouseLeave: function (ev) {
+ this.trigger_up('activate_snippet', {
+ $snippet: false,
+ previewMode: true,
+ });
+ },
+ /**
+ * @private
+ */
+ _onOptionsSectionClick: function (ev) {
+ this.trigger_up('activate_snippet', {
+ $snippet: this.$target,
+ previewMode: false,
+ });
+ },
+ /**
+ * Called when a child editor/option asks for another option to perform a
+ * specific action/react to a specific event.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onOptionUpdate: function (ev) {
+ var self = this;
+
+ // If multiple option names are given, we suppose it should not be
+ // propagated to parent editor
+ if (ev.data.optionNames) {
+ ev.stopPropagation();
+ _.each(ev.data.optionNames, function (name) {
+ notifyForEachMatchedOption(name);
+ });
+ }
+ // If one option name is given, we suppose it should be handle by the
+ // first parent editor which can do it
+ if (ev.data.optionName) {
+ if (notifyForEachMatchedOption(ev.data.optionName)) {
+ ev.stopPropagation();
+ }
+ }
+
+ function notifyForEachMatchedOption(name) {
+ var regex = new RegExp('^' + name + '\\d+$');
+ var hasOption = false;
+ for (var key in self.styles) {
+ if (key === name || regex.test(key)) {
+ self.styles[key].notify(ev.data.name, ev.data.data);
+ hasOption = true;
+ }
+ }
+ return hasOption;
+ }
+ },
+ /**
+ * Called when the 'remove' button is clicked.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onRemoveClick: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+ this.removeSnippet();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetOptionUpdate: async function (ev) {
+ // TODO remove me in master
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetOptionVisibilityUpdate: function (ev) {
+ ev.data.show = this._toggleVisibilityStatus(ev.data.show);
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onUserValueWidgetRequest: function (ev) {
+ ev.stopPropagation();
+ for (const key of Object.keys(this.styles)) {
+ const widget = this.styles[key].findWidget(ev.data.name);
+ if (widget) {
+ ev.data.onSuccess(widget);
+ return;
+ }
+ }
+ },
+ /**
+ * Called when the 'mouse wheel' is used when hovering over the overlay.
+ * Disable the pointer events to prevent page scrolling from stopping.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseWheel: function (ev) {
+ ev.stopPropagation();
+ this.$el.css('pointer-events', 'none');
+ clearTimeout(this.wheelTimeout);
+ this.wheelTimeout = setTimeout(() => {
+ this.$el.css('pointer-events', '');
+ }, 250);
+ },
+});
+
+/**
+ * Management of drag&drop menu and snippet related behaviors in the page.
+ */
+var SnippetsMenu = Widget.extend({
+ id: 'oe_snippets',
+ cacheSnippetTemplate: {},
+ events: {
+ 'click .oe_snippet': '_onSnippetClick',
+ 'click .o_install_btn': '_onInstallBtnClick',
+ 'click .o_we_add_snippet_btn': '_onBlocksTabClick',
+ 'click .o_we_invisible_entry': '_onInvisibleEntryClick',
+ 'click #snippet_custom .o_delete_btn': '_onDeleteBtnClick',
+ 'mousedown': '_onMouseDown',
+ 'input .o_snippet_search_filter_input': '_onSnippetSearchInput',
+ 'click .o_snippet_search_filter_reset': '_onSnippetSearchResetClick',
+ 'summernote_popover_update_call .o_we_snippet_text_tools': '_onSummernoteToolsUpdate',
+ },
+ custom_events: {
+ 'activate_insertion_zones': '_onActivateInsertionZones',
+ 'activate_snippet': '_onActivateSnippet',
+ 'call_for_each_child_snippet': '_onCallForEachChildSnippet',
+ 'clone_snippet': '_onCloneSnippet',
+ 'cover_update': '_onOverlaysCoverUpdate',
+ 'deactivate_snippet': '_onDeactivateSnippet',
+ 'drag_and_drop_stop': '_onDragAndDropStop',
+ 'get_snippet_versions': '_onGetSnippetVersions',
+ 'go_to_parent': '_onGoToParent',
+ 'remove_snippet': '_onRemoveSnippet',
+ 'snippet_edition_request': '_onSnippetEditionRequest',
+ 'snippet_editor_destroyed': '_onSnippetEditorDestroyed',
+ 'snippet_removed': '_onSnippetRemoved',
+ 'snippet_cloned': '_onSnippetCloned',
+ 'snippet_option_update': '_onSnippetOptionUpdate',
+ 'snippet_option_visibility_update': '_onSnippetOptionVisibilityUpdate',
+ 'snippet_thumbnail_url_request': '_onSnippetThumbnailURLRequest',
+ 'reload_snippet_dropzones': '_disableUndroppableSnippets',
+ 'request_save': '_onSaveRequest',
+ 'update_customize_elements': '_onUpdateCustomizeElements',
+ 'hide_overlay': '_onHideOverlay',
+ 'block_preview_overlays': '_onBlockPreviewOverlays',
+ 'unblock_preview_overlays': '_onUnblockPreviewOverlays',
+ 'user_value_widget_opening': '_onUserValueWidgetOpening',
+ 'user_value_widget_closing': '_onUserValueWidgetClosing',
+ 'reload_snippet_template': '_onReloadSnippetTemplate',
+ },
+ // enum of the SnippetsMenu's tabs.
+ tabs: {
+ BLOCKS: 'blocks',
+ OPTIONS: 'options',
+ },
+
+ /**
+ * @param {Widget} parent
+ * @param {Object} [options]
+ * @param {string} [options.snippets]
+ * URL of the snippets template. This URL might have been set
+ * in the global 'snippets' variable, otherwise this function
+ * assigns a default one.
+ * default: 'web_editor.snippets'
+ *
+ * @constructor
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ options = options || {};
+ this.trigger_up('getRecordInfo', {
+ recordInfo: options,
+ callback: function (recordInfo) {
+ _.defaults(options, recordInfo);
+ },
+ });
+
+ this.options = options;
+ if (!this.options.snippets) {
+ this.options.snippets = 'web_editor.snippets';
+ }
+ this.snippetEditors = [];
+ this._enabledEditorHierarchy = [];
+
+ this._mutex = new concurrency.Mutex();
+
+ this.setSelectorEditableArea(options.$el, options.selectorEditableArea);
+
+ this._notActivableElementsSelector = [
+ '#web_editor-top-edit',
+ '.o_we_website_top_actions',
+ '#oe_snippets',
+ '#oe_manipulators',
+ '.o_technical_modal',
+ '.oe_drop_zone',
+ '.o_notification_manager',
+ '.o_we_no_overlay',
+ '.ui-autocomplete',
+ '.modal .close',
+ '.o_we_crop_widget',
+ ].join(', ');
+
+ this.loadingTimers = {};
+ this.loadingElements = {};
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ // Preload colorpalette dependencies without waiting for them. The
+ // widget have huge chances of being used by the user (clicking on any
+ // text will load it). The colorpalette itself will do the actual
+ // waiting of the loading completion.
+ ColorPaletteWidget.loadDependencies(this);
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ async start() {
+ var defs = [this._super.apply(this, arguments)];
+ this.ownerDocument = this.$el[0].ownerDocument;
+ this.$document = $(this.ownerDocument);
+ this.window = this.ownerDocument.defaultView;
+ this.$window = $(this.window);
+
+ this.customizePanel = document.createElement('div');
+ this.customizePanel.classList.add('o_we_customize_panel', 'd-none');
+
+ this.textEditorPanelEl = document.createElement('div');
+ this.textEditorPanelEl.classList.add('o_we_snippet_text_tools', 'd-none');
+
+ this.invisibleDOMPanelEl = document.createElement('div');
+ this.invisibleDOMPanelEl.classList.add('o_we_invisible_el_panel');
+ this.invisibleDOMPanelEl.appendChild(
+ $('<div/>', {
+ text: _t('Invisible Elements'),
+ class: 'o_panel_header',
+ })[0]
+ );
+
+ this.options.getScrollOptions = this._getScrollOptions.bind(this);
+
+ // Fetch snippet templates and compute it
+ defs.push((async () => {
+ await this._loadSnippetsTemplates();
+ await this._updateInvisibleDOM();
+ })());
+
+ // Prepare snippets editor environment
+ this.$snippetEditorArea = $('<div/>', {
+ id: 'oe_manipulators',
+ }).insertAfter(this.$el);
+
+ // Active snippet editor on click in the page
+ var lastElement;
+ const onClick = ev => {
+ var srcElement = ev.target || (ev.originalEvent && (ev.originalEvent.target || ev.originalEvent.originalTarget)) || ev.srcElement;
+ if (!srcElement || lastElement === srcElement) {
+ return;
+ }
+ lastElement = srcElement;
+ _.defer(function () {
+ lastElement = false;
+ });
+
+ var $target = $(srcElement);
+ if (!$target.closest('we-button, we-toggler, we-select, .o_we_color_preview').length) {
+ this._closeWidgets();
+ }
+ if (!$target.closest('body > *').length) {
+ return;
+ }
+ if ($target.closest(this._notActivableElementsSelector).length) {
+ return;
+ }
+ const $oeStructure = $target.closest('.oe_structure');
+ if ($oeStructure.length && !$oeStructure.children().length && this.$snippets) {
+ // If empty oe_structure, encourage using snippets in there by
+ // making them "wizz" in the panel.
+ this.$snippets.odooBounce();
+ return;
+ }
+ this._activateSnippet($target);
+ };
+
+ this.$document.on('click.snippets_menu', '*', onClick);
+ // Needed as bootstrap stop the propagation of click events for dropdowns
+ this.$document.on('mouseup.snippets_menu', '.dropdown-toggle', onClick);
+
+ core.bus.on('deactivate_snippet', this, this._onDeactivateSnippet);
+
+ // Adapt overlay covering when the window is resized / content changes
+ var debouncedCoverUpdate = _.throttle(() => {
+ this.updateCurrentSnippetEditorOverlay();
+ }, 50);
+ this.$window.on('resize.snippets_menu', debouncedCoverUpdate);
+ this.$window.on('content_changed.snippets_menu', debouncedCoverUpdate);
+
+ // On keydown add a class on the active overlay to hide it and show it
+ // again when the mouse moves
+ this.$document.on('keydown.snippets_menu', () => {
+ this.__overlayKeyWasDown = true;
+ this.snippetEditors.forEach(editor => {
+ editor.toggleOverlayVisibility(false);
+ });
+ });
+ this.$document.on('mousemove.snippets_menu, mousedown.snippets_menu', _.throttle(() => {
+ if (!this.__overlayKeyWasDown) {
+ return;
+ }
+ this.__overlayKeyWasDown = false;
+ this.snippetEditors.forEach(editor => {
+ editor.toggleOverlayVisibility(true);
+ editor.cover();
+ });
+ }, 250));
+
+ // Hide the active overlay when scrolling.
+ // Show it again and recompute all the overlays after the scroll.
+ this.$scrollingElement = $().getScrollingElement();
+ this._onScrollingElementScroll = _.throttle(() => {
+ for (const editor of this.snippetEditors) {
+ editor.toggleOverlayVisibility(false);
+ }
+ clearTimeout(this.scrollingTimeout);
+ this.scrollingTimeout = setTimeout(() => {
+ this._scrollingTimeout = null;
+ for (const editor of this.snippetEditors) {
+ editor.toggleOverlayVisibility(true);
+ editor.cover();
+ }
+ }, 250);
+ }, 50);
+ // We use addEventListener instead of jQuery because we need 'capture'.
+ // Setting capture to true allows to take advantage of event bubbling
+ // for events that otherwise don’t support it. (e.g. useful when
+ // scrolling a modal)
+ this.$scrollingElement[0].addEventListener('scroll', this._onScrollingElementScroll, {capture: true});
+
+ // Auto-selects text elements with a specific class and remove this
+ // on text changes
+ this.$document.on('click.snippets_menu', '.o_default_snippet_text', function (ev) {
+ $(ev.target).closest('.o_default_snippet_text').removeClass('o_default_snippet_text');
+ $(ev.target).selectContent();
+ $(ev.target).removeClass('o_default_snippet_text');
+ });
+ this.$document.on('keyup.snippets_menu', function () {
+ var range = Wysiwyg.getRange(this);
+ $(range && range.sc).closest('.o_default_snippet_text').removeClass('o_default_snippet_text');
+ });
+
+ const $autoFocusEls = $('.o_we_snippet_autofocus');
+ this._activateSnippet($autoFocusEls.length ? $autoFocusEls.first() : false);
+
+ // Add tooltips on we-title elements whose text overflows
+ this.$el.tooltip({
+ selector: 'we-title',
+ placement: 'bottom',
+ delay: 100,
+ title: function () {
+ const el = this;
+ // On Firefox, el.scrollWidth is equal to el.clientWidth when
+ // overflow: hidden, so we need to update the style before to
+ // get the right values.
+ el.style.setProperty('overflow', 'scroll', 'important');
+ const tipContent = el.scrollWidth > el.clientWidth ? el.innerHTML : '';
+ el.style.removeProperty('overflow');
+ return tipContent;
+ },
+ });
+
+ return Promise.all(defs).then(() => {
+ this.$('[data-title]').tooltip({
+ delay: 100,
+ title: function () {
+ return this.classList.contains('active') ? false : this.dataset.title;
+ },
+ });
+
+ // Trigger a resize event once entering edit mode as the snippets
+ // menu will take part of the screen width (delayed because of
+ // animation). (TODO wait for real animation end)
+ setTimeout(() => {
+ this.$window.trigger('resize');
+
+ // Hacky way to prevent to switch to text tools on editor
+ // start. Only allow switching after some delay. Switching to
+ // tools is only useful for out-of-snippet texts anyway, so
+ // snippet texts can still be enabled immediately.
+ this._mutex.exec(() => this._textToolsSwitchingEnabled = true);
+ }, 1000);
+ });
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ if (this.$window) {
+ this.$snippetEditorArea.remove();
+ this.$window.off('.snippets_menu');
+ this.$document.off('.snippets_menu');
+ this.$scrollingElement[0].removeEventListener('scroll', this._onScrollingElementScroll, {capture: true});
+ }
+ core.bus.off('deactivate_snippet', this, this._onDeactivateSnippet);
+ delete this.cacheSnippetTemplate[this.options.snippets];
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Prepares the page so that it may be saved:
+ * - Asks the snippet editors to clean their associated snippet
+ * - Remove the 'contentEditable' attributes
+ */
+ cleanForSave: async function () {
+ await this._activateSnippet(false);
+ this.trigger_up('ready_to_clean_for_save');
+ await this._destroyEditors();
+
+ this.getEditableArea().find('[contentEditable]')
+ .removeAttr('contentEditable')
+ .removeProp('contentEditable');
+
+ this.getEditableArea().find('.o_we_selected_image')
+ .removeClass('o_we_selected_image');
+ },
+ /**
+ * Load snippets.
+ * @param {boolean} invalidateCache
+ */
+ loadSnippets: function (invalidateCache) {
+ if (!invalidateCache && this.cacheSnippetTemplate[this.options.snippets]) {
+ this._defLoadSnippets = this.cacheSnippetTemplate[this.options.snippets];
+ return this._defLoadSnippets;
+ }
+ this._defLoadSnippets = this._rpc({
+ model: 'ir.ui.view',
+ method: 'render_public_asset',
+ args: [this.options.snippets, {}],
+ kwargs: {
+ context: this.options.context,
+ },
+ });
+ this.cacheSnippetTemplate[this.options.snippets] = this._defLoadSnippets;
+ return this._defLoadSnippets;
+ },
+ /**
+ * Sets the instance variables $editor, $body and selectorEditableArea.
+ *
+ * @param {JQuery} $editor
+ * @param {String} selectorEditableArea
+ */
+ setSelectorEditableArea: function ($editor, selectorEditableArea) {
+ this.selectorEditableArea = selectorEditableArea;
+ this.$editor = $editor;
+ this.$body = $editor.closest('body');
+ },
+ /**
+ * Get the editable area.
+ *
+ * @returns {JQuery}
+ */
+ getEditableArea: function () {
+ return this.$editor.find(this.selectorEditableArea)
+ .add(this.$editor.filter(this.selectorEditableArea));
+ },
+ /**
+ * Updates the cover dimensions of the current snippet editor.
+ */
+ updateCurrentSnippetEditorOverlay: function () {
+ for (const snippetEditor of this.snippetEditors) {
+ if (snippetEditor.$target.closest('body').length) {
+ snippetEditor.cover();
+ continue;
+ }
+ // Destroy options whose $target are not in the DOM anymore but
+ // only do it once all options executions are done.
+ this._mutex.exec(() => snippetEditor.destroy());
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Creates drop zones in the DOM (locations where snippets may be dropped).
+ * Those locations are determined thanks to the two types of given DOM.
+ *
+ * @private
+ * @param {jQuery} [$selectorSiblings]
+ * elements which must have siblings drop zones
+ * @param {jQuery} [$selectorChildren]
+ * elements which must have child drop zones between each of existing
+ * child
+ */
+ _activateInsertionZones: function ($selectorSiblings, $selectorChildren) {
+ var self = this;
+
+ // If a modal is open, the drop zones must be created only in this modal
+ const $openModal = self.getEditableArea().find('.modal:visible');
+ if ($openModal.length) {
+ $selectorSiblings = $openModal.find($selectorSiblings);
+ $selectorChildren = $openModal.find($selectorChildren);
+ }
+
+ // Check if the drop zone should be horizontal or vertical
+ function setDropZoneDirection($elem, $parent, $sibling) {
+ var vertical = false;
+ var style = {};
+ $sibling = $sibling || $elem;
+ var css = window.getComputedStyle($elem[0]);
+ var parentCss = window.getComputedStyle($parent[0]);
+ var float = css.float || css.cssFloat;
+ var display = parentCss.display;
+ var flex = parentCss.flexDirection;
+ if (float === 'left' || float === 'right' || (display === 'flex' && flex === 'row')) {
+ style['float'] = float;
+ if ($sibling.parent().width() !== $sibling.outerWidth(true)) {
+ vertical = true;
+ style['height'] = Math.max($sibling.outerHeight(), 30) + 'px';
+ }
+ }
+ return {
+ vertical: vertical,
+ style: style,
+ };
+ }
+
+ // If the previous sibling is a BR tag or a non-whitespace text, it
+ // should be a vertical dropzone.
+ function testPreviousSibling(node, $zone) {
+ if (!node || ((node.tagName || !node.textContent.match(/\S/)) && node.tagName !== 'BR')) {
+ return false;
+ }
+ return {
+ vertical: true,
+ style: {
+ 'float': 'none',
+ 'display': 'inline-block',
+ 'height': parseInt(self.window.getComputedStyle($zone[0]).lineHeight) + 'px',
+ },
+ };
+ }
+
+ // Firstly, add a dropzone after the clone
+ var $clone = $('.oe_drop_clone');
+ if ($clone.length) {
+ var $neighbor = $clone.prev();
+ if (!$neighbor.length) {
+ $neighbor = $clone.next();
+ }
+ var data;
+ if ($neighbor.length) {
+ data = setDropZoneDirection($neighbor, $neighbor.parent());
+ } else {
+ data = {
+ vertical: false,
+ style: {},
+ };
+ }
+ self._insertDropzone($('<we-hook/>').insertAfter($clone), data.vertical, data.style);
+ }
+
+ if ($selectorChildren) {
+ $selectorChildren.each(function () {
+ var data;
+ var $zone = $(this);
+ var $children = $zone.find('> :not(.oe_drop_zone, .oe_drop_clone)');
+
+ if (!$zone.children().last().is('.oe_drop_zone')) {
+ data = testPreviousSibling($zone[0].lastChild, $zone)
+ || setDropZoneDirection($zone, $zone, $children.last());
+ self._insertDropzone($('<we-hook/>').appendTo($zone), data.vertical, data.style);
+ }
+
+ if (!$zone.children().first().is('.oe_drop_clone')) {
+ data = testPreviousSibling($zone[0].firstChild, $zone)
+ || setDropZoneDirection($zone, $zone, $children.first());
+ self._insertDropzone($('<we-hook/>').prependTo($zone), data.vertical, data.style);
+ }
+ });
+
+ // add children near drop zone
+ $selectorSiblings = $(_.uniq(($selectorSiblings || $()).add($selectorChildren.children()).get()));
+ }
+
+ var noDropZonesSelector = '[data-invisible="1"], .o_we_no_overlay, :not(:visible)';
+ if ($selectorSiblings) {
+ $selectorSiblings.not(`.oe_drop_zone, .oe_drop_clone, ${noDropZonesSelector}`).each(function () {
+ var data;
+ var $zone = $(this);
+ var $zoneToCheck = $zone;
+
+ while ($zoneToCheck.prev(noDropZonesSelector).length) {
+ $zoneToCheck = $zoneToCheck.prev();
+ }
+ if (!$zoneToCheck.prev('.oe_drop_zone:visible, .oe_drop_clone').length) {
+ data = setDropZoneDirection($zone, $zone.parent());
+ self._insertDropzone($('<we-hook/>').insertBefore($zone), data.vertical, data.style);
+ }
+
+ $zoneToCheck = $zone;
+ while ($zoneToCheck.next(noDropZonesSelector).length) {
+ $zoneToCheck = $zoneToCheck.next();
+ }
+ if (!$zoneToCheck.next('.oe_drop_zone:visible, .oe_drop_clone').length) {
+ data = setDropZoneDirection($zone, $zone.parent());
+ self._insertDropzone($('<we-hook/>').insertAfter($zone), data.vertical, data.style);
+ }
+ });
+ }
+
+ var count;
+ var $zones;
+ do {
+ count = 0;
+ $zones = this.getEditableArea().find('.oe_drop_zone > .oe_drop_zone').remove(); // no recursive zones
+ count += $zones.length;
+ $zones.remove();
+ } while (count > 0);
+
+ // Cleaning consecutive zone and up zones placed between floating or
+ // inline elements. We do not like these kind of zones.
+ $zones = this.getEditableArea().find('.oe_drop_zone:not(.oe_vertical)');
+ $zones.each(function () {
+ var zone = $(this);
+ var prev = zone.prev();
+ var next = zone.next();
+ // remove consecutive zone
+ if (prev.is('.oe_drop_zone') || next.is('.oe_drop_zone')) {
+ zone.remove();
+ return;
+ }
+ var floatPrev = prev.css('float') || 'none';
+ var floatNext = next.css('float') || 'none';
+ var dispPrev = prev.css('display') || null;
+ var dispNext = next.css('display') || null;
+ if ((floatPrev === 'left' || floatPrev === 'right')
+ && (floatNext === 'left' || floatNext === 'right')) {
+ zone.remove();
+ } else if (dispPrev !== null && dispNext !== null
+ && dispPrev.indexOf('inline') >= 0 && dispNext.indexOf('inline') >= 0) {
+ zone.remove();
+ }
+ });
+ },
+ /**
+ * Adds an entry for every invisible snippet in the left panel box.
+ * The entries will contains an 'Edit' button to activate their snippet.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _updateInvisibleDOM: function () {
+ return this._execWithLoadingEffect(() => {
+ this.invisibleDOMMap = new Map();
+ const $invisibleDOMPanelEl = $(this.invisibleDOMPanelEl);
+ $invisibleDOMPanelEl.find('.o_we_invisible_entry').remove();
+ const $invisibleSnippets = globalSelector.all().find('.o_snippet_invisible').addBack('.o_snippet_invisible');
+
+ $invisibleDOMPanelEl.toggleClass('d-none', !$invisibleSnippets.length);
+
+ const proms = _.map($invisibleSnippets, async el => {
+ const editor = await this._createSnippetEditor($(el));
+ const $invisEntry = $('<div/>', {
+ class: 'o_we_invisible_entry d-flex align-items-center justify-content-between',
+ text: editor.getName(),
+ }).append($('<i/>', {class: `fa ${editor.isTargetVisible() ? 'fa-eye' : 'fa-eye-slash'} ml-2`}));
+ $invisibleDOMPanelEl.append($invisEntry);
+ this.invisibleDOMMap.set($invisEntry[0], el);
+ });
+ return Promise.all(proms);
+ }, false);
+ },
+ /**
+ * Disable the overlay editor of the active snippet and activate the new one
+ * if given.
+ * Note 1: if the snippet editor associated to the given snippet is not
+ * created yet, this method will create it.
+ * Note 2: if the given DOM element is not a snippet (no editor option), the
+ * first parent which is one is used instead.
+ *
+ * @param {jQuery|false} $snippet
+ * The DOM element whose editor (and its parent ones) need to be
+ * enabled. Only disable the current one if false is given.
+ * @param {boolean} [previewMode=false]
+ * @param {boolean} [ifInactiveOptions=false]
+ * @returns {Promise<SnippetEditor>}
+ * (might be async when an editor must be created)
+ */
+ _activateSnippet: async function ($snippet, previewMode, ifInactiveOptions) {
+ if (this._blockPreviewOverlays && previewMode) {
+ return;
+ }
+ if ($snippet && !$snippet.is(':visible')) {
+ return;
+ }
+ // Take the first parent of the provided DOM (or itself) which
+ // should have an associated snippet editor.
+ // It is important to do that before the mutex exec call to compute it
+ // before potential ancestor removal.
+ if ($snippet && $snippet.length) {
+ $snippet = globalSelector.closest($snippet);
+ }
+ const exec = previewMode
+ ? action => this._mutex.exec(action)
+ : action => this._execWithLoadingEffect(action, false);
+ return exec(() => {
+ return new Promise(resolve => {
+ if ($snippet && $snippet.length) {
+ return this._createSnippetEditor($snippet).then(resolve);
+ }
+ resolve(null);
+ }).then(async editorToEnable => {
+ if (ifInactiveOptions && this._enabledEditorHierarchy.includes(editorToEnable)) {
+ return editorToEnable;
+ }
+
+ if (!previewMode) {
+ this._enabledEditorHierarchy = [];
+ let current = editorToEnable;
+ while (current && current.$target) {
+ this._enabledEditorHierarchy.push(current);
+ current = current.getParent();
+ }
+ }
+
+ // First disable all editors...
+ for (let i = this.snippetEditors.length; i--;) {
+ const editor = this.snippetEditors[i];
+ editor.toggleOverlay(false, previewMode);
+ if (!previewMode && !this._enabledEditorHierarchy.includes(editor)) {
+ await editor.toggleOptions(false);
+ }
+ }
+ // ... if no editors are to be enabled, look if any have been
+ // enabled previously by a click
+ if (!editorToEnable) {
+ editorToEnable = this.snippetEditors.find(editor => editor.isSticky());
+ previewMode = false;
+ }
+ // ... then enable the right editor
+ if (editorToEnable) {
+ editorToEnable.toggleOverlay(true, previewMode);
+ await editorToEnable.toggleOptions(true);
+ }
+
+ return editorToEnable;
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {boolean} invalidateCache
+ */
+ _loadSnippetsTemplates: async function (invalidateCache) {
+ return this._execWithLoadingEffect(async () => {
+ await this._destroyEditors();
+ const html = await this.loadSnippets(invalidateCache);
+ await this._computeSnippetTemplates(html);
+ }, false);
+ },
+ /**
+ * @private
+ * @param {jQuery|null|undefined} [$el]
+ * The DOM element whose inside editors need to be destroyed.
+ * If no element is given, all the editors are destroyed.
+ */
+ _destroyEditors: async function ($el) {
+ const proms = _.map(this.snippetEditors, async function (snippetEditor) {
+ if ($el && !$el.has(snippetEditor.$target).length) {
+ return;
+ }
+ await snippetEditor.cleanForSave();
+ snippetEditor.destroy();
+ });
+ await Promise.all(proms);
+ this.snippetEditors.splice(0);
+ },
+ /**
+ * Calls a given callback 'on' the given snippet and all its child ones if
+ * any (DOM element with options).
+ *
+ * Note: the method creates the snippet editors if they do not exist yet.
+ *
+ * @private
+ * @param {jQuery} $snippet
+ * @param {function} callback
+ * Given two arguments: the snippet editor associated to the snippet
+ * being managed and the DOM element of this snippet.
+ * @returns {Promise} (might be async if snippet editors need to be created
+ * and/or the callback is async)
+ */
+ _callForEachChildSnippet: function ($snippet, callback) {
+ var self = this;
+ var defs = _.map($snippet.add(globalSelector.all($snippet)), function (el) {
+ var $snippet = $(el);
+ return self._createSnippetEditor($snippet).then(function (editor) {
+ if (editor) {
+ return callback.call(self, editor, $snippet);
+ }
+ });
+ });
+ return Promise.all(defs);
+ },
+ /**
+ * @private
+ */
+ _closeWidgets: function () {
+ this.snippetEditors.forEach(editor => editor.closeWidgets());
+ },
+ /**
+ * Creates and returns a set of helper functions which can help finding
+ * snippets in the DOM which match some parameters (typically parameters
+ * given by a snippet option). The functions are:
+ *
+ * - `is`: to determine if a given DOM is a snippet that matches the
+ * parameters
+ *
+ * - `closest`: find closest parent (or itself) of a given DOM which is a
+ * snippet that matches the parameters
+ *
+ * - `all`: find all snippets in the DOM that match the parameters
+ *
+ * See implementation for function details.
+ *
+ * @private
+ * @param {string} selector
+ * jQuery selector that DOM elements must match to be considered as
+ * potential snippet.
+ * @param {string} exclude
+ * jQuery selector that DOM elements must *not* match to be
+ * considered as potential snippet.
+ * @param {string|false} target
+ * jQuery selector that at least one child of a DOM element must
+ * match to that DOM element be considered as a potential snippet.
+ * @param {boolean} noCheck
+ * true if DOM elements which are technically not in an editable
+ * environment may be considered.
+ * @param {boolean} isChildren
+ * when the DOM elements must be in an editable environment to be
+ * considered (@see noCheck), this is true if the DOM elements'
+ * parent must also be in an editable environment to be considered.
+ */
+ _computeSelectorFunctions: function (selector, exclude, target, noCheck, isChildren) {
+ var self = this;
+
+ exclude += `${exclude && ', '}.o_snippet_not_selectable`;
+
+ let filterFunc = function () {
+ return !$(this).is(exclude);
+ };
+ if (target) {
+ const oldFilter = filterFunc;
+ filterFunc = function () {
+ return oldFilter.apply(this) && $(this).find(target).length !== 0;
+ };
+ }
+
+ // Prepare the functions
+ var functions = {
+ is: function ($from) {
+ return $from.is(selector) && $from.filter(filterFunc).length !== 0;
+ },
+ };
+ if (noCheck) {
+ functions.closest = function ($from, parentNode) {
+ return $from.closest(selector, parentNode).filter(filterFunc);
+ };
+ functions.all = function ($from) {
+ return ($from ? dom.cssFind($from, selector) : $(selector)).filter(filterFunc);
+ };
+ } else {
+ functions.closest = function ($from, parentNode) {
+ var parents = self.getEditableArea().get();
+ return $from.closest(selector, parentNode).filter(function () {
+ var node = this;
+ while (node.parentNode) {
+ if (parents.indexOf(node) !== -1) {
+ return true;
+ }
+ node = node.parentNode;
+ }
+ return false;
+ }).filter(filterFunc);
+ };
+ functions.all = isChildren ? function ($from) {
+ return dom.cssFind($from || self.getEditableArea(), selector).filter(filterFunc);
+ } : function ($from) {
+ $from = $from || self.getEditableArea();
+ return $from.filter(selector).add(dom.cssFind($from, selector)).filter(filterFunc);
+ };
+ }
+ return functions;
+ },
+ /**
+ * Processes the given snippet template to register snippet options, creates
+ * draggable thumbnail, etc.
+ *
+ * @private
+ * @param {string} html
+ */
+ _computeSnippetTemplates: function (html) {
+ var self = this;
+ var $html = $(html);
+ var $scroll = $html.siblings('#o_scroll');
+
+ // TODO remove me in master: introduced in a 14.0 fix to allow users to
+ // customize their navbar with 'Boxed' website header, which they could
+ // not because of a wrong XML selector they may not update.
+ const $headerNavFix = $html.find('[data-js="HeaderNavbar"][data-selector="#wrapwrap > header > nav"]');
+ if ($headerNavFix.length) {
+ $headerNavFix[0].dataset.selector = '#wrapwrap > header nav.navbar';
+ }
+
+ this.templateOptions = [];
+ var selectors = [];
+ var $styles = $html.find('[data-selector]');
+ $styles.each(function () {
+ var $style = $(this);
+ var selector = $style.data('selector');
+ var exclude = $style.data('exclude') || '';
+ var target = $style.data('target');
+ var noCheck = $style.data('no-check');
+ var optionID = $style.data('js') || $style.data('option-name'); // used in tour js as selector
+ var option = {
+ 'option': optionID,
+ 'base_selector': selector,
+ 'base_exclude': exclude,
+ 'base_target': target,
+ 'selector': self._computeSelectorFunctions(selector, exclude, target, noCheck),
+ '$el': $style,
+ 'drop-near': $style.data('drop-near') && self._computeSelectorFunctions($style.data('drop-near'), '', false, noCheck, true),
+ 'drop-in': $style.data('drop-in') && self._computeSelectorFunctions($style.data('drop-in'), '', false, noCheck),
+ 'data': _.extend({string: $style.attr('string')}, $style.data()),
+ };
+ self.templateOptions.push(option);
+ selectors.push(option.selector);
+ });
+ $styles.addClass('d-none');
+
+ globalSelector.closest = function ($from) {
+ var $temp;
+ var $target;
+ for (var i = 0, len = selectors.length; i < len; i++) {
+ $temp = selectors[i].closest($from, $target && $target[0]);
+ if ($temp.length) {
+ $target = $temp;
+ }
+ }
+ return $target || $();
+ };
+ globalSelector.all = function ($from) {
+ var $target = $();
+ for (var i = 0, len = selectors.length; i < len; i++) {
+ $target = $target.add(selectors[i].all($from));
+ }
+ return $target;
+ };
+ globalSelector.is = function ($from) {
+ for (var i = 0, len = selectors.length; i < len; i++) {
+ if (selectors[i].is($from)) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ this.$snippets = $scroll.find('.o_panel_body').children()
+ .addClass('oe_snippet')
+ .each((i, el) => {
+ const $snippet = $(el);
+ const name = _.escape(el.getAttribute('name'));
+ const thumbnailSrc = _.escape(el.dataset.oeThumbnail);
+ const $sbody = $snippet.children().addClass('oe_snippet_body');
+ const isCustomSnippet = !!el.closest('#snippet_custom');
+
+ // Associate in-page snippets to their name
+ // TODO I am not sure this is useful anymore and it should at
+ // least be made more robust using data-snippet
+ let snippetClasses = $sbody.attr('class').match(/s_[^ ]+/g);
+ if (snippetClasses && snippetClasses.length) {
+ snippetClasses = '.' + snippetClasses.join('.');
+ }
+ const $els = $(snippetClasses).not('[data-name]').add($sbody);
+ $els.attr('data-name', name).data('name', name);
+
+ // Create the thumbnail
+ const $thumbnail = $(`
+ <div class="oe_snippet_thumbnail">
+ <div class="oe_snippet_thumbnail_img" style="background-image: url(${thumbnailSrc});"/>
+ <span class="oe_snippet_thumbnail_title">${name}</span>
+ </div>
+ `);
+ $snippet.prepend($thumbnail);
+
+ // Create the install button (t-install feature) if necessary
+ const moduleID = $snippet.data('moduleId');
+ if (moduleID) {
+ el.classList.add('o_snippet_install');
+ $thumbnail.append($('<button/>', {
+ class: 'btn btn-primary o_install_btn w-100',
+ type: 'button',
+ text: _t("Install"),
+ }));
+ }
+
+ // Create the delete button for custom snippets
+ if (isCustomSnippet) {
+ const btnEl = document.createElement('we-button');
+ btnEl.dataset.snippetId = $snippet.data('oeSnippetId');
+ btnEl.classList.add('o_delete_btn', 'fa', 'fa-trash', 'btn', 'o_we_hover_danger');
+ btnEl.title = _.str.sprintf(_t("Delete %s"), name);
+ $snippet.append(btnEl);
+ }
+ })
+ .not('[data-module-id]');
+
+ // Hide scroll if no snippets defined
+ if (!this.$snippets.length) {
+ this.$el.detach();
+ }
+
+ // Register the text nodes that needs to be auto-selected on click
+ this._registerDefaultTexts();
+
+ // Force non editable part to contentEditable=false
+ $html.find('.o_not_editable').attr('contentEditable', false);
+
+ // Add the computed template and make elements draggable
+ this.$el.html($html);
+ this.$el.append(this.customizePanel);
+ this.$el.append(this.textEditorPanelEl);
+ this.$el.append(this.invisibleDOMPanelEl);
+ this._makeSnippetDraggable(this.$snippets);
+ this._disableUndroppableSnippets();
+
+ this.$el.addClass('o_loaded');
+ $('body.editor_enable').addClass('editor_has_snippets');
+ this.trigger_up('snippets_loaded', self.$el);
+ },
+ /**
+ * Creates a snippet editor to associated to the given snippet. If the given
+ * snippet already has a linked snippet editor, the function only returns
+ * that one.
+ * The function also instantiates a snippet editor for all snippet parents
+ * as a snippet editor must be able to display the parent snippet options.
+ *
+ * @private
+ * @param {jQuery} $snippet
+ * @returns {Promise<SnippetEditor>}
+ */
+ _createSnippetEditor: function ($snippet) {
+ var self = this;
+ var snippetEditor = $snippet.data('snippet-editor');
+ if (snippetEditor) {
+ return snippetEditor.__isStarted;
+ }
+
+ var def;
+ var $parent = globalSelector.closest($snippet.parent());
+ if ($parent.length) {
+ def = this._createSnippetEditor($parent);
+ }
+
+ return Promise.resolve(def).then(function (parentEditor) {
+ // When reaching this position, after the Promise resolution, the
+ // snippet editor instance might have been created by another call
+ // to _createSnippetEditor... the whole logic should be improved
+ // to avoid doing this here.
+ snippetEditor = $snippet.data('snippet-editor');
+ if (snippetEditor) {
+ return snippetEditor.__isStarted;
+ }
+
+ let editableArea = self.getEditableArea();
+ snippetEditor = new SnippetEditor(parentEditor || self, $snippet, self.templateOptions, $snippet.closest('[data-oe-type="html"], .oe_structure').add(editableArea), self.options);
+ self.snippetEditors.push(snippetEditor);
+ return snippetEditor.appendTo(self.$snippetEditorArea);
+ }).then(function () {
+ return snippetEditor;
+ });
+ },
+ /**
+ * There may be no location where some snippets might be dropped. This mades
+ * them appear disabled in the menu.
+ *
+ * @todo make them undraggable
+ * @private
+ */
+ _disableUndroppableSnippets: function () {
+ var self = this;
+ var cache = {};
+ this.$snippets.each(function () {
+ var $snippet = $(this);
+ var $snippetBody = $snippet.find('.oe_snippet_body');
+
+ var check = false;
+ _.each(self.templateOptions, function (option, k) {
+ if (check || !($snippetBody.is(option.base_selector) && !$snippetBody.is(option.base_exclude))) {
+ return;
+ }
+
+ cache[k] = cache[k] || {
+ 'drop-near': option['drop-near'] ? option['drop-near'].all().length : 0,
+ 'drop-in': option['drop-in'] ? option['drop-in'].all().length : 0
+ };
+ check = (cache[k]['drop-near'] || cache[k]['drop-in']);
+ });
+
+ $snippet.toggleClass('o_disabled', !check);
+ $snippet.attr('title', check ? '' : _t("No location to drop in"));
+ const $icon = $snippet.find('.o_snippet_undroppable').remove();
+ if (check) {
+ $icon.remove();
+ } else if (!$icon.length) {
+ const imgEl = document.createElement('img');
+ imgEl.classList.add('o_snippet_undroppable');
+ imgEl.src = '/web_editor/static/src/img/snippet_disabled.svg';
+ $snippet.append(imgEl);
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {string} [search]
+ */
+ _filterSnippets(search) {
+ const searchInputEl = this.el.querySelector('.o_snippet_search_filter_input');
+ const searchInputReset = this.el.querySelector('.o_snippet_search_filter_reset');
+ if (search !== undefined) {
+ searchInputEl.value = search;
+ } else {
+ search = searchInputEl.value;
+ }
+ search = search.toLowerCase();
+ searchInputReset.classList.toggle('d-none', !search);
+ const strMatches = str => !search || str.toLowerCase().includes(search);
+ for (const panelEl of this.el.querySelectorAll('.o_panel')) {
+ let hasVisibleSnippet = false;
+ const panelTitle = panelEl.querySelector('.o_panel_header').textContent;
+ const isPanelTitleMatch = strMatches(panelTitle);
+ for (const snippetEl of panelEl.querySelectorAll('.oe_snippet')) {
+ const matches = (isPanelTitleMatch
+ || strMatches(snippetEl.getAttribute('name'))
+ || strMatches(snippetEl.dataset.oeKeywords || ''));
+ if (matches) {
+ hasVisibleSnippet = true;
+ }
+ snippetEl.classList.toggle('d-none', !matches);
+ }
+ panelEl.classList.toggle('d-none', !hasVisibleSnippet);
+ }
+ },
+ /**
+ * @private
+ * @param {Object} [options={}]
+ * @returns {Object}
+ */
+ _getScrollOptions(options = {}) {
+ return Object.assign({}, options, {
+ scrollBoundaries: Object.assign({
+ right: false,
+ }, options.scrollBoundaries),
+ jQueryDraggableOptions: Object.assign({
+ appendTo: this.$body,
+ cursor: 'move',
+ greedy: true,
+ scroll: false,
+ }, options.jQueryDraggableOptions),
+ disableHorizontalScroll: true,
+ });
+ },
+ /**
+ * Creates a dropzone element and inserts it by replacing the given jQuery
+ * location. This allows to add data on the dropzone depending on the hook
+ * environment.
+ *
+ * @private
+ * @param {jQuery} $hook
+ * @param {boolean} [vertical=false]
+ * @param {Object} [style]
+ */
+ _insertDropzone: function ($hook, vertical, style) {
+ var $dropzone = $('<div/>', {
+ 'class': 'oe_drop_zone oe_insert' + (vertical ? ' oe_vertical' : ''),
+ });
+ if (style) {
+ $dropzone.css(style);
+ }
+ $hook.replaceWith($dropzone);
+ return $dropzone;
+ },
+ /**
+ * Make given snippets be draggable/droppable thanks to their thumbnail.
+ *
+ * @private
+ * @param {jQuery} $snippets
+ */
+ _makeSnippetDraggable: function ($snippets) {
+ var self = this;
+ var $toInsert, dropped, $snippet;
+
+ let dragAndDropResolve;
+ const $scrollingElement = $().getScrollingElement();
+
+ const smoothScrollOptions = this._getScrollOptions({
+ jQueryDraggableOptions: {
+ handle: '.oe_snippet_thumbnail:not(.o_we_already_dragging)',
+ helper: function () {
+ const dragSnip = this.cloneNode(true);
+ dragSnip.querySelectorAll('.o_delete_btn').forEach(
+ el => el.remove()
+ );
+ return dragSnip;
+ },
+ start: function () {
+ self.$el.find('.oe_snippet_thumbnail').addClass('o_we_already_dragging');
+
+ dropped = false;
+ $snippet = $(this);
+ var $baseBody = $snippet.find('.oe_snippet_body');
+ var $selectorSiblings = $();
+ var $selectorChildren = $();
+ var temp = self.templateOptions;
+ for (var k in temp) {
+ if ($baseBody.is(temp[k].base_selector) && !$baseBody.is(temp[k].base_exclude)) {
+ if (temp[k]['drop-near']) {
+ $selectorSiblings = $selectorSiblings.add(temp[k]['drop-near'].all());
+ }
+ if (temp[k]['drop-in']) {
+ $selectorChildren = $selectorChildren.add(temp[k]['drop-in'].all());
+ }
+ }
+ }
+
+ $toInsert = $baseBody.clone();
+ // Color-customize dynamic SVGs in dropped snippets with current theme colors.
+ [...$toInsert.find('img[src^="/web_editor/shape/"]')].forEach(dynamicSvg => {
+ const colorCustomizedURL = new URL(dynamicSvg.getAttribute('src'), window.location.origin);
+ colorCustomizedURL.searchParams.set('c1', getCSSVariableValue('o-color-1'));
+ dynamicSvg.src = colorCustomizedURL.pathname + colorCustomizedURL.search;
+ });
+
+ if (!$selectorSiblings.length && !$selectorChildren.length) {
+ console.warn($snippet.find('.oe_snippet_thumbnail_title').text() + " have not insert action: data-drop-near or data-drop-in");
+ return;
+ }
+
+ self._activateInsertionZones($selectorSiblings, $selectorChildren);
+
+ self.getEditableArea().find('.oe_drop_zone').droppable({
+ over: function () {
+ if (dropped) {
+ $toInsert.detach();
+ $toInsert.addClass('oe_snippet_body');
+ $('.oe_drop_zone').removeClass('invisible');
+ }
+ dropped = true;
+ $(this).first().after($toInsert).addClass('invisible');
+ $toInsert.removeClass('oe_snippet_body');
+ },
+ out: function () {
+ var prev = $toInsert.prev();
+ if (this === prev[0]) {
+ dropped = false;
+ $toInsert.detach();
+ $(this).removeClass('invisible');
+ $toInsert.addClass('oe_snippet_body');
+ }
+ },
+ });
+
+ // If a modal is open, the scroll target must be that modal
+ const $openModal = self.getEditableArea().find('.modal:visible');
+ self.draggableComponent.$scrollTarget = $openModal.length ? $openModal : $scrollingElement;
+
+ // Trigger a scroll on the draggable element so that jQuery updates
+ // the position of the drop zones.
+ self.draggableComponent.$scrollTarget.on('scroll.scrolling_element', function () {
+ self.$el.trigger('scroll');
+ });
+
+ const prom = new Promise(resolve => dragAndDropResolve = () => resolve());
+ self._mutex.exec(() => prom);
+ },
+ stop: async function (ev, ui) {
+ $toInsert.removeClass('oe_snippet_body');
+ self.draggableComponent.$scrollTarget.off('scroll.scrolling_element');
+
+ if (!dropped && ui.position.top > 3 && ui.position.left + ui.helper.outerHeight() < self.el.getBoundingClientRect().left) {
+ var $el = $.nearest({x: ui.position.left, y: ui.position.top}, '.oe_drop_zone', {container: document.body}).first();
+ if ($el.length) {
+ $el.after($toInsert);
+ dropped = true;
+ }
+ }
+
+ self.getEditableArea().find('.oe_drop_zone').droppable('destroy').remove();
+
+ if (dropped) {
+ var prev = $toInsert.first()[0].previousSibling;
+ var next = $toInsert.last()[0].nextSibling;
+
+ if (prev) {
+ $toInsert.detach();
+ self.trigger_up('request_history_undo_record', {$target: $(prev)});
+ $toInsert.insertAfter(prev);
+ } else if (next) {
+ $toInsert.detach();
+ self.trigger_up('request_history_undo_record', {$target: $(next)});
+ $toInsert.insertBefore(next);
+ } else {
+ var $parent = $toInsert.parent();
+ $toInsert.detach();
+ self.trigger_up('request_history_undo_record', {$target: $parent});
+ $parent.prepend($toInsert);
+ }
+
+ var $target = $toInsert;
+ await self._scrollToSnippet($target);
+
+ _.defer(async function () {
+ self.trigger_up('snippet_dropped', {$target: $target});
+ self._disableUndroppableSnippets();
+
+ dragAndDropResolve();
+
+ await self._callForEachChildSnippet($target, function (editor, $snippet) {
+ return editor.buildSnippet();
+ });
+ $target.trigger('content_changed');
+ await self._updateInvisibleDOM();
+
+ self.$el.find('.oe_snippet_thumbnail').removeClass('o_we_already_dragging');
+ });
+ } else {
+ $toInsert.remove();
+ dragAndDropResolve();
+ self.$el.find('.oe_snippet_thumbnail').removeClass('o_we_already_dragging');
+ }
+ },
+ },
+ });
+ this.draggableComponent = new SmoothScrollOnDrag(this, $snippets, $scrollingElement, smoothScrollOptions);
+ },
+ /**
+ * Adds the 'o_default_snippet_text' class on nodes which contain only
+ * non-empty text nodes. Those nodes are then auto-selected by the editor
+ * when they are clicked.
+ *
+ * @private
+ * @param {jQuery} [$in] - the element in which to search, default to the
+ * snippet bodies in the menu
+ */
+ _registerDefaultTexts: function ($in) {
+ if ($in === undefined) {
+ $in = this.$snippets.find('.oe_snippet_body');
+ }
+
+ $in.find('*').addBack()
+ .contents()
+ .filter(function () {
+ return this.nodeType === 3 && this.textContent.match(/\S/);
+ }).parent().addClass('o_default_snippet_text');
+ },
+ /**
+ * Changes the content of the left panel and selects a tab.
+ *
+ * @private
+ * @param {htmlString | Element | Text | Array | jQuery} [content]
+ * the new content of the customizePanel
+ * @param {this.tabs.VALUE} [tab='blocks'] - the tab to select
+ */
+ _updateLeftPanelContent: function ({content, tab}) {
+ clearTimeout(this._textToolsSwitchingTimeout);
+ this._closeWidgets();
+
+ tab = tab || this.tabs.BLOCKS;
+
+ if (content) {
+ while (this.customizePanel.firstChild) {
+ this.customizePanel.removeChild(this.customizePanel.firstChild);
+ }
+ $(this.customizePanel).append(content);
+ }
+
+ this.$('.o_snippet_search_filter').toggleClass('d-none', tab !== this.tabs.BLOCKS);
+ this.$('#o_scroll').toggleClass('d-none', tab !== this.tabs.BLOCKS);
+ this.customizePanel.classList.toggle('d-none', tab === this.tabs.BLOCKS);
+ this.textEditorPanelEl.classList.toggle('d-none', tab !== this.tabs.OPTIONS);
+
+ this.$('.o_we_add_snippet_btn').toggleClass('active', tab === this.tabs.BLOCKS);
+ this.$('.o_we_customize_snippet_btn').toggleClass('active', tab === this.tabs.OPTIONS)
+ .prop('disabled', tab !== this.tabs.OPTIONS);
+
+ },
+ /**
+ * Scrolls to given snippet.
+ *
+ * @private
+ * @param {jQuery} $el - snippet to scroll to
+ * @return {Promise}
+ */
+ async _scrollToSnippet($el) {
+ return dom.scrollTo($el[0], {extraOffset: 50});
+ },
+ /**
+ * @private
+ * @returns {HTMLElement}
+ */
+ _createLoadingElement() {
+ const loaderContainer = document.createElement('div');
+ const loader = document.createElement('i');
+ const loaderContainerClassList = [
+ 'o_we_ui_loading',
+ 'd-flex',
+ 'justify-content-center',
+ 'align-items-center',
+ ];
+ const loaderClassList = [
+ 'fa',
+ 'fa-circle-o-notch',
+ 'fa-spin',
+ 'fa-4x',
+ ];
+ loaderContainer.classList.add(...loaderContainerClassList);
+ loader.classList.add(...loaderClassList);
+ loaderContainer.appendChild(loader);
+ return loaderContainer;
+ },
+ /**
+ * Adds the action to the mutex queue and sets a loading effect over the
+ * editor to appear if the action takes too much time.
+ * As soon as the mutex is unlocked, the loading effect will be removed.
+ *
+ * @private
+ * @param {function} action
+ * @param {boolean} [contentLoading=true]
+ * @param {number} [delay=500]
+ * @returns {Promise}
+ */
+ async _execWithLoadingEffect(action, contentLoading = true, delay = 500) {
+ const mutexExecResult = this._mutex.exec(action);
+ if (!this.loadingTimers[contentLoading]) {
+ const addLoader = () => {
+ this.loadingElements[contentLoading] = this._createLoadingElement();
+ if (contentLoading) {
+ this.$snippetEditorArea.append(this.loadingElements[contentLoading]);
+ } else {
+ this.el.appendChild(this.loadingElements[contentLoading]);
+ }
+ };
+ if (delay) {
+ this.loadingTimers[contentLoading] = setTimeout(addLoader, delay);
+ } else {
+ addLoader();
+ }
+ this._mutex.getUnlockedDef().then(() => {
+ // Note: we remove the loading element at the end of the
+ // execution queue *even if subsequent actions are content
+ // related or not*. This is a limitation of the loading feature,
+ // the goal is still to limit the number of elements in that
+ // queue anyway.
+ if (delay) {
+ clearTimeout(this.loadingTimers[contentLoading]);
+ this.loadingTimers[contentLoading] = undefined;
+ }
+
+ if (this.loadingElements[contentLoading]) {
+ this.loadingElements[contentLoading].remove();
+ this.loadingElements[contentLoading] = null;
+ }
+ });
+ }
+ return mutexExecResult;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a child editor asks for insertion zones to be enabled.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onActivateInsertionZones: function (ev) {
+ this._activateInsertionZones(ev.data.$selectorSiblings, ev.data.$selectorChildren);
+ },
+ /**
+ * Called when a child editor asks to deactivate the current snippet
+ * overlay.
+ *
+ * @private
+ */
+ _onActivateSnippet: function (ev) {
+ this._activateSnippet(ev.data.$snippet, ev.data.previewMode, ev.data.ifInactiveOptions);
+ },
+ /**
+ * Called when a child editor asks to operate some operation on all child
+ * snippet of a DOM element.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onCallForEachChildSnippet: function (ev) {
+ this._callForEachChildSnippet(ev.data.$snippet, ev.data.callback);
+ },
+ /**
+ * Called when the overlay dimensions/positions should be recomputed.
+ *
+ * @private
+ */
+ _onOverlaysCoverUpdate: function () {
+ this.snippetEditors.forEach(editor => {
+ editor.cover();
+ });
+ },
+ /**
+ * Called when a child editor asks to clone a snippet, allows to correctly
+ * call the _onClone methods if the element's editor has one.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onCloneSnippet: async function (ev) {
+ ev.stopPropagation();
+ const editor = await this._createSnippetEditor(ev.data.$snippet);
+ await editor.clone();
+ if (ev.data.onSuccess) {
+ ev.data.onSuccess();
+ }
+ },
+ /**
+ * Called when a child editor asks to deactivate the current snippet
+ * overlay.
+ *
+ * @private
+ */
+ _onDeactivateSnippet: function () {
+ this._activateSnippet(false);
+ },
+ /**
+ * Called when a snippet has moved in the page.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onDragAndDropStop: async function (ev) {
+ const $modal = ev.data.$snippet.closest('.modal');
+ // If the snippet is in a modal, destroy editors only in that modal.
+ // This to prevent the modal from closing because of the cleanForSave
+ // on each editors.
+ await this._destroyEditors($modal.length ? $modal : null);
+ await this._activateSnippet(ev.data.$snippet);
+ },
+ /**
+ * Called when a snippet editor asked to disable itself and to enable its
+ * parent instead.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onGoToParent: function (ev) {
+ ev.stopPropagation();
+ this._activateSnippet(ev.data.$snippet.parent());
+ },
+ /**
+ * @private
+ */
+ _onHideOverlay: function () {
+ for (const editor of this.snippetEditors) {
+ editor.toggleOverlay(false);
+ }
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInstallBtnClick: function (ev) {
+ var self = this;
+ var $snippet = $(ev.currentTarget).closest('[data-module-id]');
+ var moduleID = $snippet.data('moduleId');
+ var name = $snippet.attr('name');
+ new Dialog(this, {
+ title: _.str.sprintf(_t("Install %s"), name),
+ size: 'medium',
+ $content: $('<div/>', {text: _.str.sprintf(_t("Do you want to install the %s App?"), name)}).append(
+ $('<a/>', {
+ target: '_blank',
+ href: '/web#id=' + moduleID + '&view_type=form&model=ir.module.module&action=base.open_module_tree',
+ text: _t("More info about this app."),
+ class: 'ml4',
+ })
+ ),
+ buttons: [{
+ text: _t("Save and Install"),
+ classes: 'btn-primary',
+ click: function () {
+ this.$footer.find('.btn').toggleClass('o_hidden');
+ this._rpc({
+ model: 'ir.module.module',
+ method: 'button_immediate_install',
+ args: [[moduleID]],
+ }).then(() => {
+ self.trigger_up('request_save', {
+ reloadEditor: true,
+ _toMutex: true,
+ });
+ }).guardedCatch(reason => {
+ reason.event.preventDefault();
+ this.close();
+ self.displayNotification({
+ message: _.str.sprintf(_t("Could not install module <strong>%s</strong>"), name),
+ type: 'danger',
+ sticky: true,
+ });
+ });
+ },
+ }, {
+ text: _t("Install in progress"),
+ icon: 'fa-spin fa-spinner fa-pulse mr8',
+ classes: 'btn-primary disabled o_hidden',
+ }, {
+ text: _t("Cancel"),
+ close: true,
+ }],
+ }).open();
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInvisibleEntryClick: async function (ev) {
+ ev.preventDefault();
+ const $snippet = $(this.invisibleDOMMap.get(ev.currentTarget));
+ const isVisible = await this._execWithLoadingEffect(async () => {
+ const editor = await this._createSnippetEditor($snippet);
+ return editor.toggleTargetVisibility();
+ }, true);
+ $(ev.currentTarget).find('.fa')
+ .toggleClass('fa-eye', isVisible)
+ .toggleClass('fa-eye-slash', !isVisible);
+ return this._activateSnippet(isVisible ? $snippet : false);
+ },
+ /**
+ * @private
+ */
+ _onBlocksTabClick: function (ev) {
+ this._activateSnippet(false).then(() => {
+ this._updateLeftPanelContent({
+ content: [],
+ tab: this.tabs.BLOCKS,
+ });
+ });
+ },
+ /**
+ * @private
+ */
+ _onDeleteBtnClick: function (ev) {
+ const $snippet = $(ev.target).closest('.oe_snippet');
+ const snippetId = parseInt(ev.currentTarget.dataset.snippetId);
+ ev.stopPropagation();
+ new Dialog(this, {
+ size: 'medium',
+ title: _t('Confirmation'),
+ $content: $('<div><p>' + _.str.sprintf(_t("Are you sure you want to delete the snippet: %s ?"), $snippet.attr('name')) + '</p></div>'),
+ buttons: [{
+ text: _t("Yes"),
+ close: true,
+ classes: 'btn-primary',
+ click: async () => {
+ await this._rpc({
+ model: 'ir.ui.view',
+ method: 'delete_snippet',
+ kwargs: {
+ 'view_id': snippetId,
+ 'template_key': this.options.snippets,
+ },
+ });
+ await this._loadSnippetsTemplates(true);
+ },
+ }, {
+ text: _t("No"),
+ close: true,
+ }],
+ }).open();
+ },
+ /**
+ * Prevents pointer-events to change the focus when a pointer slide from
+ * left-panel to the editable area.
+ *
+ * @private
+ */
+ _onMouseDown: function () {
+ const $blockedArea = $('#wrapwrap'); // TODO should get that element another way
+ $blockedArea.addClass('o_we_no_pointer_events');
+ const reenable = () => $blockedArea.removeClass('o_we_no_pointer_events');
+ // Use a setTimeout fallback to avoid locking the editor if the mouseup
+ // is fired over an element which stops propagation for example.
+ const enableTimeoutID = setTimeout(() => reenable(), 5000);
+ $(document).one('mouseup', () => {
+ clearTimeout(enableTimeoutID);
+ reenable();
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onGetSnippetVersions: function (ev) {
+ const snippet = this.el.querySelector(`.oe_snippet > [data-snippet="${ev.data.snippetName}"]`);
+ ev.data.onSuccess(snippet && {
+ vcss: snippet.dataset.vcss,
+ vjs: snippet.dataset.vjs,
+ vxml: snippet.dataset.vxml,
+ });
+ },
+ /**
+ * UNUSED: used to be called when saving a custom snippet. We now save and
+ * reload the page when saving a custom snippet so that all the DOM cleanup
+ * mechanisms are run before saving. Kept for compatibility.
+ *
+ * TODO: remove in master / find a way to clean the DOM without save+reload
+ *
+ * @private
+ */
+ _onReloadSnippetTemplate: async function (ev) {
+ await this._activateSnippet(false);
+ await this._loadSnippetsTemplates(true);
+ },
+ /**
+ * @private
+ */
+ _onBlockPreviewOverlays: function (ev) {
+ this._blockPreviewOverlays = true;
+ },
+ /**
+ * @private
+ */
+ _onUnblockPreviewOverlays: function (ev) {
+ this._blockPreviewOverlays = false;
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onRemoveSnippet: async function (ev) {
+ ev.stopPropagation();
+ const editor = await this._createSnippetEditor(ev.data.$snippet);
+ await editor.removeSnippet();
+ if (ev.data.onSuccess) {
+ ev.data.onSuccess();
+ }
+ },
+ /**
+ * Saving will destroy all editors since they need to clean their DOM.
+ * This has thus to be done when they are all finished doing their work.
+ *
+ * @private
+ */
+ _onSaveRequest: function (ev) {
+ const data = ev.data;
+ if (ev.target === this && !data._toMutex) {
+ return;
+ }
+ delete data._toMutex;
+ ev.stopPropagation();
+ this._execWithLoadingEffect(() => {
+ if (data.reloadEditor) {
+ data.reload = false;
+ const oldOnSuccess = data.onSuccess;
+ data.onSuccess = async function () {
+ if (oldOnSuccess) {
+ await oldOnSuccess.call(this, ...arguments);
+ }
+ window.location.href = window.location.origin + window.location.pathname + '?enable_editor=1';
+ };
+ }
+ this.trigger_up('request_save', data);
+ }, true);
+ },
+ /**
+ * @private
+ */
+ _onSnippetClick() {
+ const $els = this.getEditableArea().find('.oe_structure.oe_empty').addBack('.oe_structure.oe_empty');
+ for (const el of $els) {
+ if (!el.children.length) {
+ $(el).odooBounce('o_we_snippet_area_animation');
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data
+ * @param {function} ev.data.exec
+ */
+ _onSnippetEditionRequest: function (ev) {
+ this._execWithLoadingEffect(ev.data.exec, true);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetEditorDestroyed(ev) {
+ ev.stopPropagation();
+ const index = this.snippetEditors.indexOf(ev.target);
+ this.snippetEditors.splice(index, 1);
+ },
+ /**
+ * @private
+ */
+ _onSnippetCloned: function (ev) {
+ this._updateInvisibleDOM();
+ },
+ /**
+ * Called when a snippet is removed -> checks if there is draggable snippets
+ * to enable/disable as the DOM changed.
+ *
+ * @private
+ */
+ _onSnippetRemoved: function () {
+ this._disableUndroppableSnippets();
+ this._updateInvisibleDOM();
+ },
+ /**
+ * When the editor panel receives a notification indicating that an option
+ * was used, the panel is in charge of asking for an UI update of the whole
+ * panel. Logically, the options are displayed so that an option above
+ * may influence the status and visibility of an option which is below;
+ * e.g.:
+ * - the user sets a badge type to 'info'
+ * -> the badge background option (below) is shown as blue
+ * - the user adds a shadow
+ * -> more options are shown afterwards to control it (not above)
+ *
+ * Technically we however update the whole editor panel (parent and child
+ * options) wherever the updates comes from. The only important thing is
+ * to first update the options UI then their visibility as their visibility
+ * may depend on their UI status.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetOptionUpdate(ev) {
+ ev.stopPropagation();
+ (async () => {
+ const editors = this._enabledEditorHierarchy;
+ await Promise.all(editors.map(editor => editor.updateOptionsUI()));
+ await Promise.all(editors.map(editor => editor.updateOptionsUIVisibility()));
+ ev.data.onSuccess();
+ })();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetOptionVisibilityUpdate: async function (ev) {
+ if (!ev.data.show) {
+ await this._activateSnippet(false);
+ }
+ await this._updateInvisibleDOM(); // Re-render to update status
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetThumbnailURLRequest(ev) {
+ const $snippet = this.$snippets.has(`[data-snippet="${ev.data.key}"]`);
+ ev.data.onSuccess($snippet.length ? $snippet[0].dataset.oeThumbnail : '');
+ },
+ /**
+ * @private
+ */
+ _onSummernoteToolsUpdate(ev) {
+ if (!this._textToolsSwitchingEnabled) {
+ return;
+ }
+ const range = $.summernote.core.range.create();
+ if (!range) {
+ return;
+ }
+ if (range.sc === range.ec && range.sc.nodeType === Node.ELEMENT_NODE
+ && range.sc.classList.contains('oe_structure')
+ && range.sc.children.length === 0) {
+ // Do not switch to text tools if the cursor is in an empty
+ // oe_structure (to encourage using snippets there and actually
+ // avoid breaking tours which suppose the snippet list is visible).
+ return;
+ }
+ this.textEditorPanelEl.classList.add('d-block');
+ const hasVisibleButtons = !!$(this.textEditorPanelEl).find('.btn:visible').length;
+ this.textEditorPanelEl.classList.remove('d-block');
+ if (!hasVisibleButtons) {
+ // Ugly way to detect that summernote was updated but there is no
+ // visible text tools.
+ return;
+ }
+ // Only switch tab without changing content (_updateLeftPanelContent
+ // make text tools visible only on that specific tab). Also do it with
+ // a slight delay to avoid flickering doing it twice.
+ clearTimeout(this._textToolsSwitchingTimeout);
+ if (!this.$('#o_scroll').hasClass('d-none')) {
+ this._textToolsSwitchingTimeout = setTimeout(() => {
+ this._updateLeftPanelContent({tab: this.tabs.OPTIONS});
+ }, 250);
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onUpdateCustomizeElements: function (ev) {
+ this._updateLeftPanelContent({
+ content: ev.data.customize$Elements,
+ tab: ev.data.customize$Elements.length ? this.tabs.OPTIONS : this.tabs.BLOCKS,
+ });
+ },
+ /**
+ * Called when an user value widget is being opened -> close all the other
+ * user value widgets of all editors + add backdrop.
+ */
+ _onUserValueWidgetOpening: function () {
+ this._closeWidgets();
+ this.el.classList.add('o_we_backdrop');
+ },
+ /**
+ * Called when an user value widget is being closed -> rely on the fact only
+ * one widget can be opened at a time: remove the backdrop.
+ */
+ _onUserValueWidgetClosing: function () {
+ this.el.classList.remove('o_we_backdrop');
+ },
+ /**
+ * Called when search input value changed -> adapts the snippets grid.
+ *
+ * @private
+ */
+ _onSnippetSearchInput: function () {
+ this._filterSnippets();
+ },
+ /**
+ * Called on snippet search filter reset -> clear input field search.
+ *
+ * @private
+ */
+ _onSnippetSearchResetClick: function () {
+ this._filterSnippets('');
+ },
+});
+
+return {
+ Class: SnippetsMenu,
+ Editor: SnippetEditor,
+ globalSelector: globalSelector,
+};
+});
diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js
new file mode 100644
index 00000000..f89d5791
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/snippets.options.js
@@ -0,0 +1,4908 @@
+odoo.define('web_editor.snippets.options', function (require) {
+'use strict';
+
+var core = require('web.core');
+const {ColorpickerWidget} = require('web.Colorpicker');
+const Dialog = require('web.Dialog');
+const rpc = require('web.rpc');
+const time = require('web.time');
+var Widget = require('web.Widget');
+var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget;
+const weUtils = require('web_editor.utils');
+const {
+ normalizeColor,
+ getBgImageURL,
+} = weUtils;
+var weWidgets = require('wysiwyg.widgets');
+const {
+ loadImage,
+ loadImageInfo,
+ applyModifications,
+ removeOnImageChangeAttrs,
+} = require('web_editor.image_processing');
+
+var qweb = core.qweb;
+var _t = core._t;
+
+/**
+ * @param {HTMLElement} el
+ * @param {string} [title]
+ * @param {Object} [options]
+ * @param {string[]} [options.classes]
+ * @param {string} [options.tooltip]
+ * @param {string} [options.placeholder]
+ * @param {Object} [options.dataAttributes]
+ * @returns {HTMLElement} - the original 'el' argument
+ */
+function _addTitleAndAllowedAttributes(el, title, options) {
+ let tooltipEl = el;
+ if (title) {
+ const titleEl = _buildTitleElement(title);
+ tooltipEl = titleEl;
+ el.appendChild(titleEl);
+ }
+
+ if (options && options.classes) {
+ el.classList.add(...options.classes);
+ }
+ if (options && options.tooltip) {
+ tooltipEl.title = options.tooltip;
+ }
+ if (options && options.placeholder) {
+ el.setAttribute('placeholder', options.placeholder);
+ }
+ if (options && options.dataAttributes) {
+ for (const key in options.dataAttributes) {
+ el.dataset[key] = options.dataAttributes[key];
+ }
+ }
+
+ return el;
+}
+/**
+ * @param {string} tagName
+ * @param {string} title - @see _addTitleAndAllowedAttributes
+ * @param {Object} options - @see _addTitleAndAllowedAttributes
+ * @returns {HTMLElement}
+ */
+function _buildElement(tagName, title, options) {
+ const el = document.createElement(tagName);
+ return _addTitleAndAllowedAttributes(el, title, options);
+}
+/**
+ * @param {string} title
+ * @returns {HTMLElement}
+ */
+function _buildTitleElement(title) {
+ const titleEl = document.createElement('we-title');
+ titleEl.textContent = title;
+ return titleEl;
+}
+/**
+ * @param {string} src
+ * @returns {HTMLElement}
+ */
+const _buildImgElementCache = {};
+async function _buildImgElement(src) {
+ if (!(src in _buildImgElementCache)) {
+ _buildImgElementCache[src] = (async () => {
+ if (src.split('.').pop() === 'svg') {
+ const response = await window.fetch(src);
+ const text = await response.text();
+ const parser = new window.DOMParser();
+ const xmlDoc = parser.parseFromString(text, 'text/xml');
+ return xmlDoc.getElementsByTagName('svg')[0];
+ } else {
+ const imgEl = document.createElement('img');
+ imgEl.src = src;
+ return imgEl;
+ }
+ })();
+ }
+ const node = await _buildImgElementCache[src];
+ return node.cloneNode(true);
+}
+/**
+ * Build the correct DOM for a we-row element.
+ *
+ * @param {string} [title] - @see _buildElement
+ * @param {Object} [options] - @see _buildElement
+ * @param {HTMLElement[]} [options.childNodes]
+ * @returns {HTMLElement}
+ */
+function _buildRowElement(title, options) {
+ const groupEl = _buildElement('we-row', title, options);
+
+ const rowEl = document.createElement('div');
+ groupEl.appendChild(rowEl);
+
+ if (options && options.childNodes) {
+ options.childNodes.forEach(node => rowEl.appendChild(node));
+ }
+
+ return groupEl;
+}
+/**
+ * Build the correct DOM for a we-collapse element.
+ *
+ * @param {string} [title] - @see _buildElement
+ * @param {Object} [options] - @see _buildElement
+ * @param {HTMLElement[]} [options.childNodes]
+ * @returns {HTMLElement}
+ */
+function _buildCollapseElement(title, options) {
+ const groupEl = _buildElement('we-collapse', title, options);
+ const titleEl = groupEl.querySelector('we-title');
+
+ const children = options && options.childNodes || [];
+ if (titleEl) {
+ titleEl.remove();
+ children.unshift(titleEl);
+ }
+ let i = 0;
+ for (i = 0; i < children.length; i++) {
+ groupEl.appendChild(children[i]);
+ if (children[i].nodeType === Node.ELEMENT_NODE) {
+ break;
+ }
+ }
+
+ const togglerEl = document.createElement('we-toggler');
+ togglerEl.classList.add('o_we_collapse_toggler');
+ groupEl.appendChild(togglerEl);
+
+ const containerEl = document.createElement('div');
+ children.slice(i + 1).forEach(node => containerEl.appendChild(node));
+ groupEl.appendChild(containerEl);
+
+ return groupEl;
+}
+/**
+ * Creates a proxy for an object where one property is replaced by a different
+ * value. This value is captured in the closure and can be read and written to.
+ *
+ * @param {Object} obj - the object for which to create a proxy
+ * @param {string} propertyName - the name/key of the property to replace
+ * @param {*} value - the initial value to give to the property's copy
+ * @returns {Proxy} a proxy of the object with the property replaced
+ */
+function createPropertyProxy(obj, propertyName, value) {
+ return new Proxy(obj, {
+ get: function (obj, prop) {
+ if (prop === propertyName) {
+ return value;
+ }
+ return obj[prop];
+ },
+ set: function (obj, prop, val) {
+ if (prop === propertyName) {
+ return (value = val);
+ }
+ return Reflect.set(...arguments);
+ },
+ });
+}
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+const NULL_ID = '__NULL__';
+
+/**
+ * Base class for components to be used in snippet options widgets to retrieve
+ * user values.
+ */
+const UserValueWidget = Widget.extend({
+ className: 'o_we_user_value_widget',
+ custom_events: {
+ 'user_value_update': '_onUserValueNotification',
+ },
+
+ /**
+ * @constructor
+ */
+ init: function (parent, title, options, $target) {
+ this._super(...arguments);
+ this.title = title;
+ this.options = options;
+ this._userValueWidgets = [];
+ this._value = '';
+ this.$target = $target;
+ },
+ /**
+ * @override
+ */
+ async willStart() {
+ await this._super(...arguments);
+ if (this.options.dataAttributes.img) {
+ this.imgEl = await _buildImgElement(this.options.dataAttributes.img);
+ }
+ },
+ /**
+ * @override
+ */
+ _makeDescriptive: function () {
+ const $el = this._super(...arguments);
+ const el = $el[0];
+ _addTitleAndAllowedAttributes(el, this.title, this.options);
+ this.containerEl = document.createElement('div');
+
+ if (this.imgEl) {
+ this.containerEl.appendChild(this.imgEl);
+ }
+
+ el.appendChild(this.containerEl);
+ return $el;
+ },
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+
+ if (this.el.classList.contains('o_we_img_animate')) {
+ const buildImgExtensionSwitcher = (from, to) => {
+ const regex = new RegExp(`${from}$`, 'i');
+ return ev => {
+ const img = ev.currentTarget.getElementsByTagName("img")[0];
+ img.src = img.src.replace(regex, to);
+ };
+ };
+ this.$el.on('mouseenter.img_animate', buildImgExtensionSwitcher('png', 'gif'));
+ this.$el.on('mouseleave.img_animate', buildImgExtensionSwitcher('gif', 'png'));
+ }
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ // Check if $el exists in case the widget is destroyed before it has
+ // been fully initialized.
+ // TODO there is probably better to do. This case was found only in
+ // tours, where the editor is left before the widget icon is loaded.
+ if (this.$el) {
+ this.$el.off('.img_animate');
+ }
+ this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Closes the widget (only meaningful for widgets that can be closed).
+ */
+ close: function () {
+ if (!this.el) {
+ // In case the method is called while the widget is not fully
+ // initialized yet. No need to prevent that case: asking a non
+ // initialized widget to close itself should just not be a problem
+ // and just be ignored.
+ return;
+ }
+ this.trigger_up('user_value_widget_closing');
+ this.el.classList.remove('o_we_widget_opened');
+ this._userValueWidgets.forEach(widget => widget.close());
+ },
+ /**
+ * Simulates the correct event on the element to make it active.
+ */
+ enable() {
+ this.$el.click();
+ },
+ /**
+ * @param {string} name
+ * @returns {UserValueWidget|null}
+ */
+ findWidget: function (name) {
+ for (const widget of this._userValueWidgets) {
+ if (widget.getName() === name) {
+ return widget;
+ }
+ const depWidget = widget.findWidget(name);
+ if (depWidget) {
+ return depWidget;
+ }
+ }
+ return null;
+ },
+ /**
+ * Returns the value that the widget would hold if it was active, by default
+ * the internal value it holds.
+ *
+ * @param {string} [methodName]
+ * @returns {string}
+ */
+ getActiveValue: function (methodName) {
+ return this._value;
+ },
+ /**
+ * Returns the default value the widget holds when inactive, by default the
+ * first "possible value".
+ *
+ * @param {string} [methodName]
+ * @returns {string}
+ */
+ getDefaultValue: function (methodName) {
+ const possibleValues = this._methodsParams.optionsPossibleValues[methodName];
+ return possibleValues && possibleValues[0] || '';
+ },
+ /**
+ * @returns {string[]}
+ */
+ getDependencies: function () {
+ return this._dependencies;
+ },
+ /**
+ * Returns the names of the option methods associated to the widget. Those
+ * are loaded with @see loadMethodsData.
+ *
+ * @returns {string[]}
+ */
+ getMethodsNames: function () {
+ return this._methodsNames;
+ },
+ /**
+ * Returns the option parameters associated to the widget (for a given
+ * method name or not). Most are loaded with @see loadMethodsData.
+ *
+ * @param {string} [methodName]
+ * @returns {Object}
+ */
+ getMethodsParams: function (methodName) {
+ const params = _.extend({}, this._methodsParams);
+ if (methodName) {
+ params.possibleValues = params.optionsPossibleValues[methodName] || [];
+ params.activeValue = this.getActiveValue(methodName);
+ params.defaultValue = this.getDefaultValue(methodName);
+ }
+ return params;
+ },
+ /**
+ * @returns {string} empty string if no name is used by the widget
+ */
+ getName: function () {
+ return this._methodsParams.name || '';
+ },
+ /**
+ * Returns the user value that the widget currently holds. The value is a
+ * string, this is the value that will be received in the option methods
+ * of SnippetOptionWidget instances.
+ *
+ * @param {string} [methodName]
+ * @returns {string}
+ */
+ getValue: function (methodName) {
+ const isActive = this.isActive();
+ if (!methodName || !this._methodsNames.includes(methodName)) {
+ return isActive ? 'true' : '';
+ }
+ if (isActive) {
+ return this.getActiveValue(methodName);
+ }
+ return this.getDefaultValue(methodName);
+ },
+ /**
+ * Returns whether or not the widget is active (holds a value).
+ *
+ * @returns {boolean}
+ */
+ isActive: function () {
+ return this._value && this._value !== NULL_ID;
+ },
+ /**
+ * Indicates if the widget can contain sub user value widgets or not.
+ *
+ * @returns {boolean}
+ */
+ isContainer: function () {
+ return false;
+ },
+ /**
+ * Indicates if the widget is being previewed or not: the user is
+ * manipulating it. Base case: if an internal <input/> element is focused.
+ *
+ * @returns {boolean}
+ */
+ isPreviewed: function () {
+ const focusEl = document.activeElement;
+ if (focusEl && focusEl.tagName === 'INPUT'
+ && (this.el === focusEl || this.el.contains(focusEl))) {
+ return true;
+ }
+ return this.el.classList.contains('o_we_preview');
+ },
+ /**
+ * Loads option method names and option method parameters.
+ *
+ * @param {string[]} validMethodNames
+ * @param {Object} extraParams
+ */
+ loadMethodsData: function (validMethodNames, extraParams) {
+ this._methodsNames = [];
+ this._methodsParams = _.extend({}, extraParams);
+ this._methodsParams.optionsPossibleValues = {};
+ this._dependencies = [];
+ this._triggerWidgetsNames = [];
+ this._triggerWidgetsValues = [];
+
+ for (const key in this.el.dataset) {
+ const dataValue = this.el.dataset[key].trim();
+
+ if (key === 'dependencies') {
+ this._dependencies.push(...dataValue.split(/\s*,\s*/g));
+ } else if (key === 'trigger') {
+ this._triggerWidgetsNames.push(...dataValue.split(/\s*,\s*/g));
+ } else if (key === 'triggerValue') {
+ this._triggerWidgetsValues.push(...dataValue.split(/\s*,\s*/g));
+ } else if (validMethodNames.includes(key)) {
+ this._methodsNames.push(key);
+ this._methodsParams.optionsPossibleValues[key] = dataValue.split(/\s*\|\s*/g);
+ } else {
+ this._methodsParams[key] = dataValue;
+ }
+ }
+ this._userValueWidgets.forEach(widget => {
+ const inheritedParams = _.extend({}, this._methodsParams);
+ inheritedParams.optionsPossibleValues = null;
+ widget.loadMethodsData(validMethodNames, inheritedParams);
+ const subMethodsNames = widget.getMethodsNames();
+ const subMethodsParams = widget.getMethodsParams();
+
+ for (const methodName of subMethodsNames) {
+ if (!this._methodsNames.includes(methodName)) {
+ this._methodsNames.push(methodName);
+ this._methodsParams.optionsPossibleValues[methodName] = [];
+ }
+ for (const subPossibleValue of subMethodsParams.optionsPossibleValues[methodName]) {
+ this._methodsParams.optionsPossibleValues[methodName].push(subPossibleValue);
+ }
+ }
+ });
+ for (const methodName of this._methodsNames) {
+ const arr = this._methodsParams.optionsPossibleValues[methodName];
+ const uniqArr = arr.filter((v, i, arr) => i === arr.indexOf(v));
+ this._methodsParams.optionsPossibleValues[methodName] = uniqArr;
+ }
+ },
+ /**
+ * @param {boolean} [previewMode=false]
+ * @param {boolean} [isSimulatedEvent=false]
+ */
+ notifyValueChange: function (previewMode, isSimulatedEvent) {
+ // If the widget has no associated method, it should not notify user
+ // value changes
+ if (!this._methodsNames.length) {
+ return;
+ }
+
+ // In the case we notify a change update, force a preview update if it
+ // was not already previewed
+ const isPreviewed = this.isPreviewed();
+ if (!previewMode && !isPreviewed) {
+ this.notifyValueChange(true);
+ }
+
+ const data = {
+ previewMode: previewMode || false,
+ isSimulatedEvent: !!isSimulatedEvent,
+ };
+ // TODO improve this. The preview state has to be updated only when the
+ // actual option _select is gonna be called... but this is delayed by a
+ // mutex. So, during test tours, we would notify both 'preview' and
+ // 'reset' before the 'preview' handling is done: and so the widget
+ // would not be considered in preview during that 'preview' handling.
+ if (previewMode === true || previewMode === false) {
+ // Note: the widgets need to be considered in preview mode during
+ // non-preview handling (a previewed checkbox is considered having
+ // an inverted state)... but if, for example, a modal opens before
+ // handling that non-preview, a 'reset' will be thrown thus removing
+ // the preview class. So we force it in non-preview too.
+ data.prepare = () => this.el.classList.add('o_we_preview');
+ } else if (previewMode === 'reset') {
+ data.prepare = () => this.el.classList.remove('o_we_preview');
+ }
+
+ this.trigger_up('user_value_update', data);
+ },
+ /**
+ * Opens the widget (only meaningful for widgets that can be opened).
+ */
+ open() {
+ this.trigger_up('user_value_widget_opening');
+ this.el.classList.add('o_we_widget_opened');
+ },
+ /**
+ * Adds the given widget to the known list of user value sub-widgets (useful
+ * for container widgets).
+ *
+ * @param {UserValueWidget} widget
+ */
+ registerSubWidget: function (widget) {
+ this._userValueWidgets.push(widget);
+ },
+ /**
+ * Sets the user value that the widget should currently hold, for the
+ * given method name.
+ *
+ * Note: a widget typically only holds one value for the only method it
+ * supports. However, widgets can have several methods; in that case, the
+ * value is typically received for a first method and receiving the value
+ * for other ones should not affect the widget (otherwise, it means the
+ * methods are conflicting with each other).
+ *
+ * @param {string} value
+ * @param {string} [methodName]
+ */
+ async setValue(value, methodName) {
+ this._value = value;
+ this.el.classList.remove('o_we_preview');
+ },
+ /**
+ * @param {boolean} show
+ */
+ toggleVisibility: function (show) {
+ this.el.classList.toggle('d-none', !show);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent|Event}
+ * @returns {boolean}
+ */
+ _handleNotifierEvent: function (ev) {
+ if (!ev) {
+ return true;
+ }
+ if (ev._seen) {
+ return false;
+ }
+ ev._seen = true;
+ if (ev.preventDefault) {
+ ev.preventDefault();
+ }
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Should be called when an user event on the widget indicates a value
+ * change.
+ *
+ * @private
+ * @param {OdooEvent|Event} [ev]
+ */
+ _onUserValueChange: function (ev) {
+ if (this._handleNotifierEvent(ev)) {
+ this.notifyValueChange(false);
+ }
+ },
+ /**
+ * Allows container widgets to add additional data if needed.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onUserValueNotification: function (ev) {
+ ev.data.widget = this;
+
+ if (!ev.data.triggerWidgetsNames) {
+ ev.data.triggerWidgetsNames = [];
+ }
+ ev.data.triggerWidgetsNames.push(...this._triggerWidgetsNames);
+
+ if (!ev.data.triggerWidgetsValues) {
+ ev.data.triggerWidgetsValues = [];
+ }
+ ev.data.triggerWidgetsValues.push(...this._triggerWidgetsValues);
+ },
+ /**
+ * Should be called when an user event on the widget indicates a value
+ * preview.
+ *
+ * @private
+ * @param {OdooEvent|Event} [ev]
+ */
+ _onUserValuePreview: function (ev) {
+ if (this._handleNotifierEvent(ev)) {
+ this.notifyValueChange(true);
+ }
+ },
+ /**
+ * Should be called when an user event on the widget indicates a value
+ * reset.
+ *
+ * @private
+ * @param {OdooEvent|Event} [ev]
+ */
+ _onUserValueReset: function (ev) {
+ if (this._handleNotifierEvent(ev)) {
+ this.notifyValueChange('reset');
+ }
+ },
+});
+
+const ButtonUserValueWidget = UserValueWidget.extend({
+ tagName: 'we-button',
+ events: {
+ 'click': '_onButtonClick',
+ 'click [role="button"]': '_onInnerButtonClick',
+ 'mouseenter': '_onUserValuePreview',
+ 'mouseleave': '_onUserValueReset',
+ },
+
+ /**
+ * @override
+ */
+ start: function (parent, title, options) {
+ if (this.options && this.options.childNodes) {
+ this.options.childNodes.forEach(node => this.containerEl.appendChild(node));
+ }
+
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getActiveValue: function (methodName) {
+ const possibleValues = this._methodsParams.optionsPossibleValues[methodName];
+ return possibleValues && possibleValues[possibleValues.length - 1] || '';
+ },
+ /**
+ * @override
+ */
+ isActive: function () {
+ return (this.isPreviewed() !== this.el.classList.contains('active'));
+ },
+ /**
+ * @override
+ */
+ loadMethodsData: function (validMethodNames) {
+ this._super.apply(this, arguments);
+ for (const methodName of this._methodsNames) {
+ const possibleValues = this._methodsParams.optionsPossibleValues[methodName];
+ if (possibleValues.length <= 1) {
+ possibleValues.unshift('');
+ }
+ }
+ },
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ await this._super(...arguments);
+ let active = !!value;
+ if (methodName) {
+ if (!this._methodsNames.includes(methodName)) {
+ return;
+ }
+ active = (this.getActiveValue(methodName) === value);
+ }
+ this.el.classList.toggle('active', active);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onButtonClick: function (ev) {
+ if (!ev._innerButtonClicked) {
+ this._onUserValueChange(ev);
+ }
+ },
+ /**
+ * @private
+ */
+ _onInnerButtonClick: function (ev) {
+ // Cannot just stop propagation as the click needs to be propagated to
+ // potential parent widgets for event delegation on those inner buttons.
+ ev._innerButtonClicked = true;
+ },
+});
+
+const CheckboxUserValueWidget = ButtonUserValueWidget.extend({
+ className: (ButtonUserValueWidget.prototype.className || '') + ' o_we_checkbox_wrapper',
+
+ /**
+ * @override
+ */
+ start: function () {
+ const checkboxEl = document.createElement('we-checkbox');
+ this.containerEl.appendChild(checkboxEl);
+
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ enable() {
+ this.$('we-checkbox').click();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onButtonClick(ev) {
+ if (!ev.target.closest('we-title, we-checkbox')) {
+ // Only consider clicks on the label and the checkbox control itself
+ return;
+ }
+ return this._super(...arguments);
+ },
+});
+
+const BaseSelectionUserValueWidget = UserValueWidget.extend({
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+
+ this.menuEl = document.createElement('we-selection-items');
+ if (this.options && this.options.childNodes) {
+ this.options.childNodes.forEach(node => this.menuEl.appendChild(node));
+ }
+ this.containerEl.appendChild(this.menuEl);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getMethodsParams(methodName) {
+ const params = this._super(...arguments);
+ const activeWidget = this._getActiveSubWidget();
+ if (!activeWidget) {
+ return params;
+ }
+ return Object.assign(activeWidget.getMethodsParams(...arguments), params);
+ },
+ /**
+ * @override
+ */
+ getValue(methodName) {
+ const activeWidget = this._getActiveSubWidget();
+ if (activeWidget) {
+ return activeWidget.getActiveValue(methodName);
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ isContainer() {
+ return true;
+ },
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ const _super = this._super.bind(this);
+ for (const widget of this._userValueWidgets) {
+ await widget.setValue(NULL_ID, methodName);
+ }
+ for (const widget of [...this._userValueWidgets].reverse()) {
+ await widget.setValue(value, methodName);
+ if (widget.isActive()) {
+ // Only one select item can be true at a time, we consider the
+ // last one if multiple would be active.
+ return;
+ }
+ }
+ await _super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {UserValueWidget|undefined}
+ */
+ _getActiveSubWidget() {
+ const previewedWidget = this._userValueWidgets.find(widget => widget.isPreviewed());
+ if (previewedWidget) {
+ return previewedWidget;
+ }
+ return this._userValueWidgets.find(widget => widget.isActive());
+ },
+});
+
+const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({
+ tagName: 'we-select',
+ events: {
+ 'click': '_onClick',
+ },
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+
+ if (this.options && this.options.valueEl) {
+ this.containerEl.insertBefore(this.options.valueEl, this.menuEl);
+ }
+
+ this.menuTogglerEl = document.createElement('we-toggler');
+ this.icon = this.el.dataset.icon || false;
+ if (this.icon) {
+ this.el.classList.add('o_we_icon_select');
+ const iconEl = document.createElement('i');
+ iconEl.classList.add('fa', 'fa-fw', this.icon);
+ this.menuTogglerEl.appendChild(iconEl);
+ }
+ this.containerEl.insertBefore(this.menuTogglerEl, this.menuEl);
+
+ const dropdownCaretEl = document.createElement('span');
+ dropdownCaretEl.classList.add('o_we_dropdown_caret');
+ this.containerEl.appendChild(dropdownCaretEl);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ close: function () {
+ this._super(...arguments);
+ if (this.menuTogglerEl) {
+ this.menuTogglerEl.classList.remove('active');
+ }
+ },
+ /**
+ * @override
+ */
+ isPreviewed: function () {
+ return this._super(...arguments) || this.menuTogglerEl.classList.contains('active');
+ },
+ /**
+ * @override
+ */
+ open() {
+ this._super(...arguments);
+ this.menuTogglerEl.classList.add('active');
+ },
+ /**
+ * @override
+ */
+ async setValue() {
+ await this._super(...arguments);
+
+ if (this.icon) {
+ return;
+ }
+
+ if (this.menuTogglerItemEl) {
+ this.menuTogglerItemEl.remove();
+ this.menuTogglerItemEl = null;
+ }
+
+ let textContent = '';
+ const activeWidget = this._userValueWidgets.find(widget => !widget.isPreviewed() && widget.isActive());
+ if (activeWidget) {
+ const svgTag = activeWidget.el.querySelector('svg'); // useful to avoid searching text content in svg element
+ const value = (activeWidget.el.dataset.selectLabel || (!svgTag && activeWidget.el.textContent.trim()));
+ const imgSrc = activeWidget.el.dataset.img;
+ if (value) {
+ textContent = value;
+ } else if (imgSrc) {
+ this.menuTogglerItemEl = document.createElement('img');
+ this.menuTogglerItemEl.src = imgSrc;
+ } else {
+ const fakeImgEl = activeWidget.el.querySelector('.o_we_fake_img_item');
+ if (fakeImgEl) {
+ this.menuTogglerItemEl = fakeImgEl.cloneNode(true);
+ }
+ }
+ } else {
+ textContent = "/";
+ }
+
+ this.menuTogglerEl.textContent = textContent;
+ if (this.menuTogglerItemEl) {
+ this.menuTogglerEl.appendChild(this.menuTogglerItemEl);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _shouldIgnoreClick(ev) {
+ return !!ev.target.closest('[role="button"]');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the select is clicked anywhere -> open/close it.
+ *
+ * @private
+ */
+ _onClick: function (ev) {
+ if (this._shouldIgnoreClick(ev)) {
+ return;
+ }
+
+ if (!this.menuTogglerEl.classList.contains('active')) {
+ this.open();
+ } else {
+ this.close();
+ }
+ const activeButton = this._userValueWidgets.find(widget => widget.isActive());
+ if (activeButton) {
+ this.menuEl.scrollTop = activeButton.el.offsetTop - (this.menuEl.offsetHeight / 2);
+ }
+ },
+});
+
+const ButtonGroupUserValueWidget = BaseSelectionUserValueWidget.extend({
+ tagName: 'we-button-group',
+});
+
+const UnitUserValueWidget = UserValueWidget.extend({
+ /**
+ * @override
+ */
+ start: async function () {
+ const unit = this.el.dataset.unit || '';
+ this.el.dataset.unit = unit;
+ if (this.el.dataset.saveUnit === undefined) {
+ this.el.dataset.saveUnit = unit;
+ }
+
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getActiveValue: function (methodName) {
+ const activeValue = this._super(...arguments);
+
+ const params = this._methodsParams;
+ if (!params.unit) {
+ return activeValue;
+ }
+
+ const defaultValue = this.getDefaultValue(methodName, false);
+
+ return activeValue.split(/\s+/g).map(v => {
+ const numValue = parseFloat(v);
+ if (isNaN(numValue)) {
+ return defaultValue;
+ } else {
+ const value = weUtils.convertNumericToUnit(numValue, params.unit, params.saveUnit, params.cssProperty, this.$target);
+ return `${this._floatToStr(value)}${params.saveUnit}`;
+ }
+ }).join(' ');
+ },
+ /**
+ * @override
+ * @param {boolean} [useInputUnit=false]
+ */
+ getDefaultValue: function (methodName, useInputUnit) {
+ const defaultValue = this._super(...arguments);
+
+ const params = this._methodsParams;
+ if (!params.unit) {
+ return defaultValue;
+ }
+
+ const unit = useInputUnit ? params.unit : params.saveUnit;
+ const numValue = weUtils.convertValueToUnit(defaultValue || '0', unit, params.cssProperty, this.$target);
+ if (isNaN(numValue)) {
+ return defaultValue;
+ }
+ return `${this._floatToStr(numValue)}${unit}`;
+ },
+ /**
+ * @override
+ */
+ isActive: function () {
+ const isSuperActive = this._super(...arguments);
+ const params = this._methodsParams;
+ if (!params.unit) {
+ return isSuperActive;
+ }
+ return isSuperActive && this._floatToStr(parseFloat(this._value)) !== '0';
+ },
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ const params = this._methodsParams;
+ if (params.unit) {
+ value = value.split(' ').map(v => {
+ const numValue = weUtils.convertValueToUnit(v, params.unit, params.cssProperty, this.$target);
+ if (isNaN(numValue)) {
+ return ''; // Something not supported
+ }
+ return this._floatToStr(numValue);
+ }).join(' ');
+ }
+ return this._super(value, methodName);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Converts a floating value to a string, rounded to 5 digits without zeros.
+ *
+ * @private
+ * @param {number} value
+ * @returns {string}
+ */
+ _floatToStr: function (value) {
+ return `${parseFloat(value.toFixed(5))}`;
+ },
+});
+
+const InputUserValueWidget = UnitUserValueWidget.extend({
+ tagName: 'we-input',
+ events: {
+ 'input input': '_onInputInput',
+ 'blur input': '_onInputBlur',
+ 'keydown input': '_onInputKeydown',
+ },
+
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+
+ const unit = this.el.dataset.unit;
+ this.inputEl = document.createElement('input');
+ this.inputEl.setAttribute('type', 'text');
+ this.inputEl.setAttribute('autocomplete', 'chrome-off');
+ this.inputEl.setAttribute('placeholder', this.el.getAttribute('placeholder') || '');
+ this.inputEl.classList.toggle('text-left', !unit);
+ this.inputEl.classList.toggle('text-right', !!unit);
+ this.containerEl.appendChild(this.inputEl);
+
+ var unitEl = document.createElement('span');
+ unitEl.textContent = unit;
+ this.containerEl.appendChild(unitEl);
+ if (unit.length > 3) {
+ this.el.classList.add('o_we_large_input');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async setValue() {
+ await this._super(...arguments);
+ this.inputEl.value = this._value;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInputInput: function (ev) {
+ this._value = this.inputEl.value;
+ this._onUserValuePreview(ev);
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInputBlur: function (ev) {
+ // Sometimes, an input is focusout for internal reason (like an undo
+ // recording) then focused again manually in the same JS stack
+ // execution. In that case, the blur should not trigger an option
+ // selection as the user did not leave the input. We thus defer the blur
+ // handling to then check that the target is indeed still blurred before
+ // executing the actual option selection.
+ setTimeout(() => {
+ if (ev.currentTarget === document.activeElement) {
+ return;
+ }
+ this._onUserValueChange(ev);
+ });
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInputKeydown: function (ev) {
+ switch (ev.which) {
+ case $.ui.keyCode.ENTER: {
+ this._onUserValueChange(ev);
+ break;
+ }
+ case $.ui.keyCode.UP:
+ case $.ui.keyCode.DOWN: {
+ const input = ev.currentTarget;
+ const params = this._methodsParams;
+ if (!params.unit && !params.step) {
+ break;
+ }
+ let value = parseFloat(input.value || input.placeholder);
+ if (isNaN(value)) {
+ value = 0.0;
+ }
+ let step = parseFloat(params.step);
+ if (isNaN(step)) {
+ step = 1.0;
+ }
+ value += (ev.which === $.ui.keyCode.UP ? step : -step);
+ input.value = this._floatToStr(value);
+ $(input).trigger('input');
+ break;
+ }
+ }
+ },
+});
+
+const MultiUserValueWidget = UserValueWidget.extend({
+ tagName: 'we-multi',
+
+ /**
+ * @override
+ */
+ start: function () {
+ if (this.options && this.options.childNodes) {
+ this.options.childNodes.forEach(node => this.containerEl.appendChild(node));
+ }
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getValue: function (methodName) {
+ const value = this._userValueWidgets.map(widget => {
+ return widget.getValue(methodName);
+ }).join(' ').trim();
+
+ return value || this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ isContainer: function () {
+ return true;
+ },
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ let values = value.split(/\s*\|\s*/g);
+ if (values.length === 1) {
+ values = value.split(/\s+/g);
+ }
+ for (let i = 0; i < this._userValueWidgets.length - 1; i++) {
+ await this._userValueWidgets[i].setValue(values.shift() || '', methodName);
+ }
+ await this._userValueWidgets[this._userValueWidgets.length - 1].setValue(values.join(' '), methodName);
+ },
+});
+
+const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
+ className: (SelectUserValueWidget.prototype.className || '') + ' o_we_so_color_palette',
+ custom_events: _.extend({}, SelectUserValueWidget.prototype.custom_events, {
+ 'custom_color_picked': '_onCustomColorPicked',
+ 'color_picked': '_onColorPicked',
+ 'color_hover': '_onColorHovered',
+ 'color_leave': '_onColorLeft',
+ 'enter_key_color_colorpicker': '_onEnterKey'
+ }),
+
+ /**
+ * @override
+ */
+ start: async function () {
+ const _super = this._super.bind(this);
+ const args = arguments;
+
+ if (this.options.dataAttributes.lazyPalette === 'true') {
+ // TODO review in master, this was done in stable to keep the speed
+ // fix as stable as possible (to have a reference to a widget even
+ // if not a colorPalette widget).
+ this.colorPalette = new Widget(this);
+ this.colorPalette.getColorNames = () => [];
+ await this.colorPalette.appendTo(document.createDocumentFragment());
+ } else {
+ await this._renderColorPalette();
+ }
+
+ // Build the select element with a custom span to hold the color preview
+ this.colorPreviewEl = document.createElement('span');
+ this.colorPreviewEl.classList.add('o_we_color_preview');
+ this.options.childNodes = [this.colorPalette.el];
+ this.options.valueEl = this.colorPreviewEl;
+
+ return _super(...args);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ open: function () {
+ if (this.colorPalette.setSelectedColor) {
+ this.colorPalette.setSelectedColor(this._value);
+ } else {
+ // TODO review in master, this does async stuff. Maybe the open
+ // method should now be async. This is not really robust as the
+ // colorPalette can be used without it to be fully rendered but
+ // the use of the saved promise where we can should mitigate that
+ // issue.
+ this._colorPaletteRenderPromise = this._renderColorPalette();
+ }
+ this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ close: function () {
+ this._super(...arguments);
+ if (this._customColorValue && this._customColorValue !== this._value) {
+ this._value = this._customColorValue;
+ this._customColorValue = false;
+ this._onUserValueChange();
+ }
+ },
+ /**
+ * @override
+ */
+ getMethodsParams: function () {
+ return _.extend(this._super(...arguments), {
+ colorNames: this.colorPalette.getColorNames(),
+ });
+ },
+ /**
+ * @override
+ */
+ getValue: function (methodName) {
+ if (typeof this._previewColor === 'string') {
+ return this._previewColor;
+ }
+ if (typeof this._customColorValue === 'string') {
+ return this._customColorValue;
+ }
+ let value = this._super(...arguments);
+ if (value) {
+ const useCssColor = this.options.dataAttributes.hasOwnProperty('useCssColor');
+ const cssCompatible = this.options.dataAttributes.hasOwnProperty('cssCompatible');
+ if ((useCssColor || cssCompatible) && !ColorpickerWidget.isCSSColor(value)) {
+ if (useCssColor) {
+ value = weUtils.getCSSVariableValue(value);
+ } else {
+ value = `var(--${value})`;
+ }
+ }
+ }
+ return value;
+ },
+ /**
+ * @override
+ */
+ isContainer: function () {
+ return false;
+ },
+ /**
+ * @override
+ */
+ isActive: function () {
+ return !weUtils.areCssValuesEqual(this._value, 'rgba(0, 0, 0, 0)');
+ },
+ /**
+ * Updates the color preview + re-render the whole color palette widget.
+ *
+ * @override
+ */
+ async setValue(color) {
+ await this._super(...arguments);
+
+ await this._colorPaletteRenderPromise;
+
+ const classes = weUtils.computeColorClasses(this.colorPalette.getColorNames());
+ this.colorPreviewEl.classList.remove(...classes);
+ this.colorPreviewEl.style.removeProperty('background-color');
+
+ if (this._value) {
+ if (ColorpickerWidget.isCSSColor(this._value)) {
+ this.colorPreviewEl.style.backgroundColor = this._value;
+ } else if (weUtils.isColorCombinationName(this._value)) {
+ this.colorPreviewEl.classList.add('o_cc', `o_cc${this._value}`);
+ } else {
+ this.colorPreviewEl.classList.add(`bg-${this._value}`);
+ }
+ }
+ },
+
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _renderColorPalette: function () {
+ const options = {
+ selectedColor: this._value,
+ };
+ if (this.options.dataAttributes.excluded) {
+ options.excluded = this.options.dataAttributes.excluded.replace(/ /g, '').split(',');
+ }
+ if (this.options.dataAttributes.withCombinations) {
+ options.withCombinations = !!this.options.dataAttributes.withCombinations;
+ }
+ const oldColorPalette = this.colorPalette;
+ this.colorPalette = new ColorPaletteWidget(this, options);
+ if (oldColorPalette) {
+ return this.colorPalette.insertAfter(oldColorPalette.el).then(() => {
+ oldColorPalette.destroy();
+ });
+ }
+ return this.colorPalette.appendTo(document.createDocumentFragment());
+ },
+ /**
+ * @override
+ */
+ _shouldIgnoreClick(ev) {
+ return ev.originalEvent.__isColorpickerClick || this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a custom color is selected -> preview the color
+ * and set the current value. Update of this value on close
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onCustomColorPicked: function (ev) {
+ this._customColorValue = ev.data.color;
+ },
+ /**
+ * Called when a color button is clicked -> confirms the preview.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorPicked: function (ev) {
+ this._previewColor = false;
+ this._customColorValue = false;
+ this._value = ev.data.color;
+ this._onUserValueChange(ev);
+ },
+ /**
+ * Called when a color button is entered -> previews the background color.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorHovered: function (ev) {
+ this._previewColor = ev.data.color;
+ this._onUserValuePreview(ev);
+ },
+ /**
+ * Called when a color button is left -> cancels the preview.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorLeft: function (ev) {
+ this._previewColor = false;
+ this._onUserValueReset(ev);
+ },
+ /**
+ * @private
+ */
+ _onEnterKey: function () {
+ this.close();
+ },
+});
+
+const MediapickerUserValueWidget = UserValueWidget.extend({
+ tagName: 'we-button',
+ events: {
+ 'click': '_onEditMedia',
+ },
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+ const iconEl = document.createElement('i');
+ if (this.options.dataAttributes.buttonStyle) {
+ iconEl.classList.add('fa', 'fa-fw', 'fa-camera');
+ } else {
+ iconEl.classList.add('fa', 'fa-fw', 'fa-refresh', 'mr-1');
+ this.el.classList.add('o_we_no_toggle');
+ this.containerEl.textContent = _t("Replace media");
+ }
+ $(this.containerEl).prepend(iconEl);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Creates and opens a media dialog to edit a given element's media.
+ *
+ * @private
+ * @param {HTMLElement} el the element whose media should be edited
+ * @param {boolean} [images] whether images should be available
+ * default: false
+ * @param {boolean} [videos] whether videos should be available
+ * default: false
+ */
+ _openDialog(el, {images = false, videos = false}) {
+ el.src = this._value;
+ const $editable = this.$target.closest('.o_editable');
+ const mediaDialog = new weWidgets.MediaDialog(this, {
+ noImages: !images,
+ noVideos: !videos,
+ noIcons: true,
+ noDocuments: true,
+ isForBgVideo: true,
+ 'res_model': $editable.data('oe-model'),
+ 'res_id': $editable.data('oe-id'),
+ }, el).open();
+ return mediaDialog;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async setValue() {
+ await this._super(...arguments);
+ this.el.classList.toggle('active', this.isActive());
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the edit button is clicked.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onEditMedia: function (ev) {},
+});
+
+const ImagepickerUserValueWidget = MediapickerUserValueWidget.extend({
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onEditMedia(ev) {
+ // Need a dummy element for the media dialog to modify.
+ const dummyEl = document.createElement('img');
+ const dialog = this._openDialog(dummyEl, {images: true});
+ dialog.on('save', this, data => {
+ // Accessing the value directly through dummyEl.src converts the url to absolute,
+ // using getAttribute allows us to keep the url as it was inserted in the DOM
+ // which can be useful to compare it to values stored in db.
+ this._value = dummyEl.getAttribute('src');
+ this._onUserValueChange();
+ });
+ },
+});
+
+const VideopickerUserValueWidget = MediapickerUserValueWidget.extend({
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onEditMedia(ev) {
+ // Need a dummy element for the media dialog to modify.
+ const dummyEl = document.createElement('iframe');
+ const dialog = this._openDialog(dummyEl, {videos: true});
+ dialog.on('save', this, data => {
+ this._value = data.bgVideoSrc;
+ this._onUserValueChange();
+ });
+ },
+});
+
+const DatetimePickerUserValueWidget = InputUserValueWidget.extend({
+ events: { // Explicitely not consider all InputUserValueWidget events
+ 'blur input': '_onInputBlur',
+ 'change.datetimepicker': '_onDateTimePickerChange',
+ 'error.datetimepicker': '_onDateTimePickerError',
+ },
+
+ /**
+ * @override
+ */
+ init: function () {
+ this._super(...arguments);
+ this._value = moment().unix().toString();
+ this.__libInput = 0;
+ },
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+
+ const datetimePickerId = _.uniqueId('datetimepicker');
+ this.el.classList.add('o_we_large_input');
+ this.inputEl.classList.add('datetimepicker-input', 'mx-0', 'text-left');
+ this.inputEl.setAttribute('id', datetimePickerId);
+ this.inputEl.setAttribute('data-target', '#' + datetimePickerId);
+
+ const datepickersOptions = {
+ minDate: moment({ y: 1000 }),
+ maxDate: moment().add(200, 'y'),
+ calendarWeeks: true,
+ defaultDate: moment().format(),
+ icons: {
+ close: 'fa fa-check primary',
+ },
+ locale: moment.locale(),
+ format: time.getLangDatetimeFormat(),
+ sideBySide: true,
+ buttons: {
+ showClose: true,
+ showToday: true,
+ },
+ widgetParent: 'body',
+
+ // Open the datetimepicker on focus not on click. This allows to
+ // take care of a bug which is due to the summernote editor:
+ // sometimes, the datetimepicker loses the focus then get it back
+ // in the same execution flow. This was making the datepicker close
+ // for no apparent reason. Now, it only closes then reopens directly
+ // without it be possible to notice.
+ allowInputToggle: true,
+ };
+ this.__libInput++;
+ const $input = $(this.inputEl);
+ $input.datetimepicker(datepickersOptions);
+ this.__libInput--;
+
+ // Monkey-patch the library option to add custom classes on the pickers
+ const libObject = $input.data('datetimepicker');
+ const oldFunc = libObject._getTemplate;
+ libObject._getTemplate = function () {
+ const $template = oldFunc.call(this, ...arguments);
+ $template.addClass('o_we_no_overlay o_we_datetimepicker');
+ return $template;
+ };
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ isPreviewed: function () {
+ return this._super(...arguments) || !!$(this.inputEl).data('datetimepicker').widget;
+ },
+ /**
+ * @override
+ */
+ async setValue() {
+ await this._super(...arguments);
+ let momentObj = moment.unix(this._value);
+ if (!momentObj.isValid()) {
+ momentObj = moment();
+ }
+ this.__libInput++;
+ $(this.inputEl).datetimepicker('date', momentObj);
+ this.__libInput--;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onDateTimePickerChange: function (ev) {
+ if (this.__libInput > 0) {
+ return;
+ }
+ if (!ev.date || !ev.date.isValid()) {
+ return;
+ }
+ this._value = ev.date.unix().toString();
+ this._onUserValuePreview(ev);
+ },
+ /**
+ * Prevents crash manager to throw CORS error. Note that library already
+ * clears the wrong date format.
+ */
+ _onDateTimePickerError: function (ev) {
+ ev.stopPropagation();
+ },
+});
+
+const RangeUserValueWidget = UnitUserValueWidget.extend({
+ tagName: 'we-range',
+ events: {
+ 'change input': '_onInputChange',
+ },
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+ this.input = document.createElement('input');
+ this.input.type = "range";
+ let min = this.el.dataset.min && parseFloat(this.el.dataset.min) || 0;
+ let max = this.el.dataset.max && parseFloat(this.el.dataset.max) || 100;
+ const step = this.el.dataset.step && parseFloat(this.el.dataset.step) || 1;
+ if (min > max) {
+ [min, max] = [max, min];
+ this.input.classList.add('o_we_inverted_range');
+ }
+ this.input.setAttribute('min', min);
+ this.input.setAttribute('max', max);
+ this.input.setAttribute('step', step);
+ this.containerEl.appendChild(this.input);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ await this._super(...arguments);
+ this.input.value = this._value;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onInputChange(ev) {
+ this._value = ev.target.value;
+ this._onUserValueChange(ev);
+ },
+});
+
+const SelectPagerUserValueWidget = SelectUserValueWidget.extend({
+ className: (SelectUserValueWidget.prototype.className || '') + ' o_we_select_pager',
+ events: Object.assign({}, SelectUserValueWidget.prototype.events, {
+ 'click .o_we_pager_next, .o_we_pager_prev': '_onPageChange',
+ }),
+
+ /**
+ * @override
+ */
+ async start() {
+ const _super = this._super.bind(this);
+ this.pages = this.options.childNodes.filter(node => node.matches && node.matches('we-select-page'));
+ this.numPages = this.pages.length;
+
+ const prev = document.createElement('i');
+ prev.classList.add('o_we_pager_prev', 'fa', 'fa-chevron-left');
+
+ this.pageNum = document.createElement('span');
+ this.currentPage = 0;
+
+ const next = document.createElement('i');
+ next.classList.add('o_we_pager_next', 'fa', 'fa-chevron-right');
+
+ const pagerControls = document.createElement('div');
+ pagerControls.classList.add('o_we_pager_controls');
+ pagerControls.appendChild(prev);
+ pagerControls.appendChild(this.pageNum);
+ pagerControls.appendChild(next);
+
+ this.pageName = document.createElement('b');
+ const pagerHeader = document.createElement('div');
+ pagerHeader.classList.add('o_we_pager_header');
+ pagerHeader.appendChild(this.pageName);
+ pagerHeader.appendChild(pagerControls);
+
+ await _super(...arguments);
+ this.menuEl.classList.add('o_we_has_pager');
+ $(this.menuEl).prepend(pagerHeader);
+ this._updatePage();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _shouldIgnoreClick(ev) {
+ return !!ev.target.closest('.o_we_pager_header') || this._super(...arguments);
+ },
+ /**
+ * Updates the pager's page number display.
+ *
+ * @private
+ */
+ _updatePage() {
+ this.pages.forEach((page, i) => page.classList.toggle('active', i === this.currentPage));
+ this.pageNum.textContent = `${this.currentPage + 1}/${this.numPages}`;
+ const activePage = this.pages.find((page, i) => i === this.currentPage);
+ this.pageName.textContent = activePage.getAttribute('string');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Goes to the previous/next page with wrap-around.
+ *
+ * @private
+ */
+ _onPageChange(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const delta = ev.target.matches('.o_we_pager_next') ? 1 : -1;
+ this.currentPage = (this.currentPage + this.numPages + delta) % this.numPages;
+ this._updatePage();
+ },
+ /**
+ * @override
+ */
+ _onClick(ev) {
+ const activeButton = this._getActiveSubWidget();
+ if (activeButton) {
+ const currentPage = this.pages.indexOf(activeButton.el.closest('we-select-page'));
+ if (currentPage !== -1) {
+ this.currentPage = currentPage;
+ this._updatePage();
+ }
+ }
+ return this._super(...arguments);
+ },
+});
+
+const userValueWidgetsRegistry = {
+ 'we-button': ButtonUserValueWidget,
+ 'we-checkbox': CheckboxUserValueWidget,
+ 'we-select': SelectUserValueWidget,
+ 'we-button-group': ButtonGroupUserValueWidget,
+ 'we-input': InputUserValueWidget,
+ 'we-multi': MultiUserValueWidget,
+ 'we-colorpicker': ColorpickerUserValueWidget,
+ 'we-datetimepicker': DatetimePickerUserValueWidget,
+ 'we-imagepicker': ImagepickerUserValueWidget,
+ 'we-videopicker': VideopickerUserValueWidget,
+ 'we-range': RangeUserValueWidget,
+ 'we-select-pager': SelectPagerUserValueWidget,
+};
+
+/**
+ * Handles a set of options for one snippet. The registry returned by this
+ * module contains the names of the specialized SnippetOptionWidget which can be
+ * referenced thanks to the data-js key in the web_editor options template.
+ */
+const SnippetOptionWidget = Widget.extend({
+ tagName: 'we-customizeblock-option',
+ events: {
+ 'click .o_we_collapse_toggler': '_onCollapseTogglerClick',
+ },
+ custom_events: {
+ 'user_value_update': '_onUserValueUpdate',
+ 'user_value_widget_critical': '_onUserValueWidgetCritical',
+ },
+ /**
+ * Indicates if the option should be displayed in the button group at the
+ * top of the options panel, next to the clone/remove button.
+ *
+ * @type {boolean}
+ */
+ isTopOption: false,
+ /**
+ * Forces the target to not be possible to remove.
+ *
+ * @type {boolean}
+ */
+ forceNoDeleteButton: false,
+
+ /**
+ * The option `$el` is supposed to be the associated DOM UI element.
+ * The option controls another DOM element: the snippet it
+ * customizes, which can be found at `$target`. Access to the whole edition
+ * overlay is possible with `$overlay` (this is not recommended though).
+ *
+ * @constructor
+ */
+ init: function (parent, $uiElements, $target, $overlay, data, options) {
+ this._super.apply(this, arguments);
+
+ this.$originalUIElements = $uiElements;
+
+ this.$target = $target;
+ this.$overlay = $overlay;
+ this.data = data;
+ this.options = options;
+
+ this.className = 'snippet-option-' + this.data.optionName;
+
+ this.ownerDocument = this.$target[0].ownerDocument;
+
+ this._userValueWidgets = [];
+ this._actionQueues = new Map();
+ },
+ /**
+ * @override
+ */
+ willStart: async function () {
+ await this._super(...arguments);
+ return this._renderOriginalXML().then(uiFragment => {
+ this.uiFragment = uiFragment;
+ });
+ },
+ /**
+ * @override
+ */
+ renderElement: function () {
+ this._super(...arguments);
+ this.el.appendChild(this.uiFragment);
+ this.uiFragment = null;
+ },
+ /**
+ * Called when the parent edition overlay is covering the associated snippet
+ * (the first time, this follows the call to the @see start method).
+ *
+ * @abstract
+ */
+ onFocus: function () {},
+ /**
+ * Called when the parent edition overlay is covering the associated snippet
+ * for the first time, when it is a new snippet dropped from the d&d snippet
+ * menu. Note: this is called after the start and onFocus methods.
+ *
+ * @abstract
+ */
+ onBuilt: function () {},
+ /**
+ * Called when the parent edition overlay is removed from the associated
+ * snippet (another snippet enters edition for example).
+ *
+ * @abstract
+ */
+ onBlur: function () {},
+ /**
+ * Called when the associated snippet is the result of the cloning of
+ * another snippet (so `this.$target` is a cloned element).
+ *
+ * @abstract
+ * @param {Object} options
+ * @param {boolean} options.isCurrent
+ * true if the associated snippet is a clone of the main element that
+ * was cloned (so not a clone of a child of this main element that
+ * was cloned)
+ */
+ onClone: function (options) {},
+ /**
+ * Called when the associated snippet is moved to another DOM location.
+ *
+ * @abstract
+ */
+ onMove: function () {},
+ /**
+ * Called when the associated snippet is about to be removed from the DOM.
+ *
+ * @abstract
+ */
+ onRemove: function () {},
+ /**
+ * Called when the target is shown, only meaningful if the target was hidden
+ * at some point (typically used for 'invisible' snippets).
+ *
+ * @abstract
+ * @returns {Promise|undefined}
+ */
+ onTargetShow: async function () {},
+ /**
+ * Called when the target is hidden (typically used for 'invisible'
+ * snippets).
+ *
+ * @abstract
+ * @returns {Promise|undefined}
+ */
+ onTargetHide: async function () {},
+ /**
+ * Called when the template which contains the associated snippet is about
+ * to be saved.
+ *
+ * @abstract
+ * @return {Promise|undefined}
+ */
+ cleanForSave: async function () {},
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Default option method which allows to select one and only one class in
+ * the option classes set and set it on the associated snippet. The common
+ * case is having a select with each item having a `data-select-class`
+ * value allowing to choose the associated class, or simply an unique
+ * checkbox to allow toggling a unique class.
+ *
+ * @param {boolean|string} previewMode
+ * - truthy if the option is enabled for preview or if leaving it (in
+ * that second case, the value is 'reset')
+ * - false if the option should be activated for good
+ * @param {string} widgetValue
+ * @param {Object} params
+ * @returns {Promise|undefined}
+ */
+ selectClass: function (previewMode, widgetValue, params) {
+ for (const classNames of params.possibleValues) {
+ if (classNames) {
+ this.$target[0].classList.remove(...classNames.trim().split(/\s+/g));
+ }
+ }
+ if (widgetValue) {
+ this.$target[0].classList.add(...widgetValue.trim().split(/\s+/g));
+ }
+ },
+ /**
+ * Default option method which allows to select a value and set it on the
+ * associated snippet as a data attribute. The name of the data attribute is
+ * given by the attributeName parameter.
+ *
+ * @param {boolean} previewMode - @see this.selectClass
+ * @param {string} widgetValue
+ * @param {Object} params
+ * @returns {Promise|undefined}
+ */
+ selectDataAttribute: function (previewMode, widgetValue, params) {
+ const value = this._selectAttributeHelper(widgetValue, params);
+ this.$target[0].dataset[params.attributeName] = value;
+ },
+ /**
+ * Default option method which allows to select a value and set it on the
+ * associated snippet as an attribute. The name of the attribute is
+ * given by the attributeName parameter.
+ *
+ * @param {boolean} previewMode - @see this.selectClass
+ * @param {string} widgetValue
+ * @param {Object} params
+ * @returns {Promise|undefined}
+ */
+ selectAttribute: function (previewMode, widgetValue, params) {
+ const value = this._selectAttributeHelper(widgetValue, params);
+ this.$target[0].setAttribute(params.attributeName, value);
+ },
+ /**
+ * Default option method which allows to select a value and set it on the
+ * associated snippet as a css style. The name of the css property is
+ * given by the cssProperty parameter.
+ *
+ * @param {boolean} previewMode - @see this.selectClass
+ * @param {string} widgetValue
+ * @param {Object} params
+ * @returns {Promise|undefined}
+ */
+ selectStyle: function (previewMode, widgetValue, params) {
+ // Disable all transitions for the duration of the method as many
+ // comparisons will be done on the element to know if applying a
+ // property has an effect or not. Also, changing a css property via the
+ // editor should not show any transition as previews would not be done
+ // immediately, which is not good for the user experience.
+ this.$target[0].classList.add('o_we_force_no_transition');
+ const _restoreTransitions = () => this.$target[0].classList.remove('o_we_force_no_transition');
+
+ if (params.cssProperty === 'background-color') {
+ this.$target.trigger('background-color-event', previewMode);
+ }
+
+ const cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty];
+ for (const cssProp of cssProps) {
+ // Always reset the inline style first to not put inline style on an
+ // element which already have this style through css stylesheets.
+ this.$target[0].style.setProperty(cssProp, '');
+ }
+ if (params.extraClass) {
+ this.$target.removeClass(params.extraClass);
+ }
+
+ // Only allow to use a color name as a className if we know about the
+ // other potential color names (to remove) and if we know about a prefix
+ // (otherwise we suppose that we should use the actual related color).
+ if (params.colorNames && params.colorPrefix) {
+ const classes = weUtils.computeColorClasses(params.colorNames, params.colorPrefix);
+ this.$target[0].classList.remove(...classes);
+
+ if (weUtils.isColorCombinationName(widgetValue)) {
+ // Those are the special color combinations classes. Just have
+ // to add it (and adding the potential extra class) then leave.
+ this.$target[0].classList.add('o_cc', `o_cc${widgetValue}`, params.extraClass);
+ _restoreTransitions();
+ return;
+ }
+ if (params.colorNames.includes(widgetValue)) {
+ const originalCSSValue = window.getComputedStyle(this.$target[0])[cssProps[0]];
+ const className = params.colorPrefix + widgetValue;
+ this.$target[0].classList.add(className);
+ if (originalCSSValue !== window.getComputedStyle(this.$target[0])[cssProps[0]]) {
+ // If applying the class did indeed changed the css
+ // property we are editing, nothing more has to be done.
+ // (except adding the extra class)
+ this.$target.addClass(params.extraClass);
+ _restoreTransitions();
+ return;
+ }
+ // Otherwise, it means that class probably does not exist,
+ // we remove it and continue. Especially useful for some
+ // prefixes which only work with some color names but not all.
+ this.$target[0].classList.remove(className);
+ }
+ }
+
+ // At this point, the widget value is either a property/color name or
+ // an actual css property value. If it is a property/color name, we will
+ // apply a css variable as style value.
+ const htmlPropValue = weUtils.getCSSVariableValue(widgetValue);
+ if (htmlPropValue) {
+ widgetValue = `var(--${widgetValue})`;
+ }
+
+ // replacing ', ' by ',' to prevent attributes with internal space separators from being split:
+ // eg: "rgba(55, 12, 47, 1.9) 47px" should be split as ["rgba(55,12,47,1.9)", "47px"]
+ const values = widgetValue.replace(/,\s/g, ',').split(/\s+/g);
+ while (values.length < cssProps.length) {
+ switch (values.length) {
+ case 1:
+ case 2: {
+ values.push(values[0]);
+ break;
+ }
+ case 3: {
+ values.push(values[1]);
+ break;
+ }
+ default: {
+ values.push(values[values.length - 1]);
+ }
+ }
+ }
+
+ const styles = window.getComputedStyle(this.$target[0]);
+ let hasUserValue = false;
+ for (let i = cssProps.length - 1; i > 0; i--) {
+ hasUserValue = applyCSS.call(this, cssProps[i], values.pop(), styles) || hasUserValue;
+ }
+ hasUserValue = applyCSS.call(this, cssProps[0], values.join(' '), styles) || hasUserValue;
+
+ function applyCSS(cssProp, cssValue, styles) {
+ if (!weUtils.areCssValuesEqual(styles[cssProp], cssValue)) {
+ this.$target[0].style.setProperty(cssProp, cssValue, 'important');
+ return true;
+ }
+ return false;
+ }
+
+ if (params.extraClass) {
+ this.$target.toggleClass(params.extraClass, hasUserValue);
+ }
+
+ _restoreTransitions();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override the helper method to search inside the $target element instead
+ * of the UI item element.
+ *
+ * @override
+ */
+ $: function () {
+ return this.$target.find.apply(this.$target, arguments);
+ },
+ /**
+ * Closes all user value widgets.
+ */
+ closeWidgets: function () {
+ this._userValueWidgets.forEach(widget => widget.close());
+ },
+ /**
+ * @param {string} name
+ * @returns {UserValueWidget|null}
+ */
+ findWidget: function (name) {
+ for (const widget of this._userValueWidgets) {
+ if (widget.getName() === name) {
+ return widget;
+ }
+ const depWidget = widget.findWidget(name);
+ if (depWidget) {
+ return depWidget;
+ }
+ }
+ return null;
+ },
+ /**
+ * Sometimes, options may need to notify other options, even in parent
+ * editors. This can be done thanks to the 'option_update' event, which
+ * will then be handled by this function.
+ *
+ * @param {string} name - an identifier for a type of update
+ * @param {*} data
+ * @returns {Promise}
+ */
+ notify: function (name, data) {
+ if (name === 'target') {
+ this.setTarget(data);
+ }
+ },
+ /**
+ * Sometimes, an option is binded on an element but should in fact apply on
+ * another one. For example, elements which contain slides: we want all the
+ * per-slide options to be in the main menu of the whole snippet. This
+ * function allows to set the option's target.
+ *
+ * Note: the UI is not updated accordindly automatically.
+ *
+ * @param {jQuery} $target - the new target element
+ * @returns {Promise}
+ */
+ setTarget: function ($target) {
+ this.$target = $target;
+ },
+ /**
+ * Updates the UI. For widget update, @see _computeWidgetState.
+ *
+ * @param {boolean} [noVisibility=false]
+ * If true, only update widget values and their UI, not their visibility
+ * -> @see updateUIVisibility for toggling visibility only
+ * @returns {Promise}
+ */
+ updateUI: async function ({noVisibility} = {}) {
+ // For each widget, for each of their option method, notify to the
+ // widget the current value they should hold according to the $target's
+ // current state, related for that method.
+ const proms = this._userValueWidgets.map(async widget => {
+ // Update widget value (for each method)
+ const methodsNames = widget.getMethodsNames();
+ for (const methodName of methodsNames) {
+ const params = widget.getMethodsParams(methodName);
+
+ let obj = this;
+ if (params.applyTo) {
+ const $firstSubTarget = this.$(params.applyTo).eq(0);
+ if (!$firstSubTarget.length) {
+ continue;
+ }
+ obj = createPropertyProxy(this, '$target', $firstSubTarget);
+ }
+
+ const value = await this._computeWidgetState.call(obj, methodName, params);
+ if (value === undefined) {
+ continue;
+ }
+ const normalizedValue = this._normalizeWidgetValue(value);
+ await widget.setValue(normalizedValue, methodName);
+ }
+ });
+ await Promise.all(proms);
+
+ if (!noVisibility) {
+ await this.updateUIVisibility();
+ }
+ },
+ /**
+ * Updates the UI visibility - @see _computeVisibility. For widget update,
+ * @see _computeWidgetVisibility.
+ *
+ * @returns {Promise}
+ */
+ updateUIVisibility: async function () {
+ const proms = this._userValueWidgets.map(async widget => {
+ const params = widget.getMethodsParams();
+
+ let obj = this;
+ if (params.applyTo) {
+ const $firstSubTarget = this.$(params.applyTo).eq(0);
+ if (!$firstSubTarget.length) {
+ widget.toggleVisibility(false);
+ return;
+ }
+ obj = createPropertyProxy(this, '$target', $firstSubTarget);
+ }
+
+ // Make sure to check the visibility of all sub-widgets. For
+ // simplicity and efficiency, those will be checked with main
+ // widgets params.
+ const allSubWidgets = [widget];
+ let i = 0;
+ while (i < allSubWidgets.length) {
+ allSubWidgets.push(...allSubWidgets[i]._userValueWidgets);
+ i++;
+ }
+ const proms = allSubWidgets.map(async widget => {
+ const show = await this._computeWidgetVisibility.call(obj, widget.getName(), params);
+ if (!show) {
+ widget.toggleVisibility(false);
+ return;
+ }
+
+ const dependencies = widget.getDependencies();
+ const dependenciesData = [];
+ dependencies.forEach(depName => {
+ const toBeActive = (depName[0] !== '!');
+ if (!toBeActive) {
+ depName = depName.substr(1);
+ }
+
+ const widget = this._requestUserValueWidgets(depName)[0];
+ if (widget) {
+ dependenciesData.push({
+ widget: widget,
+ toBeActive: toBeActive,
+ });
+ }
+ });
+ const dependenciesOK = !dependenciesData.length || dependenciesData.some(depData => {
+ return (depData.widget.isActive() === depData.toBeActive);
+ });
+
+ widget.toggleVisibility(dependenciesOK);
+ });
+ return Promise.all(proms);
+ });
+
+ const showUI = await this._computeVisibility();
+ this.el.classList.toggle('d-none', !showUI);
+
+ await Promise.all(proms);
+
+ // Hide layouting elements which contains only hidden widgets
+ // TODO improve this, this is hackish to rely on DOM structure here.
+ // Layouting elements should be handled as widgets or other.
+ for (const el of this.$el.find('we-row')) {
+ el.classList.toggle('d-none', !$(el).find('> div > .o_we_user_value_widget').not('.d-none').length);
+ }
+ for (const el of this.$el.find('we-collapse')) {
+ const $el = $(el);
+ el.classList.toggle('d-none', $el.children().first().hasClass('d-none'));
+ const hasNoVisibleElInCollapseMenu = !$el.children().last().children().not('.d-none').length;
+ if (hasNoVisibleElInCollapseMenu) {
+ this._toggleCollapseEl(el, false);
+ }
+ el.querySelector('.o_we_collapse_toggler').classList.toggle('d-none', hasNoVisibleElInCollapseMenu);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {UserValueWidget[]} widgets
+ * @returns {Promise<string>}
+ */
+ async _checkIfWidgetsUpdateNeedWarning(widgets) {
+ const messages = [];
+ for (const widget of widgets) {
+ const message = widget.getMethodsParams().warnMessage;
+ if (message) {
+ messages.push(message);
+ }
+ }
+ return messages.join(' ');
+ },
+ /**
+ * @private
+ * @param {UserValueWidget[]} widgets
+ * @returns {Promise<boolean|string>}
+ */
+ async _checkIfWidgetsUpdateNeedReload(widgets) {
+ return false;
+ },
+ /**
+ * @private
+ * @returns {Promise<boolean>|boolean}
+ */
+ _computeVisibility: async function () {
+ return true;
+ },
+ /**
+ * Returns the string value that should be hold by the widget which is
+ * related to the given method name.
+ *
+ * If the value is irrelevant for a method, it must return undefined.
+ *
+ * @private
+ * @param {string} methodName
+ * @param {Object} params
+ * @returns {Promise<string|undefined>|string|undefined}
+ */
+ _computeWidgetState: async function (methodName, params) {
+ switch (methodName) {
+ case 'selectClass': {
+ let maxNbClasses = 0;
+ let activeClassNames = '';
+ params.possibleValues.forEach(classNames => {
+ if (!classNames) {
+ return;
+ }
+ const classes = classNames.split(/\s+/g);
+ if (classes.length >= maxNbClasses
+ && classes.every(className => this.$target[0].classList.contains(className))) {
+ maxNbClasses = classes.length;
+ activeClassNames = classNames;
+ }
+ });
+ return activeClassNames;
+ }
+ case 'selectAttribute':
+ case 'selectDataAttribute': {
+ const attrName = params.attributeName;
+ let attrValue;
+ if (methodName === 'selectAttribute') {
+ attrValue = this.$target[0].getAttribute(attrName);
+ } else if (methodName === 'selectDataAttribute') {
+ attrValue = this.$target[0].dataset[attrName];
+ }
+ attrValue = (attrValue || '').trim();
+ if (params.saveUnit && !params.withUnit) {
+ attrValue = attrValue.split(/\s+/g).map(v => v + params.saveUnit).join(' ');
+ }
+ return attrValue || params.attributeDefaultValue || '';
+ }
+ case 'selectStyle': {
+ if (params.colorPrefix && params.colorNames) {
+ for (const c of params.colorNames) {
+ const className = weUtils.computeColorClasses([c], params.colorPrefix)[0];
+ if (this.$target[0].classList.contains(className)) {
+ return c;
+ }
+ }
+ }
+
+ // Disable all transitions for the duration of the style check
+ // as we want to know the final value of a property to properly
+ // update the UI.
+ this.$target[0].classList.add('o_we_force_no_transition');
+ const _restoreTransitions = () => this.$target[0].classList.remove('o_we_force_no_transition');
+
+ const styles = window.getComputedStyle(this.$target[0]);
+ const cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty];
+ const cssValues = cssProps.map(cssProp => {
+ let value = styles[cssProp].trim();
+ if (cssProp === 'box-shadow') {
+ const inset = value.includes('inset');
+ let values = value.replace(/,\s/g, ',').replace('inset', '').trim().split(/\s+/g);
+ const color = values.find(s => !s.match(/^\d/));
+ values = values.join(' ').replace(color, '').trim();
+ value = `${color} ${values}${inset ? ' inset' : ''}`;
+ }
+ return value;
+ });
+ if (cssValues.length === 4 && weUtils.areCssValuesEqual(cssValues[3], cssValues[1], params.cssProperty, this.$target)) {
+ cssValues.pop();
+ }
+ if (cssValues.length === 3 && weUtils.areCssValuesEqual(cssValues[2], cssValues[0], params.cssProperty, this.$target)) {
+ cssValues.pop();
+ }
+ if (cssValues.length === 2 && weUtils.areCssValuesEqual(cssValues[1], cssValues[0], params.cssProperty, this.$target)) {
+ cssValues.pop();
+ }
+
+ _restoreTransitions();
+
+ return cssValues.join(' ');
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {string} widgetName
+ * @param {Object} params
+ * @returns {Promise<boolean>|boolean}
+ */
+ _computeWidgetVisibility: async function (widgetName, params) {
+ if (widgetName === 'move_up_opt' || widgetName === 'move_left_opt') {
+ return !this.$target.is(':first-child');
+ }
+ if (widgetName === 'move_down_opt' || widgetName === 'move_right_opt') {
+ return !this.$target.is(':last-child');
+ }
+ return true;
+ },
+ /**
+ * @private
+ * @param {HTMLElement} el
+ * @returns {Object}
+ */
+ _extraInfoFromDescriptionElement: function (el) {
+ return {
+ title: el.getAttribute('string'),
+ options: {
+ classes: el.classList,
+ dataAttributes: el.dataset,
+ tooltip: el.title,
+ placeholder: el.getAttribute('placeholder'),
+ childNodes: [...el.childNodes],
+ },
+ };
+ },
+ /**
+ * @private
+ * @param {*}
+ * @returns {string}
+ */
+ _normalizeWidgetValue: function (value) {
+ value = `${value}`.trim(); // Force to a trimmed string
+ value = ColorpickerWidget.normalizeCSSColor(value); // If is a css color, normalize it
+ return value;
+ },
+ /**
+ * @private
+ * @param {string} widgetName
+ * @param {UserValueWidget|this|null} parent
+ * @param {string} title
+ * @param {Object} options
+ * @returns {UserValueWidget}
+ */
+ _registerUserValueWidget: function (widgetName, parent, title, options) {
+ const widget = new userValueWidgetsRegistry[widgetName](parent, title, options, this.$target);
+ if (!parent || parent === this) {
+ this._userValueWidgets.push(widget);
+ } else {
+ parent.registerSubWidget(widget);
+ }
+ return widget;
+ },
+ /**
+ * @private
+ * @param {HTMLElement} uiFragment
+ * @returns {Promise}
+ */
+ _renderCustomWidgets: function (uiFragment) {
+ return Promise.resolve();
+ },
+ /**
+ * @private
+ * @param {HTMLElement} uiFragment
+ * @returns {Promise}
+ */
+ _renderCustomXML: function (uiFragment) {
+ return Promise.resolve();
+ },
+ /**
+ * @private
+ * @param {jQuery} [$xml] - default to original xml content
+ * @returns {Promise}
+ */
+ _renderOriginalXML: async function ($xml) {
+ const uiFragment = document.createDocumentFragment();
+ ($xml || this.$originalUIElements).clone(true).appendTo(uiFragment);
+
+ await this._renderCustomXML(uiFragment);
+
+ // Build layouting components first
+ for (const [itemName, build] of [['we-row', _buildRowElement], ['we-collapse', _buildCollapseElement]]) {
+ uiFragment.querySelectorAll(itemName).forEach(el => {
+ const infos = this._extraInfoFromDescriptionElement(el);
+ const groupEl = build(infos.title, infos.options);
+ el.parentNode.insertBefore(groupEl, el);
+ el.parentNode.removeChild(el);
+ });
+ }
+
+ // Load widgets
+ await this._renderXMLWidgets(uiFragment);
+ await this._renderCustomWidgets(uiFragment);
+
+ if (this.isDestroyed()) {
+ // TODO there is probably better to do. This case was found only in
+ // tours, where the editor is left before the widget are fully
+ // loaded (loadMethodsData doesn't work if the widget is destroyed).
+ return uiFragment;
+ }
+
+ const validMethodNames = [];
+ for (const key in this) {
+ validMethodNames.push(key);
+ }
+ this._userValueWidgets.forEach(widget => {
+ widget.loadMethodsData(validMethodNames);
+ });
+
+ return uiFragment;
+ },
+ /**
+ * @private
+ * @param {HTMLElement} parentEl
+ * @param {SnippetOptionWidget|UserValueWidget} parentWidget
+ * @returns {Promise}
+ */
+ _renderXMLWidgets: function (parentEl, parentWidget) {
+ const proms = [...parentEl.children].map(el => {
+ const widgetName = el.tagName.toLowerCase();
+ if (!userValueWidgetsRegistry.hasOwnProperty(widgetName)) {
+ return this._renderXMLWidgets(el, parentWidget);
+ }
+
+ const infos = this._extraInfoFromDescriptionElement(el);
+ const widget = this._registerUserValueWidget(widgetName, parentWidget || this, infos.title, infos.options);
+ return widget.insertAfter(el).then(() => {
+ // Remove the original element afterwards as the insertion
+ // operation may move some of its inner content during
+ // widget start.
+ parentEl.removeChild(el);
+
+ if (widget.isContainer()) {
+ return this._renderXMLWidgets(widget.el, widget);
+ }
+ });
+ });
+ return Promise.all(proms);
+ },
+ /**
+ * @private
+ * @param {...string} widgetNames
+ * @returns {UserValueWidget[]}
+ */
+ _requestUserValueWidgets: function (...widgetNames) {
+ const widgets = [];
+ for (const widgetName of widgetNames) {
+ let widget = null;
+ this.trigger_up('user_value_widget_request', {
+ name: widgetName,
+ onSuccess: _widget => widget = _widget,
+ });
+ if (widget) {
+ widgets.push(widget);
+ }
+ }
+ return widgets;
+ },
+ /**
+ * @private
+ * @param {function<Promise<jQuery>>} [callback]
+ * @returns {Promise}
+ */
+ _rerenderXML: async function (callback) {
+ this._userValueWidgets.forEach(widget => widget.destroy());
+ this._userValueWidgets = [];
+ this.$el.empty();
+
+ let $xml = undefined;
+ if (callback) {
+ $xml = await callback.call(this);
+ }
+
+ return this._renderOriginalXML($xml).then(uiFragment => {
+ this.$el.append(uiFragment);
+ return this.updateUI();
+ });
+ },
+ /**
+ * Activates the option associated to the given DOM element.
+ *
+ * @private
+ * @param {boolean|string} previewMode
+ * - truthy if the option is enabled for preview or if leaving it (in
+ * that second case, the value is 'reset')
+ * - false if the option should be activated for good
+ * @param {UserValueWidget} widget - the widget which triggered the option change
+ * @returns {Promise}
+ */
+ _select: async function (previewMode, widget) {
+ let $applyTo = null;
+
+ // Call each option method sequentially
+ for (const methodName of widget.getMethodsNames()) {
+ const widgetValue = widget.getValue(methodName);
+ const params = widget.getMethodsParams(methodName);
+
+ if (params.applyTo) {
+ if (!$applyTo) {
+ $applyTo = this.$(params.applyTo);
+ }
+ const proms = _.map($applyTo, subTargetEl => {
+ const proxy = createPropertyProxy(this, '$target', $(subTargetEl));
+ return this[methodName].call(proxy, previewMode, widgetValue, params);
+ });
+ await Promise.all(proms);
+ } else {
+ await this[methodName](previewMode, widgetValue, params);
+ }
+ }
+
+ // We trigger the event on elements targeted by apply-to if any as
+ // this.$target could not be in an editable element while the elements
+ // targeted by apply-to are.
+ ($applyTo || this.$target).trigger('content_changed');
+ },
+ /**
+ * Used to handle attribute or data attribute value change
+ *
+ * @param {string} value
+ * @param {Object} params
+ * @returns {string|undefined}
+ */
+ _selectAttributeHelper(value, params) {
+ if (!params.attributeName) {
+ throw new Error('Attribute name missing');
+ }
+ if (params.saveUnit && !params.withUnit) {
+ // Values that come with an unit are saved without unit as
+ // data-attribute unless told otherwise.
+ value = value.split(params.saveUnit).join('');
+ }
+ if (params.extraClass) {
+ this.$target.toggleClass(params.extraClass, params.defaultValue !== value);
+ }
+ return value;
+ },
+ /**
+ * @private
+ * @param {HTMLElement} collapseEl
+ * @param {boolean|undefined} [show]
+ */
+ _toggleCollapseEl(collapseEl, show) {
+ collapseEl.classList.toggle('active', show);
+ collapseEl.querySelector('.o_we_collapse_toggler').classList.toggle('active', show);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onCollapseTogglerClick(ev) {
+ const currentCollapseEl = ev.currentTarget.parentNode;
+ this._toggleCollapseEl(currentCollapseEl);
+ for (const collapseEl of currentCollapseEl.querySelectorAll('we-collapse')) {
+ this._toggleCollapseEl(collapseEl, false);
+ }
+ },
+ /**
+ * Called when a widget notifies a preview/change/reset.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onUserValueUpdate: async function (ev) {
+ ev.stopPropagation();
+ const widget = ev.data.widget;
+ const previewMode = ev.data.previewMode;
+
+ // First check if the updated widget or any of the widgets it triggers
+ // will require a reload or a confirmation choice by the user. If it is
+ // the case, warn the user and potentially ask if he agrees to save its
+ // current changes. If not, just do nothing.
+ let requiresReload = false;
+ if (!ev.data.previewMode && !ev.data.isSimulatedEvent) {
+ const linkedWidgets = this._requestUserValueWidgets(...ev.data.triggerWidgetsNames);
+ const widgets = [ev.data.widget].concat(linkedWidgets);
+
+ const warnMessage = await this._checkIfWidgetsUpdateNeedWarning(widgets);
+ if (warnMessage) {
+ const okWarning = await new Promise(resolve => {
+ Dialog.confirm(this, warnMessage, {
+ confirm_callback: () => resolve(true),
+ cancel_callback: () => resolve(false),
+ });
+ });
+ if (!okWarning) {
+ return;
+ }
+ }
+
+ const reloadMessage = await this._checkIfWidgetsUpdateNeedReload(widgets);
+ requiresReload = !!reloadMessage;
+ if (requiresReload) {
+ const save = await new Promise(resolve => {
+ Dialog.confirm(this, _t("This change needs to reload the page, this will save all your changes and reload the page, are you sure you want to proceed?") + ' '
+ + (typeof reloadMessage === 'string' ? reloadMessage : ''), {
+ confirm_callback: () => resolve(true),
+ cancel_callback: () => resolve(false),
+ });
+ });
+ if (!save) {
+ return;
+ }
+ }
+ }
+
+ // Queue action so that we can later skip useless actions.
+ if (!this._actionQueues.get(widget)) {
+ this._actionQueues.set(widget, []);
+ }
+ const currentAction = {previewMode};
+ this._actionQueues.get(widget).push(currentAction);
+
+ // Ask a mutexed snippet update according to the widget value change
+ const shouldRecordUndo = (!previewMode && !ev.data.isSimulatedEvent);
+ this.trigger_up('snippet_edition_request', {exec: async () => {
+ // If some previous snippet edition in the mutex removed the target from
+ // the DOM, the widget can be destroyed, in that case the edition request
+ // is now useless and can be discarded.
+ if (this.isDestroyed()) {
+ return;
+ }
+ // Filter actions that are counterbalanced by earlier/later actions
+ const actionQueue = this._actionQueues.get(widget).filter(({previewMode}, i, actions) => {
+ const prev = actions[i - 1];
+ const next = actions[i + 1];
+ if (previewMode === true && next && next.previewMode) {
+ return false;
+ } else if (previewMode === 'reset' && prev && prev.previewMode) {
+ return false;
+ }
+ return true;
+ });
+ // Skip action if it's been counterbalanced
+ if (!actionQueue.includes(currentAction)) {
+ this._actionQueues.set(widget, actionQueue);
+ return;
+ }
+ this._actionQueues.set(widget, actionQueue.filter(action => action !== currentAction));
+
+ if (ev.data.prepare) {
+ ev.data.prepare();
+ }
+
+ if (previewMode && (widget.$el.closest('[data-no-preview="true"]').length)) {
+ // TODO the flag should be fetched through widget params somehow
+ return;
+ }
+
+ // If it is not preview mode, the user selected the option for good
+ // (so record the action)
+ if (shouldRecordUndo) {
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+ }
+
+ // Call widget option methods and update $target
+ await this._select(previewMode, widget);
+ if (previewMode) {
+ return;
+ }
+
+ await new Promise(resolve => setTimeout(() => {
+ // Will update the UI of the correct widgets for all options
+ // related to the same $target/editor
+ this.trigger_up('snippet_option_update', {
+ onSuccess: () => resolve(),
+ });
+ // Set timeout needed so that the user event which triggered the
+ // option can bubble first.
+ }));
+ }});
+
+ if (ev.data.isSimulatedEvent) {
+ // If the user value update was simulated through a trigger, we
+ // prevent triggering further widgets. This could be allowed at some
+ // point but does not work correctly in complex website cases (see
+ // customizeWebsite).
+ return;
+ }
+
+ // Check linked widgets: force their value and simulate a notification
+ const linkedWidgets = this._requestUserValueWidgets(...ev.data.triggerWidgetsNames);
+ if (linkedWidgets.length !== ev.data.triggerWidgetsNames.length) {
+ console.warn('Missing widget to trigger');
+ return;
+ }
+ let i = 0;
+ const triggerWidgetsValues = ev.data.triggerWidgetsValues;
+ for (const linkedWidget of linkedWidgets) {
+ const widgetValue = triggerWidgetsValues[i];
+ if (widgetValue !== undefined) {
+ // FIXME right now only make this work supposing it is a
+ // colorpicker widget with big big hacks, this should be
+ // improved a lot
+ const normValue = this._normalizeWidgetValue(widgetValue);
+ if (previewMode === true) {
+ linkedWidget._previewColor = normValue;
+ } else if (previewMode === false) {
+ linkedWidget._previewColor = false;
+ linkedWidget._value = normValue;
+ } else {
+ linkedWidget._previewColor = false;
+ }
+ }
+
+ linkedWidget.notifyValueChange(previewMode, true);
+ i++;
+ }
+
+ if (requiresReload) {
+ this.trigger_up('request_save', {
+ reloadEditor: true,
+ });
+ }
+ },
+ /**
+ * @private
+ */
+ _onUserValueWidgetCritical() {
+ this.trigger_up('remove_snippet', {
+ $snippet: this.$target,
+ });
+ },
+});
+const registry = {};
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+registry.sizing = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ var def = this._super.apply(this, arguments);
+
+ this.$handles = this.$overlay.find('.o_handle');
+
+ var resizeValues = this._getSize();
+ this.$handles.on('mousedown', function (ev) {
+ ev.preventDefault();
+
+ // First update size values as some element sizes may not have been
+ // initialized on option start (hidden slides, etc)
+ resizeValues = self._getSize();
+ var $handle = $(ev.currentTarget);
+
+ var compass = false;
+ var XY = false;
+ if ($handle.hasClass('n')) {
+ compass = 'n';
+ XY = 'Y';
+ } else if ($handle.hasClass('s')) {
+ compass = 's';
+ XY = 'Y';
+ } else if ($handle.hasClass('e')) {
+ compass = 'e';
+ XY = 'X';
+ } else if ($handle.hasClass('w')) {
+ compass = 'w';
+ XY = 'X';
+ }
+
+ var resize = resizeValues[compass];
+ if (!resize) {
+ return;
+ }
+
+ var current = 0;
+ var cssProperty = resize[2];
+ var cssPropertyValue = parseInt(self.$target.css(cssProperty));
+ _.each(resize[0], function (val, key) {
+ if (self.$target.hasClass(val)) {
+ current = key;
+ } else if (resize[1][key] === cssPropertyValue) {
+ current = key;
+ }
+ });
+ var begin = current;
+ var beginClass = self.$target.attr('class');
+ var regClass = new RegExp('\\s*' + resize[0][begin].replace(/[-]*[0-9]+/, '[-]*[0-9]+'), 'g');
+
+ var cursor = $handle.css('cursor') + '-important';
+ var $body = $(this.ownerDocument.body);
+ $body.addClass(cursor);
+
+ var xy = ev['page' + XY];
+ var bodyMouseMove = function (ev) {
+ ev.preventDefault();
+
+ var dd = ev['page' + XY] - xy + resize[1][begin];
+ var next = current + (current + 1 === resize[1].length ? 0 : 1);
+ var prev = current ? (current - 1) : 0;
+
+ var change = false;
+ if (dd > (2 * resize[1][next] + resize[1][current]) / 3) {
+ self.$target.attr('class', (self.$target.attr('class') || '').replace(regClass, ''));
+ self.$target.addClass(resize[0][next]);
+ current = next;
+ change = true;
+ }
+ if (prev !== current && dd < (2 * resize[1][prev] + resize[1][current]) / 3) {
+ self.$target.attr('class', (self.$target.attr('class') || '').replace(regClass, ''));
+ self.$target.addClass(resize[0][prev]);
+ current = prev;
+ change = true;
+ }
+
+ if (change) {
+ self._onResize(compass, beginClass, current);
+ self.trigger_up('cover_update');
+ $handle.addClass('o_active');
+ }
+ };
+ var bodyMouseUp = function () {
+ $body.off('mousemove', bodyMouseMove);
+ $(window).off('mouseup', bodyMouseUp);
+ $body.removeClass(cursor);
+ $handle.removeClass('o_active');
+
+ // Highlights the previews for a while
+ var $handlers = self.$overlay.find('.o_handle');
+ $handlers.addClass('o_active').delay(300).queue(function () {
+ $handlers.removeClass('o_active').dequeue();
+ });
+
+ if (begin === current) {
+ return;
+ }
+ setTimeout(function () {
+ self.trigger_up('request_history_undo_record', {
+ $target: self.$target,
+ event: 'resize_' + XY,
+ });
+ }, 0);
+ };
+ $body.on('mousemove', bodyMouseMove);
+ $(window).on('mouseup', bodyMouseUp);
+ });
+
+ return def;
+ },
+ /**
+ * @override
+ */
+ onFocus: function () {
+ this._onResize();
+ },
+ /**
+ * @override
+ */
+ onBlur: function () {
+ this.$handles.addClass('readonly');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ setTarget: function () {
+ this._super(...arguments);
+ this._onResize();
+ },
+ /**
+ * @override
+ */
+ updateUI: async function () {
+ await this._super(...arguments);
+ const resizeValues = this._getSize();
+ _.each(resizeValues, (value, key) => {
+ this.$handles.filter('.' + key).toggleClass('readonly', !value);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns an object mapping one or several cardinal direction (n, e, s, w)
+ * to an Array containing:
+ * 1) A list of classes to toggle when using this cardinal direction
+ * 2) A list of values these classes are supposed to set on a given CSS prop
+ * 3) The mentioned CSS prop
+ *
+ * Note: this object must also be saved in this.grid before being returned.
+ *
+ * @abstract
+ * @private
+ * @returns {Object}
+ */
+ _getSize: function () {},
+ /**
+ * Called when the snippet is being resized and its classes changes.
+ *
+ * @private
+ * @param {string} [compass] - resize direction ('n', 's', 'e' or 'w')
+ * @param {string} [beginClass] - attributes class at the beginning
+ * @param {integer} [current] - current increment in this.grid
+ */
+ _onResize: function (compass, beginClass, current) {
+ var self = this;
+
+ // Adapt the resize handles according to the classes and dimensions
+ var resizeValues = this._getSize();
+ var $handles = this.$overlay.find('.o_handle');
+ _.each(resizeValues, function (resizeValue, direction) {
+ var classes = resizeValue[0];
+ var values = resizeValue[1];
+ var cssProperty = resizeValue[2];
+
+ var $handle = $handles.filter('.' + direction);
+
+ var current = 0;
+ var cssPropertyValue = parseInt(self.$target.css(cssProperty));
+ _.each(classes, function (className, key) {
+ if (self.$target.hasClass(className)) {
+ current = key;
+ } else if (values[key] === cssPropertyValue) {
+ current = key;
+ }
+ });
+
+ $handle.toggleClass('o_handle_start', current === 0);
+ $handle.toggleClass('o_handle_end', current === classes.length - 1);
+ });
+
+ // Adapt the handles to fit the left, top and bottom sizes
+ var ml = this.$target.css('margin-left');
+ this.$overlay.find('.o_handle.w').css({
+ width: ml,
+ left: '-' + ml,
+ });
+ this.$overlay.find('.o_handle.e').css({
+ width: 0,
+ });
+ _.each(this.$overlay.find(".o_handle.n, .o_handle.s"), function (handle) {
+ var $handle = $(handle);
+ var direction = $handle.hasClass('n') ? 'top' : 'bottom';
+ $handle.height(self.$target.css('padding-' + direction));
+ });
+ this.$target.trigger('content_changed');
+ },
+});
+
+/**
+ * Handles the edition of padding-top and padding-bottom.
+ */
+registry['sizing_y'] = registry.sizing.extend({
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getSize: function () {
+ var nClass = 'pt';
+ var nProp = 'padding-top';
+ var sClass = 'pb';
+ var sProp = 'padding-bottom';
+ if (this.$target.is('hr')) {
+ nClass = 'mt';
+ nProp = 'margin-top';
+ sClass = 'mb';
+ sProp = 'margin-bottom';
+ }
+
+ var grid = [];
+ for (var i = 0; i <= (256 / 8); i++) {
+ grid.push(i * 8);
+ }
+ grid.splice(1, 0, 4);
+ this.grid = {
+ n: [grid.map(v => nClass + v), grid, nProp],
+ s: [grid.map(v => sClass + v), grid, sProp],
+ };
+ return this.grid;
+ },
+});
+
+/*
+ * Abstract option to be extended by the ImageOptimize and BackgroundOptimize
+ * options that handles all the common parts.
+ */
+const ImageHandlerOption = SnippetOptionWidget.extend({
+
+ /**
+ * @override
+ */
+ async willStart() {
+ const _super = this._super.bind(this);
+ await this._loadImageInfo();
+ return _super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see this.selectClass for parameters
+ */
+ selectWidth(previewMode, widgetValue, params) {
+ this._getImg().dataset.resizeWidth = widgetValue;
+ return this._applyOptions();
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ setQuality(previewMode, widgetValue, params) {
+ this._getImg().dataset.quality = widgetValue;
+ return this._applyOptions();
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ glFilter(previewMode, widgetValue, params) {
+ const dataset = this._getImg().dataset;
+ if (widgetValue) {
+ dataset.glFilter = widgetValue;
+ } else {
+ delete dataset.glFilter;
+ }
+ return this._applyOptions();
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ customFilter(previewMode, widgetValue, params) {
+ const img = this._getImg();
+ const {filterOptions} = img.dataset;
+ const {filterProperty} = params;
+ if (filterProperty === 'filterColor') {
+ widgetValue = normalizeColor(widgetValue);
+ }
+ const newOptions = Object.assign(JSON.parse(filterOptions || "{}"), {[filterProperty]: widgetValue});
+ img.dataset.filterOptions = JSON.stringify(newOptions);
+ return this._applyOptions();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeVisibility() {
+ const src = this._getImg().getAttribute('src');
+ return src && src !== '/';
+ },
+ /**
+ * @override
+ */
+ async _computeWidgetState(methodName, params) {
+ const img = this._getImg();
+
+ // Make sure image is loaded because we need its naturalWidth
+ await new Promise((resolve, reject) => {
+ if (img.complete) {
+ resolve();
+ return;
+ }
+ img.addEventListener('load', resolve, {once: true});
+ img.addEventListener('error', resolve, {once: true});
+ });
+
+ switch (methodName) {
+ case 'selectWidth':
+ return img.naturalWidth;
+ case 'setFilter':
+ return img.dataset.filter;
+ case 'glFilter':
+ return img.dataset.glFilter || "";
+ case 'setQuality':
+ return img.dataset.quality || 75;
+ case 'customFilter': {
+ const {filterProperty} = params;
+ const options = JSON.parse(img.dataset.filterOptions || "{}");
+ const defaultValue = filterProperty === 'blend' ? 'normal' : 0;
+ return options[filterProperty] || defaultValue;
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ async _renderCustomXML(uiFragment) {
+ const isLocalURL = href => new URL(href, window.location.origin).origin === window.location.origin;
+
+ const img = this._getImg();
+ if (!this.originalSrc || !['image/png', 'image/jpeg'].includes(img.dataset.mimetype)) {
+ return [...uiFragment.childNodes].forEach(node => {
+ if (node.matches('.o_we_external_warning')) {
+ node.classList.remove('d-none');
+ if (isLocalURL(img.getAttribute('src'))) {
+ const title = node.querySelector('we-title');
+ title.textContent = ` ${_t("Quality options unavailable")}`;
+ $(title).prepend('<i class="fa fa-warning" />');
+ if (img.dataset.mimetype) {
+ title.setAttribute('title', _t("Only PNG and JPEG images support quality options and image filtering"));
+ } else {
+ title.setAttribute('title', _t("Due to technical limitations, you can only change optimization settings on this image by choosing it again in the media-dialog or reuploading it (double click on the image)"));
+ }
+ }
+ } else {
+ node.remove();
+ }
+ });
+ }
+ const $select = $(uiFragment).find('we-select[data-name=width_select_opt]');
+ (await this._computeAvailableWidths()).forEach(([value, label]) => {
+ $select.append(`<we-button data-select-width="${value}">${label}</we-button>`);
+ });
+
+ if (img.dataset.mimetype !== 'image/jpeg') {
+ uiFragment.querySelector('we-range[data-set-quality]').remove();
+ }
+ },
+ /**
+ * Returns a list of valid widths for a given image.
+ *
+ * @private
+ */
+ async _computeAvailableWidths() {
+ const img = this._getImg();
+ const original = await loadImage(this.originalSrc);
+ const maxWidth = img.dataset.width ? img.naturalWidth : original.naturalWidth;
+ const optimizedWidth = Math.min(maxWidth, this._computeMaxDisplayWidth());
+ this.optimizedWidth = optimizedWidth;
+ const widths = {
+ 128: '128px',
+ 256: '256px',
+ 512: '512px',
+ 1024: '1024px',
+ 1920: '1920px',
+ };
+ widths[img.naturalWidth] = _.str.sprintf(_t("%spx"), img.naturalWidth);
+ widths[optimizedWidth] = _.str.sprintf(_t("%dpx (Suggested)"), optimizedWidth);
+ widths[maxWidth] = _.str.sprintf(_t("%dpx (Original)"), maxWidth);
+ return Object.entries(widths)
+ .filter(([width]) => width <= maxWidth)
+ .sort(([v1], [v2]) => v1 - v2);
+ },
+ /**
+ * Applies all selected options on the original image.
+ *
+ * @private
+ */
+ async _applyOptions() {
+ const img = this._getImg();
+ if (!['image/jpeg', 'image/png'].includes(img.dataset.mimetype)) {
+ this.originalId = null;
+ return;
+ }
+ const dataURL = await applyModifications(img);
+ const weight = dataURL.split(',')[1].length / 4 * 3;
+ const $weight = this.$el.find('.o_we_image_weight');
+ $weight.find('> small').text(_t("New size"));
+ $weight.find('b').text(`${(weight / 1024).toFixed(1)} kb`);
+ $weight.removeClass('d-none');
+ img.classList.add('o_modified_image_to_save');
+ const loadedImg = await loadImage(dataURL, img);
+ this._applyImage(loadedImg);
+ return loadedImg;
+ },
+ /**
+ * Loads the image's attachment info.
+ *
+ * @private
+ */
+ async _loadImageInfo() {
+ const img = this._getImg();
+ await loadImageInfo(img, this._rpc.bind(this));
+ if (!img.dataset.originalId) {
+ this.originalId = null;
+ this.originalSrc = null;
+ return;
+ }
+ this.originalId = img.dataset.originalId;
+ this.originalSrc = img.dataset.originalSrc;
+ },
+ /**
+ * Sets the image's width to its suggested size.
+ *
+ * @private
+ */
+ async _autoOptimizeImage() {
+ await this._loadImageInfo();
+ await this._rerenderXML();
+ this._getImg().dataset.resizeWidth = this.optimizedWidth;
+ await this._applyOptions();
+ await this.updateUI();
+ },
+ /**
+ * Returns the image that is currently being modified.
+ *
+ * @private
+ * @abstract
+ * @returns {HTMLImageElement} the image to use for modifications
+ */
+ _getImg() {},
+ /**
+ * Computes the image's maximum display width.
+ *
+ * @private
+ * @abstract
+ * @returns {Int} the maximum width at which the image can be displayed
+ */
+ _computeMaxDisplayWidth() {},
+ /**
+ * Use the processed image when it's needed in the DOM.
+ *
+ * @private
+ * @abstract
+ * @param {HTMLImageElement} img
+ */
+ _applyImage(img) {},
+});
+
+/**
+ * Controls image width and quality.
+ */
+registry.ImageOptimize = ImageHandlerOption.extend({
+ /**
+ * @override
+ */
+ start() {
+ this.$target.on('image_changed.ImageOptimization', this._onImageChanged.bind(this));
+ this.$target.on('image_cropped.ImageOptimization', this._onImageCropped.bind(this));
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this.$target.off('.ImageOptimization');
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeMaxDisplayWidth() {
+ // TODO: read widths from computed style in case container widths are not default
+ const displayWidth = this._getImg().clientWidth;
+ // If the image is in a column, it might get bigger on smaller screens.
+ // We use col-lg for this in snippets, so they get bigger on the md breakpoint
+ if (this.$target.closest('[class*="col-lg"]').length) {
+ // container and o_container_small have maximum inner width of 690px on the md breakpoint
+ if (this.$target.closest('.container, .o_container_small').length) {
+ return Math.min(1920, Math.max(displayWidth, 690));
+ }
+ // A container-fluid's max inner width is 962px on the md breakpoint
+ return Math.min(1920, Math.max(displayWidth, 962));
+ }
+ // If it's not in a col-lg, it's probably not going to change size depending on breakpoints
+ return displayWidth;
+ },
+ /**
+ * @override
+ */
+ _getImg() {
+ return this.$target[0];
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Reloads image data and auto-optimizes the new image.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ async _onImageChanged(ev) {
+ this.trigger_up('snippet_edition_request', {exec: async () => {
+ await this._autoOptimizeImage();
+ this.trigger_up('cover_update');
+ }});
+ },
+ /**
+ * Available widths will change, need to rerender the width select.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ async _onImageCropped(ev) {
+ await this._rerenderXML();
+ },
+});
+
+/**
+ * Controls background image width and quality.
+ */
+registry.BackgroundOptimize = ImageHandlerOption.extend({
+ /**
+ * @override
+ */
+ start() {
+ this.$target.on('background_changed.BackgroundOptimize', this._onBackgroundChanged.bind(this));
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this.$target.off('.BackgroundOptimize');
+ return this._super(...arguments);
+ },
+ /**
+ * Marks the target for creation of an attachment and copies data attributes
+ * to the target so that they can be restored on this.img in later editions.
+ *
+ * @override
+ */
+ async cleanForSave() {
+ const img = this._getImg();
+ if (img.matches('.o_modified_image_to_save')) {
+ this.$target.addClass('o_modified_image_to_save');
+ Object.entries(img.dataset).forEach(([key, value]) => {
+ this.$target[0].dataset[key] = value;
+ });
+ this.$target[0].dataset.bgSrc = img.getAttribute('src');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getImg() {
+ return this.img;
+ },
+ /**
+ * @override
+ */
+ _computeMaxDisplayWidth() {
+ return 1920;
+ },
+ /**
+ * Initializes this.img to an image with the background image url as src.
+ *
+ * @override
+ */
+ async _loadImageInfo() {
+ this.img = new Image();
+ Object.entries(this.$target[0].dataset).filter(([key]) =>
+ // Avoid copying dynamic editor attributes
+ !['oeId','oeModel', 'oeField', 'oeXpath', 'noteId'].includes(key)
+ ).forEach(([key, value]) => {
+ this.img.dataset[key] = value;
+ });
+ const src = getBgImageURL(this.$target[0]);
+ // Don't set the src if not relative (ie, not local image: cannot be modified)
+ this.img.src = src.startsWith('/') ? src : '';
+ return await this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _applyImage(img) {
+ this.$target.css('background-image', `url('${img.getAttribute('src')}')`);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Reloads image data when the background is changed.
+ *
+ * @private
+ */
+ async _onBackgroundChanged(ev, previewMode) {
+ if (!previewMode) {
+ this.trigger_up('snippet_edition_request', {exec: async () => {
+ await this._autoOptimizeImage();
+ }});
+ }
+ },
+});
+
+registry.BackgroundToggler = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ start() {
+ this.$target.on('content_changed.BackgroundToggler', this._onExternalUpdate.bind(this));
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ this.$target.off('.BackgroundToggler');
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Toggles background image on or off.
+ *
+ * @see this.selectClass for parameters
+ */
+ toggleBgImage(previewMode, widgetValue, params) {
+ if (!widgetValue) {
+ // TODO: use setWidgetValue instead of calling background directly when possible
+ const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt');
+ const bgImageOpt = bgImageWidget.getParent();
+ return bgImageOpt.background(false, '', bgImageWidget.getMethodsParams('background'));
+ } else {
+ // TODO: use trigger instead of el.click when possible
+ this._requestUserValueWidgets('bg_image_opt')[0].el.click();
+ }
+ },
+ /**
+ * Toggles background shape on or off.
+ *
+ * @see this.selectClass for parameters
+ */
+ toggleBgShape(previewMode, widgetValue, params) {
+ const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt');
+ const shapeOption = shapeWidget.getParent();
+ // TODO: open select after shape was selected?
+ // TODO: use setWidgetValue instead of calling shapeOption method directly when possible
+ return shapeOption._toggleShape();
+ },
+ /**
+ * Toggles background filter on or off.
+ *
+ * @see this.selectClass for parameters
+ */
+ toggleBgFilter(previewMode, widgetValue, params) {
+ if (widgetValue) {
+ const bgFilterEl = document.createElement('div');
+ bgFilterEl.classList.add('o_we_bg_filter', 'bg-black-50');
+ const lastBackgroundEl = this._getLastPreFilterLayerElement();
+ if (lastBackgroundEl) {
+ $(lastBackgroundEl).after(bgFilterEl);
+ } else {
+ this.$target.prepend(bgFilterEl);
+ }
+ } else {
+ this.$target.find('.o_we_bg_filter').remove();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ switch (methodName) {
+ case 'toggleBgImage': {
+ const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt');
+ const bgImageOpt = bgImageWidget.getParent();
+ return !!bgImageOpt._computeWidgetState('background', bgImageWidget.getMethodsParams('background'));
+ }
+ case 'toggleBgFilter': {
+ return this._hasBgFilter();
+ }
+ case 'toggleBgShape': {
+ const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt');
+ const shapeOption = shapeWidget.getParent();
+ return !!shapeOption._computeWidgetState('shape', shapeWidget.getMethodsParams('shape'));
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ */
+ _getLastPreFilterLayerElement() {
+ return null;
+ },
+ /**
+ * @private
+ * @returns {Boolean}
+ */
+ _hasBgFilter() {
+ return !!this.$target.find('> .o_we_bg_filter').length;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onExternalUpdate() {
+ if (this._hasBgFilter()
+ && !this._getLastPreFilterLayerElement()
+ && !getBgImageURL(this.$target)) {
+ // No 'pre-filter' background layout anymore and no more background
+ // image: remove the background filter option.
+ // TODO there probably is a better system to implement to do that
+ const widget = this._requestUserValueWidgets('bg_filter_toggle_opt')[0];
+ widget.enable();
+ }
+ },
+});
+
+/**
+ * Handles the edition of snippet's background image.
+ */
+registry.BackgroundImage = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ this.__customImageSrc = getBgImageURL(this.$target[0]);
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handles a background change.
+ *
+ * @see this.selectClass for parameters
+ */
+ background: async function (previewMode, widgetValue, params) {
+ if (previewMode === true) {
+ this.__customImageSrc = getBgImageURL(this.$target[0]);
+ } else if (previewMode === 'reset') {
+ widgetValue = this.__customImageSrc;
+ } else {
+ this.__customImageSrc = widgetValue;
+ }
+
+ this._setBackground(widgetValue);
+
+ if (previewMode !== 'reset') {
+ removeOnImageChangeAttrs.forEach(attr => delete this.$target[0].dataset[attr]);
+ this.$target.trigger('background_changed', [previewMode]);
+ }
+ },
+ /**
+ * Changes the main color of dynamic SVGs.
+ *
+ * @see this.selectClass for parameters
+ */
+ async dynamicColor(previewMode, widgetValue, params) {
+ const currentSrc = getBgImageURL(this.$target[0]);
+ switch (previewMode) {
+ case true:
+ this.previousSrc = currentSrc;
+ break;
+ case 'reset':
+ this.$target.css('background-image', `url('${this.previousSrc}')`);
+ return;
+ }
+ const newURL = new URL(currentSrc, window.location.origin);
+ newURL.searchParams.set('c1', normalizeColor(widgetValue));
+ const src = newURL.pathname + newURL.search;
+ await loadImage(src);
+ this.$target.css('background-image', `url('${src}')`);
+ if (!previewMode) {
+ this.previousSrc = src;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ setTarget: function () {
+ // When we change the target of this option we need to transfer the
+ // background-image from the old target to the new one.
+ const oldBgURL = getBgImageURL(this.$target);
+ this._setBackground('');
+ this._super(...arguments);
+ if (oldBgURL) {
+ this._setBackground(oldBgURL);
+ }
+
+ // TODO should be automatic for all options as equal to the start method
+ this.__customImageSrc = getBgImageURL(this.$target[0]);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName) {
+ switch (methodName) {
+ case 'background':
+ return getBgImageURL(this.$target[0]);
+ case 'dynamicColor':
+ return new URL(getBgImageURL(this.$target[0]), window.location.origin).searchParams.get('c1');
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _computeWidgetVisibility(widgetName, params) {
+ if (widgetName === 'dynamic_color_opt') {
+ const src = new URL(getBgImageURL(this.$target[0]), window.location.origin);
+ return src.origin === window.location.origin && src.pathname.startsWith('/web_editor/shape/');
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ * @param {string} backgroundURL
+ */
+ _setBackground(backgroundURL) {
+ if (backgroundURL) {
+ this.$target.css('background-image', `url('${backgroundURL}')`);
+ this.$target.addClass('oe_img_bg');
+ } else {
+ this.$target.css('background-image', '');
+ this.$target.removeClass('oe_img_bg');
+ }
+ },
+});
+
+/**
+ * Handles background shapes.
+ */
+registry.BackgroundShape = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ updateUI() {
+ if (this.rerender) {
+ this.rerender = false;
+ return this._rerenderXML();
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the current background shape.
+ *
+ * @see this.selectClass for params
+ */
+ shape(previewMode, widgetValue, params) {
+ this._handlePreviewState(previewMode, () => {
+ return {shape: widgetValue, colors: this._getDefaultColors(), flip: []};
+ });
+ },
+ /**
+ * Sets the current background shape's colors.
+ *
+ * @see this.selectClass for params
+ */
+ color(previewMode, widgetValue, params) {
+ this._handlePreviewState(previewMode, () => {
+ const {colorName} = params;
+ const {colors: previousColors} = this._getShapeData();
+ const newColor = normalizeColor(widgetValue) || this._getDefaultColors()[colorName];
+ const newColors = Object.assign(previousColors, {[colorName]: newColor});
+ return {colors: newColors};
+ });
+ },
+ /**
+ * Flips the shape on its x axis.
+ *
+ * @see this.selectClass for params
+ */
+ flipX(previewMode, widgetValue, params) {
+ this._flipShape(previewMode, 'x');
+ },
+ /**
+ * Flips the shape on its y axis.
+ *
+ * @see this.selectClass for params
+ */
+ flipY(previewMode, widgetValue, params) {
+ this._flipShape(previewMode, 'y');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ switch (methodName) {
+ case 'shape': {
+ return this._getShapeData().shape;
+ }
+ case 'color': {
+ const {shape, colors: customColors} = this._getShapeData();
+ const colors = Object.assign(this._getDefaultColors(), customColors);
+ const color = shape && colors[params.colorName];
+ return color || '';
+ }
+ case 'flipX': {
+ // Compat: flip classes are no longer used but may be present in client db
+ const hasFlipClass = this.$target.find('> .o_we_shape.o_we_flip_x').length !== 0;
+ return hasFlipClass || this._getShapeData().flip.includes('x');
+ }
+ case 'flipY': {
+ // Compat: flip classes are no longer used but may be present in client db
+ const hasFlipClass = this.$target.find('> .o_we_shape.o_we_flip_y').length !== 0;
+ return hasFlipClass || this._getShapeData().flip.includes('y');
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _renderCustomXML(uiFragment) {
+ Object.keys(this._getDefaultColors()).map(colorName => {
+ uiFragment.querySelector('[data-name="colors"]')
+ .prepend($(`<we-colorpicker data-color="true" data-color-name="${colorName}">`)[0]);
+ });
+
+ uiFragment.querySelectorAll('we-select-pager we-button[data-shape]').forEach(btn => {
+ const btnContent = document.createElement('div');
+ btnContent.classList.add('o_we_shape_btn_content', 'position-relative', 'border-dark');
+ const btnContentInnerDiv = document.createElement('div');
+ btnContentInnerDiv.classList.add('o_we_shape');
+ btnContent.appendChild(btnContentInnerDiv);
+
+ const {shape} = btn.dataset;
+ const shapeEl = btnContent.querySelector('.o_we_shape');
+ shapeEl.classList.add(`o_${shape.replace(/\//g, '_')}`);
+ btn.append(btnContent);
+ });
+ return uiFragment;
+ },
+ /**
+ * @override
+ */
+ async _computeWidgetVisibility(widgetName, params) {
+ if (widgetName === 'shape_none_opt') {
+ return false;
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Flips the shape on its x/y axis.
+ *
+ * @param {boolean} previewMode
+ * @param {'x'|'y'} axis the axis of the shape that should be flipped.
+ */
+ _flipShape(previewMode, axis) {
+ this._handlePreviewState(previewMode, () => {
+ const flip = new Set(this._getShapeData().flip);
+ if (flip.has(axis)) {
+ flip.delete(axis);
+ } else {
+ flip.add(axis);
+ }
+ return {flip: [...flip]};
+ });
+ },
+ /**
+ * Handles everything related to saving state before preview and restoring
+ * it after a preview or locking in the changes when not in preview.
+ *
+ * @param {boolean} previewMode
+ * @param {function} computeShapeData function to compute the new shape data.
+ */
+ _handlePreviewState(previewMode, computeShapeData) {
+ const target = this.$target[0];
+ const insertShapeContainer = newContainer => {
+ const shapeContainer = target.querySelector(':scope > .o_we_shape');
+ if (shapeContainer) {
+ shapeContainer.remove();
+ }
+ if (newContainer) {
+ const preShapeLayerElement = this._getLastPreShapeLayerElement();
+ if (preShapeLayerElement) {
+ $(preShapeLayerElement).after(newContainer);
+ } else {
+ this.$target.prepend(newContainer);
+ }
+ }
+ return newContainer;
+ };
+
+ let changedShape = false;
+ if (previewMode === 'reset') {
+ insertShapeContainer(this.prevShapeContainer);
+ if (this.prevShape) {
+ target.dataset.oeShapeData = this.prevShape;
+ } else {
+ delete target.dataset.oeShapeData;
+ }
+ return;
+ } else {
+ if (previewMode === true) {
+ const shapeContainer = target.querySelector(':scope > .o_we_shape');
+ this.prevShapeContainer = shapeContainer && shapeContainer.cloneNode(true);
+ this.prevShape = target.dataset.oeShapeData;
+ }
+ const curShapeData = target.dataset.oeShapeData || {};
+ const newShapeData = computeShapeData();
+ const {shape: curShape} = curShapeData;
+ changedShape = newShapeData.shape !== curShape;
+ this._markShape(newShapeData);
+ if (previewMode === false && changedShape) {
+ // Need to rerender for correct number of colorpickers
+ this.rerender = true;
+ }
+ }
+
+ // Updates/removes the shape container as needed and gives it the
+ // correct background shape
+ const json = target.dataset.oeShapeData;
+ const {shape, colors, flip = []} = json ? JSON.parse(json) : {};
+ let shapeContainer = target.querySelector(':scope > .o_we_shape');
+ if (!shape) {
+ return insertShapeContainer(null);
+ }
+ // When changing shape we want to reset the shape container (for transparency color)
+ if (changedShape) {
+ shapeContainer = insertShapeContainer(null);
+ }
+ if (!shapeContainer) {
+ shapeContainer = insertShapeContainer(document.createElement('div'));
+ target.style.position = 'relative';
+ shapeContainer.className = `o_we_shape o_${shape.replace(/\//g, '_')}`;
+ }
+ // Compat: remove old flip classes as flipping is now done inside the svg
+ shapeContainer.classList.remove('o_we_flip_x', 'o_we_flip_y');
+
+ if (colors || flip.length) {
+ // Custom colors/flip, overwrite shape that is set by the class
+ $(shapeContainer).css('background-image', `url("${this._getShapeSrc()}")`);
+ shapeContainer.style.backgroundPosition = '';
+ if (flip.length) {
+ let [xPos, yPos] = $(shapeContainer)
+ .css('background-position')
+ .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;
+ shapeContainer.style.backgroundPosition = `${xPos}% ${yPos}%`;
+ }
+ } else {
+ // Remove custom bg image and let the shape class set the bg shape
+ $(shapeContainer).css('background-image', '');
+ $(shapeContainer).css('background-position', '');
+ }
+ if (previewMode === false) {
+ this.prevShapeContainer = shapeContainer.cloneNode(true);
+ this.prevShape = target.dataset.oeShapeData;
+ }
+ },
+ /**
+ * Overwrites shape properties with the specified data.
+ *
+ * @private
+ * @param {Object} newData an object with the new data
+ */
+ _markShape(newData) {
+ const defaultColors = this._getDefaultColors();
+ const shapeData = Object.assign(this._getShapeData(), newData);
+ const areColorsDefault = Object.entries(shapeData.colors).every(([colorName, colorValue]) => {
+ return colorValue.toLowerCase() === defaultColors[colorName].toLowerCase();
+ });
+ if (areColorsDefault) {
+ delete shapeData.colors;
+ }
+ if (!shapeData.shape) {
+ delete this.$target[0].dataset.oeShapeData;
+ } else {
+ this.$target[0].dataset.oeShapeData = JSON.stringify(shapeData);
+ }
+ },
+ /**
+ * @private
+ */
+ _getLastPreShapeLayerElement() {
+ const $filterEl = this.$target.find('> .o_we_bg_filter');
+ if ($filterEl.length) {
+ return $filterEl[0];
+ }
+ return null;
+ },
+ /**
+ * Returns the src of the shape corresponding to the current parameters.
+ *
+ * @private
+ */
+ _getShapeSrc() {
+ const {shape, colors, flip} = this._getShapeData();
+ if (!shape) {
+ return '';
+ }
+ const searchParams = Object.entries(colors)
+ .map(([colorName, colorValue]) => {
+ const encodedCol = encodeURIComponent(colorValue);
+ return `${colorName}=${encodedCol}`;
+ });
+ if (flip.length) {
+ searchParams.push(`flip=${flip.sort().join('')}`);
+ }
+ return `/web_editor/shape/${shape}.svg?${searchParams.join('&')}`;
+ },
+ /**
+ * Retrieves current shape data from the target's dataset.
+ *
+ * @private
+ * @param {HTMLElement} [target=this.$target[0]] the target on which to read
+ * the shape data.
+ */
+ _getShapeData(target = this.$target[0]) {
+ const defaultData = {
+ shape: '',
+ colors: this._getDefaultColors(),
+ flip: [],
+ };
+ const json = target.dataset.oeShapeData;
+ return json ? Object.assign(defaultData, JSON.parse(json.replace(/'/g, '"'))) : defaultData;
+ },
+ /**
+ * Returns the default colors for the currently selected shape.
+ *
+ * @private
+ */
+ _getDefaultColors() {
+ const $shapeContainer = this.$target.find('> .o_we_shape')
+ .clone()
+ .addClass('d-none')
+ // Needs to be in document for bg-image class to take effect
+ .appendTo(document.body);
+ const shapeContainer = $shapeContainer[0];
+ $shapeContainer.css('background-image', '');
+ const shapeSrc = shapeContainer && getBgImageURL(shapeContainer);
+ $shapeContainer.remove();
+ if (!shapeSrc) {
+ return {};
+ }
+ const url = new URL(shapeSrc, window.location.origin);
+ return Object.fromEntries(url.searchParams.entries());
+ },
+ /**
+ * Toggles whether there is a shape or not, to be called from bg toggler.
+ *
+ * @private
+ */
+ _toggleShape() {
+ if (this._getShapeData().shape) {
+ return this._handlePreviewState(false, () => ({shape: ''}));
+ } else {
+ const target = this.$target[0];
+ const previousSibling = target.previousElementSibling;
+ const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt');
+ const possibleShapes = shapeWidget.getMethodsParams('shape').possibleValues;
+ let shapeToSelect;
+ if (previousSibling) {
+ const previousShape = this._getShapeData(previousSibling).shape;
+ shapeToSelect = possibleShapes.find((shape, i) => {
+ return possibleShapes[i - 1] === previousShape;
+ });
+ } else {
+ shapeToSelect = possibleShapes[1];
+ }
+ return this._handlePreviewState(false, () => ({shape: shapeToSelect}));
+ }
+ },
+});
+
+/**
+ * Handles the edition of snippets' background image position.
+ */
+registry.BackgroundPosition = SnippetOptionWidget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/editor.xml'],
+
+ /**
+ * @override
+ */
+ start: function () {
+ this._super.apply(this, arguments);
+
+ this._initOverlay();
+
+ // Resize overlay content on window resize because background images
+ // change size, and on carousel slide because they sometimes take up
+ // more space and move elements around them.
+ $(window).on('resize.bgposition', () => this._dimensionOverlay());
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._toggleBgOverlay(false);
+ $(window).off('.bgposition');
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the background type (cover/repeat pattern).
+ *
+ * @see this.selectClass for params
+ */
+ backgroundType: function (previewMode, widgetValue, params) {
+ this.$target.toggleClass('o_bg_img_opt_repeat', widgetValue === 'repeat-pattern');
+ this.$target.css('background-position', '');
+ this.$target.css('background-size', '');
+ },
+ /**
+ * Saves current background position and enables overlay.
+ *
+ * @see this.selectClass for params
+ */
+ backgroundPositionOverlay: async function (previewMode, widgetValue, params) {
+ // Updates the internal image
+ await new Promise(resolve => {
+ this.img = document.createElement('img');
+ this.img.addEventListener('load', () => resolve());
+ this.img.src = getBgImageURL(this.$target[0]);
+ });
+
+ const position = this.$target.css('background-position').split(' ').map(v => parseInt(v));
+ const delta = this._getBackgroundDelta();
+ // originalPosition kept in % for when movement in one direction doesn't make sense
+ this.originalPosition = {
+ left: position[0],
+ top: position[1],
+ };
+ // Convert % values to pixels for current position because mouse movement is in pixels
+ this.currentPosition = {
+ left: position[0] / 100 * delta.x || 0,
+ top: position[1] / 100 * delta.y || 0,
+ };
+ this._toggleBgOverlay(true);
+ },
+ /**
+ * @override
+ */
+ selectStyle: function (previewMode, widgetValue, params) {
+ if (params.cssProperty === 'background-size'
+ && !this.$target.hasClass('o_bg_img_opt_repeat')) {
+ // Disable the option when the image is in cover mode, otherwise
+ // the background-size: auto style may be forced.
+ return;
+ }
+ this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeVisibility: function () {
+ return this._super(...arguments) && !!getBgImageURL(this.$target[0]);
+ },
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ if (methodName === 'backgroundType') {
+ return this.$target.css('background-repeat') === 'repeat' ? 'repeat-pattern' : 'cover';
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Initializes the overlay, binds events to the buttons, inserts it in
+ * the DOM.
+ *
+ * @private
+ */
+ _initOverlay: function () {
+ this.$backgroundOverlay = $(qweb.render('web_editor.background_position_overlay'));
+ this.$overlayContent = this.$backgroundOverlay.find('.o_we_overlay_content');
+ this.$overlayBackground = this.$overlayContent.find('.o_overlay_background');
+
+ this.$backgroundOverlay.on('click', '.o_btn_apply', () => {
+ this.$target.css('background-position', this.$bgDragger.css('background-position'));
+ this._toggleBgOverlay(false);
+ });
+ this.$backgroundOverlay.on('click', '.o_btn_discard', () => {
+ this._toggleBgOverlay(false);
+ });
+
+ this.$backgroundOverlay.insertAfter(this.$overlay);
+ },
+ /**
+ * Sets the overlay in the right place so that the draggable background
+ * renders over the target, and size the background item like the target.
+ *
+ * @private
+ */
+ _dimensionOverlay: function () {
+ if (!this.$backgroundOverlay.is('.oe_active')) {
+ return;
+ }
+ // TODO: change #wrapwrap after web_editor rework.
+ const $wrapwrap = $('#wrapwrap');
+ const targetOffset = this.$target.offset();
+
+ this.$backgroundOverlay.css({
+ width: $wrapwrap.innerWidth(),
+ height: $wrapwrap.innerHeight(),
+ });
+
+ this.$overlayContent.offset(targetOffset);
+
+ this.$bgDragger.css({
+ width: `${this.$target.innerWidth()}px`,
+ height: `${this.$target.innerHeight()}px`,
+ });
+
+ const topPos = (parseInt(this.$overlay.css('top')) - parseInt(this.$overlayContent.css('top')));
+ this.$overlayContent.find('.o_we_overlay_buttons').css('top', `${topPos}px`);
+ },
+ /**
+ * Toggles the overlay's display and renders a background clone inside of it.
+ *
+ * @private
+ * @param {boolean} activate toggle the overlay on (true) or off (false)
+ */
+ _toggleBgOverlay: function (activate) {
+ if (!this.$backgroundOverlay || this.$backgroundOverlay.is('.oe_active') === activate) {
+ return;
+ }
+
+ if (!activate) {
+ this.$backgroundOverlay.removeClass('oe_active');
+ this.trigger_up('unblock_preview_overlays');
+ this.trigger_up('activate_snippet', {$snippet: this.$target});
+
+ $(document).off('click.bgposition');
+ return;
+ }
+
+ this.trigger_up('hide_overlay');
+ this.trigger_up('activate_snippet', {
+ $snippet: this.$target,
+ previewMode: true,
+ });
+ this.trigger_up('block_preview_overlays');
+
+ // Create empty clone of $target with same display size, make it draggable and give it a tooltip.
+ this.$bgDragger = this.$target.clone().empty();
+ // Prevent clone from being seen as editor if target is editor (eg. background on root tag)
+ this.$bgDragger.removeClass('o_editable');
+ // Some CSS child selector rules will not be applied since the clone has a different container from $target.
+ // The background-attachment property should be the same in both $target & $bgDragger, this will keep the
+ // preview more "wysiwyg" instead of getting different result when bg position saved (e.g. parallax snippet)
+ // TODO: improve this to copy all style from $target and override it with overlay related style (copying all
+ // css into $bgDragger will not work since it will change overlay content style too).
+ this.$bgDragger.css('background-attachment', this.$target.css('background-attachment'));
+ this.$bgDragger.on('mousedown', this._onDragBackgroundStart.bind(this));
+ this.$bgDragger.tooltip({
+ title: 'Click and drag the background to adjust its position!',
+ trigger: 'manual',
+ container: this.$backgroundOverlay
+ });
+
+ // Replace content of overlayBackground, activate the overlay and give it the right dimensions.
+ this.$overlayBackground.empty().append(this.$bgDragger);
+ this.$backgroundOverlay.addClass('oe_active');
+ this._dimensionOverlay();
+ this.$bgDragger.tooltip('show');
+
+ // Needs to be deferred or the click event that activated the overlay deactivates it as well.
+ // This is caused by the click event which we are currently handling bubbling up to the document.
+ window.setTimeout(() => $(document).on('click.bgposition', this._onDocumentClicked.bind(this)), 0);
+ },
+ /**
+ * Returns the difference between the target's size and the background's
+ * rendered size. Background position values in % are a percentage of this.
+ *
+ * @private
+ */
+ _getBackgroundDelta: function () {
+ const bgSize = this.$target.css('background-size');
+ if (bgSize !== 'cover') {
+ let [width, height] = bgSize.split(' ');
+ if (width === 'auto' && (height === 'auto' || !height)) {
+ return {
+ x: this.$target.outerWidth() - this.img.naturalWidth,
+ y: this.$target.outerHeight() - this.img.naturalHeight,
+ };
+ }
+ // At least one of width or height is not auto, so we can use it to calculate the other if it's not set
+ [width, height] = [parseInt(width), parseInt(height)];
+ return {
+ x: this.$target.outerWidth() - (width || (height * this.img.naturalWidth / this.img.naturalHeight)),
+ y: this.$target.outerHeight() - (height || (width * this.img.naturalHeight / this.img.naturalWidth)),
+ };
+ }
+
+ const renderRatio = Math.max(
+ this.$target.outerWidth() / this.img.naturalWidth,
+ this.$target.outerHeight() / this.img.naturalHeight
+ );
+
+ return {
+ x: this.$target.outerWidth() - Math.round(renderRatio * this.img.naturalWidth),
+ y: this.$target.outerHeight() - Math.round(renderRatio * this.img.naturalHeight),
+ };
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Drags the overlay's background image, copied to target on "Apply".
+ *
+ * @private
+ */
+ _onDragBackgroundStart: function (ev) {
+ ev.preventDefault();
+ this.$bgDragger.addClass('o_we_grabbing');
+ const $document = $(this.ownerDocument);
+ $document.on('mousemove.bgposition', this._onDragBackgroundMove.bind(this));
+ $document.one('mouseup', () => {
+ this.$bgDragger.removeClass('o_we_grabbing');
+ $document.off('mousemove.bgposition');
+ });
+ },
+ /**
+ * Drags the overlay's background image, copied to target on "Apply".
+ *
+ * @private
+ */
+ _onDragBackgroundMove: function (ev) {
+ ev.preventDefault();
+
+ const delta = this._getBackgroundDelta();
+ this.currentPosition.left = clamp(this.currentPosition.left + ev.originalEvent.movementX, [0, delta.x]);
+ this.currentPosition.top = clamp(this.currentPosition.top + ev.originalEvent.movementY, [0, delta.y]);
+
+ const percentPosition = {
+ left: this.currentPosition.left / delta.x * 100,
+ top: this.currentPosition.top / delta.y * 100,
+ };
+ // In cover mode, one delta will be 0 and dividing by it will yield Infinity.
+ // Defaulting to originalPosition in that case (can't be dragged)
+ percentPosition.left = isFinite(percentPosition.left) ? percentPosition.left : this.originalPosition.left;
+ percentPosition.top = isFinite(percentPosition.top) ? percentPosition.top : this.originalPosition.top;
+
+ this.$bgDragger.css('background-position', `${percentPosition.left}% ${percentPosition.top}%`);
+
+ function clamp(val, bounds) {
+ // We sort the bounds because when one dimension of the rendered background is
+ // larger than the container, delta is negative, and we want to use it as lower bound
+ bounds = bounds.sort();
+ return Math.max(bounds[0], Math.min(val, bounds[1]));
+ }
+ },
+ /**
+ * Deactivates the overlay if the user clicks outside of it.
+ *
+ * @private
+ */
+ _onDocumentClicked: function (ev) {
+ if (!$(ev.target).closest('.o_we_background_position_overlay')) {
+ this._toggleBgOverlay(false);
+ }
+ },
+});
+
+/**
+ * Marks color levels of any element that may get or has a color classes. This
+ * is done for the specific main colorpicker option so that those are marked on
+ * snippet drop (so that base snippet definition do not need to care about that)
+ * and on first focus (for compatibility).
+ */
+registry.ColoredLevelBackground = registry.BackgroundToggler.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ this._markColorLevel();
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ onBuilt: function () {
+ this._markColorLevel();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds a specific class indicating the element is colored so that nested
+ * color classes work (we support one-level). Removing it is not useful,
+ * technically the class can be added on anything that *may* receive a color
+ * class: this does not come with any CSS rule.
+ *
+ * @private
+ */
+ _markColorLevel: function () {
+ this.$target.addClass('o_colored_level');
+ },
+});
+
+/**
+ * Allows to replace a text value with the name of a database record.
+ * @todo replace this mechanism with real backend m2o field ?
+ */
+registry.many2one = SnippetOptionWidget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'],
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.trigger_up('getRecordInfo', _.extend(this.options, {
+ callback: function (recordInfo) {
+ _.defaults(self.options, recordInfo);
+ },
+ }));
+
+ this.Model = this.$target.data('oe-many2one-model');
+ this.ID = +this.$target.data('oe-many2one-id');
+
+ // create search button and bind search bar
+ this.$btn = $(qweb.render('web_editor.many2one.button'))
+ .prependTo(this.$el);
+
+ this.$ul = this.$btn.find('ul');
+ this.$search = this.$ul.find('li:first');
+ this.$search.find('input').on('mousedown click mouseup keyup keydown', function (e) {
+ e.stopPropagation();
+ });
+
+ // move menu item
+ setTimeout(function () {
+ self.$btn.find('a').on('click', function (e) {
+ self._clear();
+ });
+ }, 0);
+
+ // bind search input
+ this.$search.find('input')
+ .focus()
+ .on('keyup', function (e) {
+ self.$overlay.removeClass('o_overlay_hidden');
+ self._findExisting($(this).val());
+ });
+
+ // bind result
+ this.$ul.on('click', 'li:not(:first) a', function (e) {
+ self._selectRecord($(e.currentTarget));
+ });
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ onFocus: function () {
+ this.$target.attr('contentEditable', 'false');
+ this._clear();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Removes the input value and suggestions.
+ *
+ * @private
+ */
+ _clear: function () {
+ var self = this;
+ this.$search.siblings().remove();
+ self.$search.find('input').val('');
+ setTimeout(function () {
+ self.$search.find('input').focus();
+ }, 0);
+ },
+ /**
+ * Find existing record with the given name and suggest them.
+ *
+ * @private
+ * @param {string} name
+ * @returns {Promise}
+ */
+ _findExisting: function (name) {
+ var self = this;
+ var domain = [];
+ if (!name || !name.length) {
+ self.$search.siblings().remove();
+ return;
+ }
+ if (isNaN(+name)) {
+ if (this.Model !== 'res.partner') {
+ domain.push(['name', 'ilike', name]);
+ } else {
+ domain.push('|', ['name', 'ilike', name], ['email', 'ilike', name]);
+ }
+ } else {
+ domain.push(['id', '=', name]);
+ }
+
+ return this._rpc({
+ model: this.Model,
+ method: 'search_read',
+ args: [domain, this.Model === 'res.partner' ? ['name', 'display_name', 'city', 'country_id'] : ['name', 'display_name']],
+ kwargs: {
+ order: [{name: 'name', asc: false}],
+ limit: 5,
+ context: this.options.context,
+ },
+ }).then(function (result) {
+ self.$search.siblings().remove();
+ self.$search.after(qweb.render('web_editor.many2one.search', {contacts: result}));
+ });
+ },
+ /**
+ * Selects the given suggestion and displays it the proper way.
+ *
+ * @private
+ * @param {jQuery} $li
+ */
+ _selectRecord: function ($li) {
+ var self = this;
+
+ this.ID = +$li.data('id');
+ this.$target.attr('data-oe-many2one-id', this.ID).data('oe-many2one-id', this.ID);
+
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+ this.$target.trigger('content_changed');
+
+ if (self.$target.data('oe-type') === 'contact') {
+ $('[data-oe-contact-options]')
+ .filter('[data-oe-model="' + self.$target.data('oe-model') + '"]')
+ .filter('[data-oe-id="' + self.$target.data('oe-id') + '"]')
+ .filter('[data-oe-field="' + self.$target.data('oe-field') + '"]')
+ .filter('[data-oe-contact-options!="' + self.$target.data('oe-contact-options') + '"]')
+ .add(self.$target)
+ .attr('data-oe-many2one-id', self.ID).data('oe-many2one-id', self.ID)
+ .each(function () {
+ var $node = $(this);
+ var options = $node.data('oe-contact-options');
+ self._rpc({
+ model: 'ir.qweb.field.contact',
+ method: 'get_record_to_html',
+ args: [[self.ID]],
+ kwargs: {
+ options: options,
+ context: self.options.context,
+ },
+ }).then(function (html) {
+ $node.html(html);
+ });
+ });
+ } else {
+ self.$target.text($li.data('name'));
+ }
+
+ this._clear();
+ }
+});
+
+/**
+ * Allows to display a warning message on outdated snippets.
+ */
+registry.VersionControl = SnippetOptionWidget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'],
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.trigger_up('get_snippet_versions', {
+ snippetName: this.$target[0].dataset.snippet,
+ onSuccess: snippetVersions => {
+ const isUpToDate = snippetVersions && ['vjs', 'vcss', 'vxml'].every(key => this.$target[0].dataset[key] === snippetVersions[key]);
+ if (!isUpToDate) {
+ this.$el.prepend(qweb.render('web_editor.outdated_block_message'));
+ }
+ },
+ });
+ return this._super(...arguments);
+ },
+});
+
+/**
+ * Handle the save of a snippet as a template that can be reused later
+ */
+registry.SnippetSave = SnippetOptionWidget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/editor.xml'],
+ isTopOption: true,
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see this.selectClass for parameters
+ */
+ saveSnippet: function (previewMode, widgetValue, params) {
+ return new Promise(resolve => {
+ const dialog = new Dialog(this, {
+ title: _t("Save Your Block"),
+ size: 'small',
+ $content: $(qweb.render('web_editor.dialog.save_snippet', {
+ currentSnippetName: _.str.sprintf(_t("Custom %s"), this.data.snippetName),
+ })),
+ buttons: [{
+ text: _t("Save"),
+ classes: 'btn-primary',
+ close: true,
+ click: async () => {
+ const save = await new Promise(resolve => {
+ Dialog.confirm(this, _t("To save a snippet, we need to save all your previous modifications and reload the page."), {
+ buttons: [
+ {
+ text: _t("Save and Reload"),
+ classes: 'btn-primary',
+ close: true,
+ click: () => resolve(true),
+ }, {
+ text: _t("Cancel"),
+ close: true,
+ click: () => resolve(false),
+ }
+ ]
+ });
+ });
+ if (!save) {
+ return;
+ }
+ const snippetKey = this.$target[0].dataset.snippet;
+ let thumbnailURL;
+ this.trigger_up('snippet_thumbnail_url_request', {
+ key: snippetKey,
+ onSuccess: url => thumbnailURL = url,
+ });
+ let context;
+ this.trigger_up('context_get', {
+ callback: ctx => context = ctx,
+ });
+ this.trigger_up('request_save', {
+ reloadEditor: true,
+ onSuccess: async () => {
+ const snippetName = dialog.el.querySelector('.o_we_snippet_name_input').value;
+ const targetCopyEl = this.$target[0].cloneNode(true);
+ delete targetCopyEl.dataset.name;
+ // By the time onSuccess is called after request_save, the
+ // current widget has been destroyed and is orphaned, so this._rpc
+ // will not work as it can't trigger_up. For this reason, we need
+ // to bypass the service provider and use the global RPC directly
+ await rpc.query({
+ model: 'ir.ui.view',
+ method: 'save_snippet',
+ kwargs: {
+ 'name': snippetName,
+ 'arch': targetCopyEl.outerHTML,
+ 'template_key': this.options.snippets,
+ 'snippet_key': snippetKey,
+ 'thumbnail_url': thumbnailURL,
+ 'context': context,
+ },
+ });
+ },
+ });
+ },
+ }, {
+ text: _t("Discard"),
+ close: true,
+ }],
+ }).open();
+ dialog.on('closed', this, () => resolve());
+ });
+ },
+});
+
+/**
+ * Handles the dynamic colors for dynamic SVGs.
+ */
+registry.DynamicSvg = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ start() {
+ this.$target.on('image_changed.DynamicSvg', this._onImageChanged.bind(this));
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this.$target.off('.DynamicSvg');
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the dynamic SVG's dynamic color.
+ *
+ * @see this.selectClass for params
+ */
+ async color(previewMode, widgetValue, params) {
+ const target = this.$target[0];
+ switch (previewMode) {
+ case true:
+ this.previousSrc = target.getAttribute('src');
+ break;
+ case 'reset':
+ target.src = this.previousSrc;
+ return;
+ }
+ const newURL = new URL(target.src, window.location.origin);
+ newURL.searchParams.set('c1', normalizeColor(widgetValue));
+ const src = newURL.pathname + newURL.search;
+ await loadImage(src);
+ target.src = src;
+ if (!previewMode) {
+ this.previousSrc = src;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ switch (methodName) {
+ case 'color':
+ return new URL(this.$target[0].src, window.location.origin).searchParams.get('c1');
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _computeVisibility(methodName, params) {
+ return this.$target.is("img[src^='/web_editor/shape/']");
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onImageChanged(methodName, params) {
+ return this.updateUI();
+ },
+});
+
+return {
+ SnippetOptionWidget: SnippetOptionWidget,
+ snippetOptionRegistry: registry,
+
+ NULL_ID: NULL_ID,
+ UserValueWidget: UserValueWidget,
+ userValueWidgetsRegistry: userValueWidgetsRegistry,
+ UnitUserValueWidget: UnitUserValueWidget,
+
+ addTitleAndAllowedAttributes: _addTitleAndAllowedAttributes,
+ buildElement: _buildElement,
+ buildTitleElement: _buildTitleElement,
+ buildRowElement: _buildRowElement,
+ buildCollapseElement: _buildCollapseElement,
+
+ // Other names for convenience
+ Class: SnippetOptionWidget,
+ registry: registry,
+};
+});
diff --git a/addons/web_editor/static/src/js/editor/summernote.js b/addons/web_editor/static/src/js/editor/summernote.js
new file mode 100644
index 00000000..3b49d1d8
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/summernote.js
@@ -0,0 +1,2527 @@
+odoo.define('web_editor.summernote', function (require) {
+'use strict';
+
+var core = require('web.core');
+require('summernote/summernote'); // wait that summernote is loaded
+var weDefaultOptions = require('web_editor.wysiwyg.default_options');
+
+var _t = core._t;
+
+// Summernote Lib (neek hack to make accessible: method and object)
+// var agent = $.summernote.core.agent;
+var dom = $.summernote.core.dom;
+var range = $.summernote.core.range;
+var list = $.summernote.core.list;
+var key = $.summernote.core.key;
+var eventHandler = $.summernote.eventHandler;
+var editor = eventHandler.modules.editor;
+var renderer = $.summernote.renderer;
+var options = $.summernote.options;
+
+// Browser-unify execCommand
+var oldJustify = {};
+_.each(['Left', 'Right', 'Full', 'Center'], function (align) {
+ oldJustify[align] = editor['justify' + align];
+ editor['justify' + align] = function ($editable, value) {
+ // Before calling the standard function, check all elements which have
+ // an 'align' attribute and mark them with their value
+ var $align = $editable.find('[align]');
+ _.each($align, function (el) {
+ var $el = $(el);
+ $el.data('__align', $el.attr('align'));
+ });
+
+ // Call the standard function
+ oldJustify[align].apply(this, arguments);
+
+ // Then:
+
+ // Remove the text-align of elements which lost the 'align' attribute
+ var $newAlign = $editable.find('[align]');
+ $align.not($newAlign).css('text-align', '');
+
+ // Transform the 'align' attribute into the 'text-align' css
+ // property for elements which received the 'align' attribute or whose
+ // 'align' attribute changed
+ _.each($newAlign, function (el) {
+ var $el = $(el);
+
+ var oldAlignValue = $align.data('__align');
+ var alignValue = $el.attr('align');
+ if (oldAlignValue === alignValue) {
+ // If the element already had an 'align' attribute and that it
+ // did not changed, do nothing (compatibility)
+ return;
+ }
+
+ $el.removeAttr('align');
+ $el.css('text-align', alignValue);
+
+ // Note the first step (removing the text-align of elemnts which
+ // lost the 'align' attribute) is kinda the same as this one, but
+ // this one handles the elements which have been edited with chrome
+ // or with this new system
+ $el.find('*').css('text-align', '');
+ });
+
+ // Unmark the elements
+ $align.removeData('__align');
+ };
+});
+
+
+// Add methods to summernote
+
+dom.hasContentAfter = function (node) {
+ var next;
+ if (dom.isEditable(node)) return;
+ while (node.nextSibling) {
+ next = node.nextSibling;
+ if (next.tagName || dom.isVisibleText(next) || dom.isBR(next)) return next;
+ node = next;
+ }
+};
+dom.hasContentBefore = function (node) {
+ var prev;
+ if (dom.isEditable(node)) return;
+ while (node.previousSibling) {
+ prev = node.previousSibling;
+ if (prev.tagName || dom.isVisibleText(prev) || dom.isBR(prev)) return prev;
+ node = prev;
+ }
+};
+dom.ancestorHaveNextSibling = function (node, pred) {
+ pred = pred || dom.hasContentAfter;
+ while (!dom.isEditable(node) && (!node.nextSibling || !pred(node))) { node = node.parentNode; }
+ return node;
+};
+dom.ancestorHavePreviousSibling = function (node, pred) {
+ pred = pred || dom.hasContentBefore;
+ while (!dom.isEditable(node) && (!node.previousSibling || !pred(node))) { node = node.parentNode; }
+ return node;
+};
+dom.nextElementSibling = function (node) {
+ while (node) {
+ node = node.nextSibling;
+ if (node && node.tagName) {
+ break;
+ }
+ }
+ return node;
+};
+dom.previousElementSibling = function (node) {
+ while (node) {
+ node = node.previousSibling;
+ if (node && node.tagName) {
+ break;
+ }
+ }
+ return node;
+};
+dom.lastChild = function (node) {
+ while (node.lastChild) { node = node.lastChild; }
+ return node;
+};
+dom.firstChild = function (node) {
+ while (node.firstChild) { node = node.firstChild; }
+ return node;
+};
+dom.lastElementChild = function (node, deep) {
+ node = deep ? dom.lastChild(node) : node.lastChild;
+ return !node || node.tagName ? node : dom.previousElementSibling(node);
+};
+dom.firstElementChild = function (node, deep) {
+ node = deep ? dom.firstChild(node) : node.firstChild;
+ return !node || node.tagName ? node : dom.nextElementSibling(node);
+};
+dom.isEqual = function (prev, cur) {
+ if (prev.tagName !== cur.tagName) {
+ return false;
+ }
+ if ((prev.attributes ? prev.attributes.length : 0) !== (cur.attributes ? cur.attributes.length : 0)) {
+ return false;
+ }
+
+ function strip(text) {
+ return text && text.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
+ }
+ var att, att2;
+ loop_prev:
+ for (var a in prev.attributes) {
+ att = prev.attributes[a];
+ for (var b in cur.attributes) {
+ att2 = cur.attributes[b];
+ if (att.name === att2.name) {
+ if (strip(att.value) !== strip(att2.value)) return false;
+ continue loop_prev;
+ }
+ }
+ return false;
+ }
+ return true;
+};
+dom.hasOnlyStyle = function (node) {
+ for (var i = 0; i < node.attributes.length; i++) {
+ var attr = node.attributes[i];
+ if (attr.attributeName !== 'style') {
+ return false;
+ }
+ }
+ return true;
+};
+dom.hasProgrammaticStyle = function (node) {
+ var styles = ["float", "display", "position", "top", "left", "right", "bottom"];
+ for (var i = 0; i < node.style.length; i++) {
+ var style = node.style[i];
+ if (styles.indexOf(style) !== -1) {
+ return true;
+ }
+ }
+ return false;
+};
+dom.mergeFilter = function (prev, cur, parent) {
+ // merge text nodes
+ if (prev && (dom.isText(prev) || (['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P'].indexOf(prev.tagName) !== -1 && prev !== cur.parentNode)) && dom.isText(cur)) {
+ return true;
+ }
+ if (prev && prev.tagName === "P" && dom.isText(cur)) {
+ return true;
+ }
+ if (prev && dom.isText(cur) && !dom.isVisibleText(cur) && (dom.isText(prev) || dom.isVisibleText(prev))) {
+ return true;
+ }
+ if (prev && !dom.isBR(prev) && dom.isEqual(prev, cur) &&
+ ((prev.tagName && dom.getComputedStyle(prev).display === "inline" &&
+ cur.tagName && dom.getComputedStyle(cur).display === "inline"))) {
+ return true;
+ }
+ if (dom.isEqual(parent, cur) &&
+ ((parent.tagName && dom.getComputedStyle(parent).display === "inline" &&
+ cur.tagName && dom.getComputedStyle(cur).display === "inline"))) {
+ return true;
+ }
+ if (parent && cur.tagName === "FONT" && (!cur.firstChild || (!cur.attributes.getNamedItem('style') && !cur.className.length))) {
+ return true;
+ }
+ // On backspace, webkit browsers create a <span> with a bunch of
+ // inline styles "remembering" where they come from.
+ // chances are we had e.g.
+ // <p>foo</p>
+ // <p>bar</p>
+ // merged the lines getting this in webkit
+ // <p>foo<span>bar</span></p>
+ if (parent && cur.tagName === "SPAN" && dom.hasOnlyStyle(cur) && !dom.hasProgrammaticStyle(cur)) {
+ return true;
+ }
+};
+dom.doMerge = function (prev, cur) {
+ if (prev.tagName) {
+ if (prev.childNodes.length && !prev.textContent.match(/\S/) && dom.firstElementChild(prev) && dom.isBR(dom.firstElementChild(prev))) {
+ prev.removeChild(dom.firstElementChild(prev));
+ }
+ if (cur.tagName) {
+ while (cur.firstChild) {
+ prev.appendChild(cur.firstChild);
+ }
+ cur.parentNode.removeChild(cur);
+ } else {
+ prev.appendChild(cur);
+ }
+ } else {
+ if (cur.tagName) {
+ var deep = cur;
+ while (deep.tagName && deep.firstChild) {deep = deep.firstChild;}
+ prev.appendData(deep.textContent);
+ cur.parentNode.removeChild(cur);
+ } else {
+ prev.appendData(cur.textContent);
+ cur.parentNode.removeChild(cur);
+ }
+ }
+};
+dom.merge = function (node, begin, so, end, eo, mergeFilter, all) {
+ mergeFilter = mergeFilter || dom.mergeFilter;
+ var _merged = false;
+ var add = all || false;
+
+ if (!begin) {
+ begin = node;
+ while (begin.firstChild) {begin = begin.firstChild;}
+ so = 0;
+ } else if (begin.tagName && begin.childNodes[so]) {
+ begin = begin.childNodes[so];
+ so = 0;
+ }
+ if (!end) {
+ end = node;
+ while (end.lastChild) {end = end.lastChild;}
+ eo = end.textContent.length-1;
+ } else if (end.tagName && end.childNodes[so]) {
+ end = end.childNodes[so];
+ so = 0;
+ }
+
+ begin = dom.firstChild(begin);
+ if (dom.isText(begin) && so > begin.textContent.length) {
+ so = 0;
+ }
+ end = dom.firstChild(end);
+ if (dom.isText(end) && eo > end.textContent.length) {
+ eo = 0;
+ }
+
+ function __merge(node) {
+ var merged = false;
+ var prev;
+ for (var k=0; k<node.childNodes.length; k++) {
+ var cur = node.childNodes[k];
+
+ if (cur === begin) {
+ if (!all) add = true;
+ }
+
+ __merge(cur);
+ dom.orderClass(dom.node(cur));
+
+ if (!add || !cur) continue;
+ if (cur === end) {
+ if (!all) add = false;
+ }
+
+ // create the first prev value
+ if (!prev) {
+ if (mergeFilter.call(dom, prev, cur, node)) {
+ prev = prev || cur.previousSibling;
+ dom.moveTo(cur, cur.parentNode, cur);
+ k--;
+ } else {
+ prev = cur;
+ }
+ continue;
+ } else if (mergeFilter.call(dom, null, cur, node)) { // merge with parent
+ prev = prev || cur.previousSibling;
+ dom.moveTo(cur, cur.parentNode, cur);
+ k--;
+ continue;
+ }
+
+ // merge nodes
+ if (mergeFilter.call(dom, prev, cur, node)) {
+ var p = prev;
+ var c = cur;
+ // compute prev/end and offset
+ if (prev.tagName) {
+ if (cur.tagName) {
+ if (cur === begin) begin = prev;
+ if (cur === end) end = prev;
+ }
+ } else {
+ if (cur.tagName) {
+ var deep = cur;
+ while (deep.tagName && deep.lastChild) {deep = deep.lastChild;}
+ if (deep === begin) {
+ so += prev.textContent.length;
+ begin = prev;
+ }
+ if (deep === end) {
+ eo += prev.textContent.length;
+ end = prev;
+ }
+ } else {
+ // merge text nodes
+ if (cur === begin) {
+ so += prev.textContent.length;
+ begin = prev;
+ }
+ if (cur === end) {
+ eo += prev.textContent.length;
+ end = prev;
+ }
+ }
+ }
+
+ dom.doMerge(p, c);
+
+ merged = true;
+ k--;
+ continue;
+ }
+
+ prev = cur;
+ }
+
+ // an other loop to merge the new shibbing nodes
+ if (merged) {
+ _merged = true;
+ __merge(node);
+ }
+ }
+ if (node) {
+ __merge(node);
+ }
+
+ return {
+ merged: _merged,
+ sc: begin,
+ ec: end,
+ so: so,
+ eo: eo
+ };
+};
+dom.autoMerge = function (target, previous) {
+ var node = dom.lastChild(target);
+ var nodes = [];
+ var temp;
+
+ while (node) {
+ nodes.push(node);
+ temp = (previous ? dom.hasContentBefore(node) : dom.hasContentAfter(node));
+ if (temp) {
+ if (!dom.isText(node) && !dom.isMergable(node) && temp.tagName !== node.tagName) {
+ nodes = [];
+ }
+ break;
+ }
+ node = node.parentNode;
+ }
+
+ while (nodes.length) {
+ node = nodes.pop();
+ if (node && (temp = (previous ? dom.hasContentBefore(node) : dom.hasContentAfter(node))) &&
+ temp.tagName === node.tagName &&
+ !dom.isText(node) &&
+ dom.isMergable(node) &&
+ !dom.isNotBreakable(node) && !dom.isNotBreakable(previous ? dom.previousElementSibling(node) : dom.nextElementSibling(node))) {
+
+ if (previous) {
+ dom.doMerge(temp, node);
+ } else {
+ dom.doMerge(node, temp);
+ }
+ }
+ }
+};
+dom.removeSpace = function (node, begin, so, end, eo) {
+ var removed = false;
+ var add = node === begin;
+
+ if (node === begin && begin === end && dom.isBR(node)) {
+ return {
+ removed: removed,
+ sc: begin,
+ ec: end,
+ so: so,
+ eo: eo
+ };
+ }
+
+ (function __remove_space(node) {
+ if (!node) return;
+ var t_begin, t_end;
+ for (var k=0; k<node.childNodes.length; k++) {
+ var cur = node.childNodes[k];
+
+ if (cur === begin) add = true;
+
+ if (cur.tagName && cur.tagName !== "SCRIPT" && cur.tagName !== "STYLE" && dom.getComputedStyle(cur).whiteSpace !== "pre") {
+ __remove_space(cur);
+ }
+
+ if (!add) continue;
+ if (cur === end) add = false;
+
+ // remove begin empty text node
+ if (node.childNodes.length > 1 && dom.isText(cur) && !dom.isVisibleText(cur)) {
+ removed = true;
+ if (cur === begin) {
+ t_begin = dom.hasContentBefore(dom.ancestorHavePreviousSibling(cur));
+ if (t_begin) {
+ so = 0;
+ begin = dom.lastChild(t_begin);
+ }
+ }
+ if (cur === end) {
+ t_end = dom.hasContentAfter(dom.ancestorHaveNextSibling(cur));
+ if (t_end) {
+ eo = 1;
+ end = dom.firstChild(t_end);
+ if (dom.isText(end)) {
+ eo = end.textContent.length;
+ }
+ }
+ }
+ cur.parentNode.removeChild(cur);
+ begin = dom.lastChild(begin);
+ end = dom.lastChild(end);
+ k--;
+ continue;
+ }
+
+ // convert HTML space
+ if (dom.isText(cur)) {
+ var text;
+ var temp;
+ var _temp;
+ var exp1 = /[\t\n\r ]+/g;
+ var exp2 = /(?!([ ]|\u00A0)|^)\u00A0(?!([ ]|\u00A0)|$)/g;
+ if (cur === begin) {
+ temp = cur.textContent.substr(0, so);
+ _temp = temp.replace(exp1, ' ').replace(exp2, ' ');
+ so -= temp.length - _temp.length;
+ }
+ if (cur === end) {
+ temp = cur.textContent.substr(0, eo);
+ _temp = temp.replace(exp1, ' ').replace(exp2, ' ');
+ eo -= temp.length - _temp.length;
+ }
+ text = cur.textContent.replace(exp1, ' ').replace(exp2, ' ');
+ removed = removed || cur.textContent.length !== text.length;
+ cur.textContent = text;
+ }
+ }
+ })(node);
+
+ return {
+ removed: removed,
+ sc: begin,
+ ec: end,
+ so: !dom.isBR(begin) && so > 0 ? so : 0,
+ eo: dom.isBR(end) ? 0 : eo
+ };
+};
+dom.removeBetween = function (sc, so, ec, eo, towrite) {
+ var text;
+ if (ec.tagName) {
+ if (ec.childNodes[eo]) {
+ ec = ec.childNodes[eo];
+ eo = 0;
+ } else {
+ ec = dom.lastChild(ec);
+ eo = dom.nodeLength(ec);
+ }
+ }
+ if (sc.tagName) {
+ sc = sc.childNodes[so] || dom.firstChild(ec);
+ so = 0;
+ if (!dom.hasContentBefore(sc) && towrite) {
+ sc.parentNode.insertBefore(document.createTextNode('\u00A0'), sc);
+ }
+ }
+ if (!eo && sc !== ec) {
+ ec = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(ec)) || ec);
+ eo = ec.textContent.length;
+ }
+
+ var ancestor = dom.commonAncestor(sc.tagName ? sc.parentNode : sc, ec.tagName ? ec.parentNode : ec) || dom.ancestor(sc, dom.isEditable);
+
+ if (!dom.isContentEditable(ancestor)) {
+ return {
+ sc: sc,
+ so: so,
+ ec: sc,
+ eo: eo
+ };
+ }
+
+ if (ancestor.tagName) {
+ var ancestor_sc = sc;
+ var ancestor_ec = ec;
+ while (ancestor !== ancestor_sc && ancestor !== ancestor_sc.parentNode) { ancestor_sc = ancestor_sc.parentNode; }
+ while (ancestor !== ancestor_ec && ancestor !== ancestor_ec.parentNode) { ancestor_ec = ancestor_ec.parentNode; }
+
+
+ var node = dom.node(sc);
+ if (!dom.isNotBreakable(node) && !dom.isVoid(sc)) {
+ sc = dom.splitTree(ancestor_sc, {'node': sc, 'offset': so});
+ }
+ var before = dom.hasContentBefore(dom.ancestorHavePreviousSibling(sc));
+
+ var after;
+ if (ec.textContent.slice(eo, Infinity).match(/\S|\u00A0/)) {
+ after = dom.splitTree(ancestor_ec, {'node': ec, 'offset': eo});
+ } else {
+ after = dom.hasContentAfter(dom.ancestorHaveNextSibling(ec));
+ }
+
+ var nodes = dom.listBetween(sc, ec);
+
+ var ancestor_first_last = function (node) {
+ return node === before || node === after;
+ };
+
+ for (var i=0; i<nodes.length; i++) {
+ if (!dom.ancestor(nodes[i], ancestor_first_last) && !$.contains(nodes[i], before) && !$.contains(nodes[i], after) && !dom.isEditable(nodes[i])) {
+ nodes[i].parentNode.removeChild(nodes[i]);
+ }
+ }
+
+ if (dom.listAncestor(after).length <= dom.listAncestor(before).length) {
+ sc = dom.lastChild(before || ancestor);
+ so = dom.nodeLength(sc);
+ } else {
+ sc = dom.firstChild(after);
+ so = 0;
+ }
+
+ if (dom.isVoid(node)) {
+ // we don't need to append a br
+ } else if (towrite && !node.firstChild && node.parentNode && !dom.isNotBreakable(node)) {
+ var br = $("<br/>")[0];
+ node.appendChild(sc);
+ sc = br;
+ so = 0;
+ } else if (!ancestor.children.length && !ancestor.textContent.match(/\S|\u00A0/)) {
+ sc = $("<br/>")[0];
+ so = 0;
+ $(ancestor).prepend(sc);
+ } else if (dom.isText(sc)) {
+ text = sc.textContent.replace(/[ \t\n\r]+$/, '\u00A0');
+ so = Math.min(so, text.length);
+ sc.textContent = text;
+ }
+ } else {
+ text = ancestor.textContent;
+ ancestor.textContent = text.slice(0, so) + text.slice(eo, Infinity).replace(/^[ \t\n\r]+/, '\u00A0');
+ }
+
+ eo = so;
+ if (!dom.isBR(sc) && !dom.isVisibleText(sc) && !dom.isText(dom.hasContentBefore(sc)) && !dom.isText(dom.hasContentAfter(sc))) {
+ ancestor = dom.node(sc);
+ text = document.createTextNode('\u00A0');
+ $(sc).before(text);
+ sc = text;
+ so = 0;
+ eo = 1;
+ }
+
+ var parentNode = sc && sc.parentNode;
+ if (parentNode && sc.tagName === 'BR') {
+ sc = parentNode;
+ ec = parentNode;
+ }
+
+ return {
+ sc: sc,
+ so: so,
+ ec: sc,
+ eo: eo
+ };
+};
+dom.indent = function (node) {
+ var style = dom.isCell(node) ? 'paddingLeft' : 'marginLeft';
+ var margin = parseFloat(node.style[style] || 0)+1.5;
+ node.style[style] = margin + "em";
+ return margin;
+};
+dom.outdent = function (node) {
+ var style = dom.isCell(node) ? 'paddingLeft' : 'marginLeft';
+ var margin = parseFloat(node.style[style] || 0)-1.5;
+ node.style[style] = margin > 0 ? margin + "em" : "";
+ return margin;
+};
+dom.scrollIntoViewIfNeeded = function (node) {
+ node = dom.node(node);
+
+ var $span;
+ if (dom.isBR(node)) {
+ $span = $('<span/>').text('\u00A0');
+ $(node).after($span);
+ node = $span[0];
+ }
+
+ if (node.scrollIntoViewIfNeeded) {
+ node.scrollIntoViewIfNeeded(false);
+ } else {
+ var offsetParent = node.offsetParent;
+ while (offsetParent) {
+ var elY = 0;
+ var elH = node.offsetHeight;
+ var parent = node;
+
+ while (offsetParent && parent) {
+ elY += node.offsetTop;
+
+ // get if a parent have a scrollbar
+ parent = node.parentNode;
+ while (parent !== offsetParent &&
+ (parent.tagName === "BODY" || ["auto", "scroll"].indexOf(dom.getComputedStyle(parent).overflowY) === -1)) {
+ parent = parent.parentNode;
+ }
+ node = parent;
+
+ if (parent !== offsetParent) {
+ elY -= parent.offsetTop;
+ parent = null;
+ }
+
+ offsetParent = node.offsetParent;
+ }
+
+ if ((node.tagName === "BODY" || ["auto", "scroll"].indexOf(dom.getComputedStyle(node).overflowY) !== -1) &&
+ (node.scrollTop + node.clientHeight) < (elY + elH)) {
+ node.scrollTop = (elY + elH) - node.clientHeight;
+ }
+ }
+ }
+
+ if ($span) {
+ $span.remove();
+ }
+
+ return;
+};
+dom.moveTo = function (node, target, before) {
+ var nodes = [];
+ while (node.firstChild) {
+ nodes.push(node.firstChild);
+ if (before) {
+ target.insertBefore(node.firstChild, before);
+ } else {
+ target.appendChild(node.firstChild);
+ }
+ }
+ node.parentNode.removeChild(node);
+ return nodes;
+};
+dom.isMergable = function (node) {
+ return node.tagName && "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small li a ul ol font".indexOf(node.tagName.toLowerCase()) !== -1;
+};
+dom.isSplitable = function (node) {
+ return node.tagName && "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small li a font".indexOf(node.tagName.toLowerCase()) !== -1;
+};
+dom.isRemovableEmptyNode = function (node) {
+ return "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small li a ul ol font span br".indexOf(node.tagName.toLowerCase()) !== -1;
+};
+dom.isForbiddenNode = function (node) {
+ return node.tagName === "BR" || $(node).is(".fa, img");
+};
+/**
+ * @todo 'so' and 'eo' were added as a bugfix and are not given everytime. They
+ * however should be as the function may be wrong without them (for example,
+ * when asking the list between an element and its parent, as there is no path
+ * from the beginning of the former to the beginning of the later).
+ */
+dom.listBetween = function (sc, ec, so, eo) {
+ var nodes = [];
+ var ancestor = dom.commonAncestor(sc, ec);
+ dom.walkPoint({'node': sc, 'offset': so || 0}, {'node': ec, 'offset': eo || 0}, function (point) {
+ if (ancestor !== point.node || ancestor === sc || ancestor === ec) {
+ nodes.push(point.node);
+ }
+ });
+ return list.unique(nodes);
+};
+dom.isNotBreakable = function (node) {
+ // avoid triple click => crappy dom
+ return !dom.isText(node) && !dom.isBR(dom.firstChild(node)) && dom.isVoid(dom.firstChild(node));
+};
+dom.isContentEditable = function (node) {
+ return $(node).closest('[contenteditable]').prop('contenteditable') === 'true';
+};
+dom.isContentEditableFalse = function (node) {
+ return $(node).closest('[contenteditable]').prop('contenteditable') === 'false';
+};
+dom.isFont = function (node) {
+ var nodeName = node && node.nodeName.toUpperCase();
+ return node && (nodeName === "FONT" ||
+ (nodeName === "SPAN" && (
+ node.className.match(/(^|\s)fa(\s|$)/i) ||
+ node.className.match(/(^|\s)(text|bg)-/i) ||
+ (node.attributes.style && node.attributes.style.value.match(/(^|\s)(color|background-color|font-size):/i)))) );
+};
+dom.isVisibleText = function (textNode) {
+ return !!textNode.textContent.match(/\S|\u00A0/);
+};
+var old_isVisiblePoint = dom.isVisiblePoint;
+dom.isVisiblePoint = function (point) {
+ return point.node.nodeType !== 8 && old_isVisiblePoint.apply(this, arguments);
+};
+dom.orderStyle = function (node) {
+ var style = node.getAttribute('style');
+ if (!style) return null;
+ style = style.replace(/[\s\n\r]+/, ' ').replace(/^ ?;? ?| ?;? ?$/g, '').replace(/ ?; ?/g, ';');
+ if (!style.length) {
+ node.removeAttribute("style");
+ return null;
+ }
+ style = style.split(";");
+ style.sort();
+ style = style.join("; ")+";";
+ node.setAttribute('style', style);
+ return style;
+};
+dom.orderClass = function (node) {
+ var className = node.getAttribute && node.getAttribute('class');
+ if (!className) return null;
+ className = className.replace(/[\s\n\r]+/, ' ').replace(/^ | $/g, '').replace(/ +/g, ' ');
+ if (!className.length) {
+ node.removeAttribute("class");
+ return null;
+ }
+ className = className.split(" ");
+ className.sort();
+ className = className.join(" ");
+ node.setAttribute('class', className);
+ return className;
+};
+dom.node = function (node) {
+ return dom.isText(node) ? node.parentNode : node;
+};
+dom.moveContent = function (from, to) {
+ if (from === to) {
+ return;
+ }
+ if (from.parentNode === to) {
+ while (from.lastChild) {
+ dom.insertAfter(from.lastChild, from);
+ }
+ } else {
+ while (from.firstChild && from.firstChild !== to) {
+ to.appendChild(from.firstChild);
+ }
+ }
+};
+dom.getComputedStyle = function (node) {
+ return node.nodeType === Node.COMMENT_NODE ? {} : window.getComputedStyle(node);
+};
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+range.WrappedRange.prototype.reRange = function (keep_end, isNotBreakable) {
+ var sc = this.sc;
+ var so = this.so;
+ var ec = this.ec;
+ var eo = this.eo;
+ isNotBreakable = isNotBreakable || dom.isNotBreakable;
+
+ // search the first snippet editable node
+ var start = keep_end ? ec : sc;
+ while (start) {
+ if (isNotBreakable(start, sc, so, ec, eo)) {
+ break;
+ }
+ start = start.parentNode;
+ }
+
+ // check if the end caret have the same node
+ var lastFilterEnd;
+ var end = keep_end ? sc : ec;
+ while (end) {
+ if (start === end) {
+ break;
+ }
+ if (isNotBreakable(end, sc, so, ec, eo)) {
+ lastFilterEnd = end;
+ }
+ end = end.parentNode;
+ }
+ if (lastFilterEnd) {
+ end = lastFilterEnd;
+ }
+ if (!end) {
+ end = document.getElementsByTagName('body')[0];
+ }
+
+ // if same node, keep range
+ if (start === end || !start) {
+ return this;
+ }
+
+ // reduce or extend the range to don't break a isNotBreakable area
+ if ($.contains(start, end)) {
+
+ if (keep_end) {
+ sc = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(end)) || sc);
+ so = sc.textContent.length;
+ } else if (!eo) {
+ ec = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(end)) || ec);
+ eo = ec.textContent.length;
+ } else {
+ ec = dom.firstChild(dom.hasContentAfter(dom.ancestorHaveNextSibling(end)) || ec);
+ eo = 0;
+ }
+ } else {
+
+ if (keep_end) {
+ sc = dom.firstChild(start);
+ so = 0;
+ } else {
+ ec = dom.lastChild(start);
+ eo = ec.textContent.length;
+ }
+ }
+
+ return new range.WrappedRange(sc, so, ec, eo);
+};
+/**
+ * Returns the image the range is in or matches (if any, false otherwise).
+ *
+ * @todo this implementation may not cover all corner cases but should do the
+ * trick for all reproductible ones
+ * @returns {DOMElement|boolean}
+ */
+range.WrappedRange.prototype.isOnImg = function () {
+ // If not a selection but a cursor position, just check if a point's
+ // ancestor is an image or not
+ if (this.sc === this.ec && this.so === this.eo) {
+ return dom.ancestor(this.sc, dom.isImg);
+ }
+
+ var startPoint = {node: this.sc, offset: this.so};
+ var endPoint = {node: this.ec, offset: this.eo};
+
+ var nb = 0;
+ var image;
+ var textNode;
+ dom.walkPoint(startPoint, endPoint, function (point) {
+ // If the element has children (not a text node and not empty node),
+ // the element cannot be considered as selected (these children will
+ // be processed to determine that)
+ if (dom.hasChildren(point.node)) {
+ return;
+ }
+
+ // Check if an ancestor of the current point is an image
+ var pointImg = dom.ancestor(point.node, dom.isImg);
+ var isText = dom.isText(point.node);
+
+ // Check if a visible element is selected, i.e.
+ // - If an ancestor of the current is an image we did not see yet
+ // - If the point is not in a br or a text (so a node with no children)
+ // - If the point is in a non empty text node we already saw
+ if (pointImg ?
+ (image !== pointImg) :
+ ((!dom.isBR(point.node) && !isText) || (textNode === point.node && point.node.textContent.match(/\S|\u00A0/)))) {
+ nb++;
+ }
+
+ // If an ancestor of the current point is an image, then save it as the
+ // image we are looking for
+ if (pointImg) {
+ image = pointImg;
+ }
+ // If the current point is a text node save it as the last text node
+ // seen (if we see it again, this might mean it is selected)
+ if (isText) {
+ textNode = point.node;
+ }
+ });
+
+ return nb === 1 && image;
+};
+range.WrappedRange.prototype.deleteContents = function (towrite) {
+ if (this.sc === this.ec && this.so === this.eo) {
+ return this;
+ }
+
+ var r;
+ var image = this.isOnImg();
+ if (image) {
+ // If the range matches/is in an image, then the image is to be removed
+ // and the cursor moved to its previous position
+ var parentNode = image.parentNode;
+ var index = _.indexOf(parentNode.childNodes, image);
+ parentNode.removeChild(image);
+ r = new range.WrappedRange(parentNode, index, parentNode, index);
+ } else {
+ r = dom.removeBetween(this.sc, this.so, this.ec, this.eo, towrite);
+ }
+
+ $(dom.node(r.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler
+ return new range.WrappedRange(r.sc, r.so, r.ec, r.eo);
+};
+range.WrappedRange.prototype.clean = function (mergeFilter, all) {
+ var node = dom.node(this.sc === this.ec ? this.sc : this.commonAncestor());
+ node = node || $(this.sc).closest('[contenteditable]')[0];
+ if (node.childNodes.length <=1) {
+ return this;
+ }
+
+ var merge = dom.merge(node, this.sc, this.so, this.ec, this.eo, mergeFilter, all);
+ var rem = dom.removeSpace(node.parentNode, merge.sc, merge.so, merge.ec, merge.eo);
+
+ if (merge.merged || rem.removed) {
+ return range.create(rem.sc, rem.so, rem.ec, rem.eo);
+ }
+ return this;
+};
+range.WrappedRange.prototype.remove = function (mergeFilter) {
+};
+range.WrappedRange.prototype.isOnCellFirst = function () {
+ var node = dom.ancestor(this.sc, function (node) {return ["LI", "DIV", "TD","TH"].indexOf(node.tagName) !== -1;});
+ return node && ["TD","TH"].indexOf(node.tagName) !== -1;
+};
+range.WrappedRange.prototype.isContentEditable = function () {
+ return dom.isContentEditable(this.sc) && (this.sc === this.ec || dom.isContentEditable(this.ec));
+};
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+renderer.tplButtonInfo.fontsize = function (lang, options) {
+ var items = options.fontSizes.reduce(function (memo, v) {
+ return memo + '<a data-event="fontSize" href="#" class="dropdown-item" data-value="' + v + '">' +
+ '<i class="fa fa-check"></i> ' + v +
+ '</a>';
+ }, '');
+
+ var sLabel = '<span class="note-current-fontsize">11</span>';
+ return renderer.getTemplate().button(sLabel, {
+ title: lang.font.size,
+ dropdown: '<div class="dropdown-menu">' + items + '</div>'
+ });
+};
+
+renderer.tplButtonInfo.color = function (lang, options) {
+ var foreColorButtonLabel = '<i class="' + options.iconPrefix + options.icons.color.recent + '"></i>';
+ var backColorButtonLabel = '<i class="' + options.iconPrefix + 'paint-brush"></i>';
+ // TODO Remove recent color button if possible.
+ // It is still put to avoid JS errors when clicking other buttons as the
+ // editor still expects it to exist.
+ var recentColorButton = renderer.getTemplate().button(foreColorButtonLabel, {
+ className: 'note-recent-color d-none',
+ title: lang.color.foreground,
+ event: 'color',
+ value: '{"backColor":"#B35E9B"}'
+ });
+ var foreColorButton = renderer.getTemplate().button(foreColorButtonLabel, {
+ className: 'note-fore-color-preview',
+ title: lang.color.foreground,
+ dropdown: renderer.getTemplate().dropdown('<li><div data-event-name="foreColor" class="colorPalette"/></li>'),
+ });
+ var backColorButton = renderer.getTemplate().button(backColorButtonLabel, {
+ className: 'note-back-color-preview',
+ title: lang.color.background,
+ dropdown: renderer.getTemplate().dropdown('<li><div data-event-name="backColor" class="colorPalette"/></li>'),
+ });
+ return recentColorButton + foreColorButton + backColorButton;
+};
+
+renderer.tplButtonInfo.checklist = function (lang, options) {
+ return '<button ' +
+ 'type="button" ' +
+ 'class="btn btn-secondary btn-sm" ' +
+ 'title="' + _t('Checklist') + '" ' +
+ 'data-event="insertCheckList" ' +
+ 'tabindex="-1" ' +
+ 'data-name="ul" ' +
+ '><i class="fa fa-check-square"></i></button>';
+};
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+key.nameFromCode[46] = 'DELETE';
+key.nameFromCode[27] = 'ESCAPE';
+
+options.keyMap.pc['BACKSPACE'] = 'backspace';
+options.keyMap.pc['DELETE'] = 'delete';
+options.keyMap.pc['ENTER'] = 'enter';
+options.keyMap.pc['ESCAPE'] = 'cancel';
+options.keyMap.mac['SHIFT+TAB'] = 'untab';
+options.keyMap.pc['UP'] = 'up';
+options.keyMap.pc['DOWN'] = 'down';
+
+options.keyMap.mac['BACKSPACE'] = 'backspace';
+options.keyMap.mac['DELETE'] = 'delete';
+options.keyMap.mac['ENTER'] = 'enter';
+options.keyMap.mac['ESCAPE'] = 'cancel';
+options.keyMap.mac['UP'] = 'up';
+options.keyMap.mac['DOWN'] = 'down';
+
+options.styleTags = weDefaultOptions.styleTags;
+
+$.summernote.pluginEvents.insertTable = function (event, editor, layoutInfo, sDim) {
+ var $editable = layoutInfo.editable();
+ $editable.focus();
+ var dimension = sDim.split('x');
+ var r = range.create();
+ if (!r) return;
+ r = r.deleteContents(true);
+
+ var table = editor.table.createTable(dimension[0], dimension[1]);
+ var parent = r.sc;
+ while (dom.isText(parent.parentNode) || dom.isRemovableEmptyNode(parent.parentNode)) {
+ parent = parent.parentNode;
+ }
+ var node = dom.splitTree(parent, {'node': r.sc, 'offset': r.so}) || r.sc;
+ node.parentNode.insertBefore(table, node);
+
+ if ($(node).text() === '' || node.textContent === '\u00A0') {
+ node.parentNode.removeChild(node);
+ }
+
+ editor.afterCommand($editable);
+ event.preventDefault();
+ return false;
+};
+$.summernote.pluginEvents.tab = function (event, editor, layoutInfo, outdent) {
+ var $editable = layoutInfo.editable();
+ $editable.data('NoteHistory').recordUndo($editable, 'tab');
+ var r = range.create();
+ outdent = outdent || false;
+ event.preventDefault();
+
+ if (r && (dom.ancestor(r.sc, dom.isCell) || dom.ancestor(r.ec, dom.isCell))) {
+ if (r.isCollapsed() && r.isOnCell() && r.isOnCellFirst()) {
+ var td = dom.ancestor(r.sc, dom.isCell);
+ if (!outdent && !dom.nextElementSibling(td) && !dom.nextElementSibling(td.parentNode)) {
+ var last = dom.lastChild(td);
+ range.create(last, dom.nodeLength(last), last, dom.nodeLength(last)).select();
+ $.summernote.pluginEvents.enter(event, editor, layoutInfo);
+ } else if (outdent && !dom.previousElementSibling(td) && !$(td.parentNode).text().match(/\S/)) {
+ $.summernote.pluginEvents.backspace(event, editor, layoutInfo);
+ } else {
+ editor.table.tab(r, outdent);
+ }
+ } else {
+ $.summernote.pluginEvents.indent(event, editor, layoutInfo, outdent);
+ }
+ } else if (r && r.isCollapsed()) {
+ if (!r.sc.textContent.slice(0,r.so).match(/\S/) && r.isOnList()) {
+ if (outdent) {
+ $.summernote.pluginEvents.outdent(event, editor, layoutInfo);
+ } else {
+ $.summernote.pluginEvents.indent(event, editor, layoutInfo);
+ }
+ } else {
+ var next;
+ if (!outdent) {
+ if (dom.isText(r.sc)) {
+ next = r.sc.splitText(r.so);
+ } else {
+ next = document.createTextNode('');
+ $(r.sc.childNodes[r.so]).before(next);
+ }
+ editor.typing.insertTab($editable, r, options.tabsize);
+ r = range.create(next, 0, next, 0);
+ r = dom.merge(r.sc.parentNode, r.sc, r.so, r.ec, r.eo, null, true);
+ range.create(r.sc, r.so, r.ec, r.eo).select();
+ } else {
+ r = dom.merge(r.sc.parentNode, r.sc, r.so, r.ec, r.eo, null, true);
+ r = range.create(r.sc, r.so, r.ec, r.eo);
+ if (r.sc.splitText) {
+ next = r.sc.splitText(r.so);
+ r.sc.textContent = r.sc.textContent.replace(/(\u00A0)+$/g, '');
+ next.textContent = next.textContent.replace(/^(\u00A0)+/g, '');
+ range.create(r.sc, r.sc.textContent.length, r.sc, r.sc.textContent.length).select();
+ }
+ }
+ }
+ }
+ return false;
+};
+$.summernote.pluginEvents.untab = function (event, editor, layoutInfo) {
+ return $.summernote.pluginEvents.tab(event, editor, layoutInfo, true);
+};
+$.summernote.pluginEvents.up = function (event, editor, layoutInfo) {
+ var r = range.create();
+ var node = dom.firstChild(r.sc.childNodes[r.so] || r.sc);
+ if (!r.isOnCell()) {
+ return;
+ }
+ // check if an ancestor between node and cell has content before
+ var ancestor = dom.ancestor(node, function (ancestorNode) {
+ return dom.hasContentBefore(ancestorNode) || dom.isCell(ancestorNode);
+ });
+ if (!dom.isCell(ancestor) && (!dom.isBR(dom.hasContentBefore(ancestor)) || !dom.isText(node) || dom.isVisibleText(node) || dom.hasContentBefore(dom.hasContentBefore(ancestor)))) {
+ return;
+ }
+ event.preventDefault();
+ var td = dom.ancestor(r.sc, dom.isCell);
+ var tr = td.parentNode;
+ var target = tr.previousElementSibling && tr.previousElementSibling.children[_.indexOf(tr.children, td)];
+ if (!target) {
+ target = (dom.ancestorHavePreviousSibling(tr) || tr).previousSibling;
+ }
+ if (target) {
+ range.create(dom.lastChild(target), dom.lastChild(target).textContent.length).select();
+ }
+};
+$.summernote.pluginEvents.down = function (event, editor, layoutInfo) {
+ var r = range.create();
+ var node = dom.firstChild(r.sc.childNodes[r.so] || r.sc);
+ if (!r.isOnCell()) {
+ return;
+ }
+ // check if an ancestor between node and cell has content after
+ var ancestor = dom.ancestor(node, function (ancestorNode) {
+ return dom.hasContentAfter(ancestorNode) || dom.isCell(ancestorNode);
+ });
+ if (!dom.isCell(ancestor) && (!dom.isBR(dom.hasContentAfter(ancestor)) || !dom.isText(node) || dom.isVisibleText(node) || dom.hasContentAfter(dom.hasContentAfter(ancestor)))) {
+ return;
+ }
+ event.preventDefault();
+ var td = dom.ancestor(r.sc, dom.isCell);
+ var tr = td.parentNode;
+ var target = tr.nextElementSibling && tr.nextElementSibling.children[_.indexOf(tr.children, td)];
+ if (!target) {
+ target = (dom.ancestorHaveNextSibling(tr) || tr).nextSibling;
+ }
+ if (target) {
+ range.create(dom.firstChild(target), 0).select();
+ }
+};
+$.summernote.pluginEvents.enter = function (event, editor, layoutInfo) {
+ var $editable = layoutInfo.editable();
+ $editable.data('NoteHistory').recordUndo($editable, 'enter');
+
+ var r = range.create();
+ if (!r.isContentEditable()) {
+ event.preventDefault();
+ return false;
+ }
+ if (!r.isCollapsed()) {
+ r = r.deleteContents();
+ r.select();
+ }
+
+ var br = $("<br/>")[0];
+
+ // set selection outside of A if range is at beginning or end
+ var elem = dom.isBR(elem) ? elem.parentNode : dom.node(r.sc);
+ if (elem.tagName === "A") {
+ if (r.so === 0 && dom.firstChild(elem) === r.sc) {
+ r.ec = r.sc = dom.hasContentBefore(elem) || $(dom.createText('')).insertBefore(elem)[0];
+ r.eo = r.so = dom.nodeLength(r.sc);
+ r.select();
+ } else if (dom.nodeLength(r.sc) === r.so && dom.lastChild(elem) === r.sc) {
+ r.ec = r.sc = dom.hasContentAfter(elem) || dom.insertAfter(dom.createText(''), elem);
+ r.eo = r.so = 0;
+ r.select();
+ }
+ }
+
+ var node;
+ var $node;
+ var $clone;
+ var contentBefore = r.sc.textContent.slice(0,r.so).match(/\S|\u00A0/);
+ if (!contentBefore && dom.isText(r.sc)) {
+ node = r.sc.previousSibling;
+ while (!contentBefore && node && dom.isText(node)) {
+ contentBefore = dom.isVisibleText(node);
+ node = node.previousSibling;
+ }
+ }
+
+ node = dom.node(r.sc);
+ var exist = r.sc.childNodes[r.so] || r.sc;
+ exist = dom.isVisibleText(exist) || dom.isBR(exist) ? exist : dom.hasContentAfter(exist) || (dom.hasContentBefore(exist) || exist);
+
+ // table: add a tr
+ var td = dom.ancestor(node, dom.isCell);
+ if (td && !dom.nextElementSibling(node) && !dom.nextElementSibling(td) && !dom.nextElementSibling(td.parentNode) && (!dom.isText(r.sc) || !r.sc.textContent.slice(r.so).match(/\S|\u00A0/))) {
+ $node = $(td.parentNode);
+ $clone = $node.clone();
+ $clone.children().html(dom.blank);
+ $node.after($clone);
+ node = dom.firstElementChild($clone[0]) || $clone[0];
+ range.create(node, 0, node, 0).select();
+ dom.scrollIntoViewIfNeeded(br);
+ event.preventDefault();
+ return false;
+ }
+
+ var last = node;
+ while (node && dom.isSplitable(node) && !dom.isList(node)) {
+ last = node;
+ node = node.parentNode;
+ }
+
+ if (last === node && !dom.isBR(node)) {
+ node = r.insertNode(br, true);
+ if (isFormatNode(last.firstChild) && $(last).closest(options.styleTags.join(',')).length) {
+ dom.moveContent(last.firstChild, last);
+ last.removeChild(last.firstChild);
+ }
+ do {
+ node = dom.hasContentAfter(node);
+ } while (node && dom.isBR(node));
+
+ // create an other br because the user can't see the new line with only br in a block
+ if (!node && (!br.nextElementSibling || !dom.isBR(br.nextElementSibling))) {
+ $(br).before($("<br/>")[0]);
+ }
+ node = br.nextSibling || br;
+ } else if (last === node && dom.isBR(node)) {
+ $(node).after(br);
+ node = br;
+ } else if (!r.so && r.isOnList() && !r.sc.textContent.length && !dom.ancestor(r.sc, dom.isLi).nextElementSibling) {
+ // double enter on the end of a list = new line out of the list
+ $('<p></p>').append(br).insertAfter(dom.ancestor(r.sc, dom.isList));
+ node = br;
+ } else if (dom.isBR(exist) && $(r.sc).closest('blockquote, pre').length && !dom.hasContentAfter($(exist.parentNode).closest('blockquote *, pre *').length ? exist.parentNode : exist)) {
+ // double enter on the end of a blockquote & pre = new line out of the list
+ $('<p></p>').append(br).insertAfter($(r.sc).closest('blockquote, pre'));
+ node = br;
+ } else if (dom.isEditable(dom.node(r.sc))) {
+ // if we are directly in an editable, only SHIFT + ENTER should add a newline
+ node = null;
+ } else if (last === r.sc) {
+ if (dom.isBR(last)) {
+ last = last.parentNode;
+ }
+ $node = $(last);
+ $clone = $node.clone().text("");
+ $node.after($clone);
+ node = dom.node(dom.firstElementChild($clone[0]) || $clone[0]);
+ $(node).html(br);
+ node = br;
+ } else {
+ node = dom.splitTree(last, {'node': r.sc, 'offset': r.so}) || r.sc;
+ if (!contentBefore) {
+ // dom.node chooses the parent if node is text
+ var cur = dom.node(dom.lastChild(node.previousSibling));
+ if (!dom.isBR(cur)) {
+ // We should concat what was before with a <br>
+ $(cur).html(cur.innerHTML + br.outerHTML);
+ }
+ }
+ if (!dom.isVisibleText(node)) {
+ node = dom.firstChild(node);
+ $(dom.node( dom.isBR(node) ? node.parentNode : node )).html(br);
+ node = br;
+ }
+ }
+
+ if (node) {
+ node = dom.firstChild(node);
+ if (dom.isBR(node)) {
+ range.createFromNode(node).select();
+ } else {
+ range.create(node,0).select();
+ }
+ dom.scrollIntoViewIfNeeded(node);
+ }
+ event.preventDefault();
+ return false;
+};
+$.summernote.pluginEvents.visible = function (event, editor, layoutInfo) {
+ var $editable = layoutInfo.editable();
+ $editable.data('NoteHistory').recordUndo($editable, "visible");
+
+ var r = range.create();
+ if (!r) return;
+
+ if (!r.isCollapsed()) {
+ if ((dom.isCell(dom.node(r.sc)) || dom.isCell(dom.node(r.ec))) && dom.node(r.sc) !== dom.node(r.ec)) {
+ remove_table_content(r);
+ r = range.create(r.ec, 0);
+ }
+ r.select();
+ }
+
+ // don't write in forbidden tag (like span for font awsome)
+ var node = dom.firstChild(r.sc.tagName && r.so ? r.sc.childNodes[r.so] || r.sc : r.sc);
+ while (node.parentNode) {
+ if (dom.isForbiddenNode(node)) {
+ var text = node.previousSibling;
+ if (text && dom.isText(text) && dom.isVisibleText(text)) {
+ range.create(text, text.textContent.length, text, text.textContent.length).select();
+ } else {
+ text = node.parentNode.insertBefore(document.createTextNode( "." ), node);
+ range.create(text, 1, text, 1).select();
+ setTimeout(function () {
+ var text = range.create().sc;
+ text.textContent = text.textContent.replace(/^./, '');
+ range.create(text, text.textContent.length, text, text.textContent.length).select();
+ },0);
+ }
+ break;
+ }
+ node = node.parentNode;
+ }
+
+ return true;
+};
+
+function remove_table_content(r) {
+ var nodes = dom.listBetween(r.sc, r.ec, r.so, r.eo);
+ if (dom.isText(r.sc)) {
+ r.sc.textContent = r.sc.textContent.slice(0, r.so);
+ }
+ if (dom.isText(r.ec)) {
+ r.ec.textContent = r.ec.textContent.slice(r.eo);
+ }
+ for (var i in nodes) {
+ var node = nodes[i];
+ if (node === r.sc || node === r.ec || $.contains(node, r.sc) || $.contains(node, r.ec)) {
+ continue;
+ } else if (dom.isCell(node)) {
+ $(node).html("<br/>");
+ } else if (node.parentNode) {
+ do {
+ var parent = node.parentNode;
+ parent.removeChild(node);
+ node = parent;
+ } while (!dom.isVisibleText(node) && !dom.firstElementChild(node) &&
+ !dom.isCell(node) &&
+ node.parentNode && !$(node.parentNode).hasClass('o_editable'));
+ }
+ }
+ return false;
+}
+
+$.summernote.pluginEvents.delete = function (event, editor, layoutInfo) {
+ var $editable = layoutInfo.editable();
+ $editable.data('NoteHistory').recordUndo($editable, "delete");
+
+ var r = range.create();
+ if (!r) return;
+ if (!r.isContentEditable()) {
+ event.preventDefault();
+ return false;
+ }
+ if (!r.isCollapsed()) {
+ if (dom.isCell(dom.node(r.sc)) || dom.isCell(dom.node(r.ec))) {
+ remove_table_content(r);
+ range.create(r.ec, 0).select();
+ } else {
+ r = r.deleteContents();
+ r.select();
+ }
+ event.preventDefault();
+ return false;
+ }
+
+ var target = r.ec;
+ var offset = r.eo;
+ if (target.tagName && target.childNodes[offset]) {
+ target = target.childNodes[offset];
+ offset = 0;
+ }
+
+ var node = dom.node(target);
+ var data = dom.merge(node, target, offset, target, offset, null, true);
+ data = dom.removeSpace(node.parentNode, data.sc, data.so, data.ec, data.eo);
+ r = range.create(data.sc, data.so);
+ r.select();
+ target = r.sc;
+ offset = r.so;
+
+ while (!dom.hasContentAfter(node) && !dom.hasContentBefore(node) && !dom.isImg(node)) {node = node.parentNode;}
+
+ var contentAfter = target.textContent.slice(offset,Infinity).match(/\S|\u00A0/);
+ var content = target.textContent.replace(/[ \t\r\n]+$/, '');
+ var temp;
+ var temp2;
+ var next;
+
+ // media
+ if (dom.isImg(node) || (!contentAfter && dom.isImg(dom.hasContentAfter(node)))) {
+ var parent;
+ var index;
+ if (!dom.isImg(node)) {
+ node = dom.hasContentAfter(node);
+ }
+ while (dom.isImg(node)) {
+ parent = node.parentNode;
+ index = dom.position(node);
+ if (index>0) {
+ next = node.previousSibling;
+ r = range.create(next, next.textContent.length);
+ } else {
+ r = range.create(parent, 0);
+ }
+ if (!dom.hasContentAfter(node) && !dom.hasContentBefore(node)) {
+ parent.appendChild($('<br/>')[0]);
+ }
+ parent.removeChild(node);
+ node = parent;
+ r.select();
+ }
+ }
+ // empty tag
+ else if (!content.length && target.tagName && dom.isRemovableEmptyNode(dom.isBR(target) ? target.parentNode : target)) {
+ if (node === $editable[0] || $.contains(node, $editable[0])) {
+ event.preventDefault();
+ return false;
+ }
+ var before = false;
+ next = dom.hasContentAfter(dom.ancestorHaveNextSibling(node));
+ if (!dom.isContentEditable(next)) {
+ before = true;
+ next = dom.hasContentBefore(dom.ancestorHavePreviousSibling(node));
+ }
+ dom.removeSpace(next.parentNode, next, 0, next, 0); // clean before jump for not select invisible space between 2 tag
+ next = dom.firstChild(next);
+ node.parentNode.removeChild(node);
+ range.create(next, before ? next.textContent.length : 0).select();
+ }
+ // normal feature if same tag and not the end
+ else if (contentAfter) {
+ return true;
+ }
+ // merge with the next text node
+ else if (dom.isText(target) && (temp = dom.hasContentAfter(target)) && dom.isText(temp)) {
+ return true;
+ }
+ //merge with the next block
+ else if ((temp = dom.ancestorHaveNextSibling(target)) &&
+ !r.isOnCell() &&
+ dom.isMergable(temp) &&
+ dom.isMergable(temp2 = dom.hasContentAfter(temp)) &&
+ temp.tagName === temp2.tagName &&
+ (temp.tagName !== "LI" || !$('ul,ol', temp).length) && (temp2.tagName !== "LI" || !$('ul,ol', temp2).length) && // protect li
+ !dom.isNotBreakable(temp) &&
+ !dom.isNotBreakable(temp2)) {
+ dom.autoMerge(target, false);
+ next = dom.firstChild(dom.hasContentAfter(dom.ancestorHaveNextSibling(target)));
+ if (dom.isBR(next)) {
+ if (dom.position(next) === 0) {
+ range.create(next.parentNode, 0).select();
+ }
+ else {
+ range.create(next.previousSibling, next.previousSibling.textContent.length).select();
+ }
+ next.parentNode.removeChild(next);
+ } else {
+ range.create(next, 0).select();
+ }
+ }
+ // jump to next node for delete
+ else if ((temp = dom.ancestorHaveNextSibling(target)) && (temp2 = dom.hasContentAfter(temp)) && dom.isContentEditable(temp2)) {
+
+ dom.removeSpace(temp2.parentNode, temp2, 0, temp, 0); // clean before jump for not select invisible space between 2 tag
+ temp2 = dom.firstChild(temp2);
+
+ r = range.create(temp2, 0);
+ r.select();
+
+ if ((dom.isText(temp) || dom.getComputedStyle(temp).display === "inline") && (dom.isText(temp2) || dom.getComputedStyle(temp2).display === "inline")) {
+ if (dom.isText(temp2)) {
+ temp2.textContent = temp2.textContent.replace(/^\s*\S/, '');
+ } else {
+ $.summernote.pluginEvents.delete(event, editor, layoutInfo);
+ }
+ }
+ }
+
+ $(dom.node(r.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler
+ event.preventDefault();
+ return false;
+};
+$.summernote.pluginEvents.backspace = function (event, editor, layoutInfo) {
+ var $editable = layoutInfo.editable();
+ $editable.data('NoteHistory').recordUndo($editable, "backspace");
+
+ var r = range.create();
+ if (!r) return;
+ if (!r.isContentEditable()) {
+ event.preventDefault();
+ return false;
+ }
+ if (!r.isCollapsed()) {
+ if (dom.isCell(dom.node(r.sc)) || dom.isCell(dom.node(r.ec))) {
+ remove_table_content(r);
+ range.create(r.sc, dom.nodeLength(r.sc)).select();
+ } else {
+ r = r.deleteContents();
+ r.select();
+ }
+ event.preventDefault();
+ return false;
+ }
+
+ var target = r.sc;
+ var offset = r.so;
+ if (target.tagName && target.childNodes[offset]) {
+ target = target.childNodes[offset];
+ offset = 0;
+ }
+
+ var node = dom.node(target);
+ var data = dom.merge(node, target, offset, target, offset, null, true);
+ data = dom.removeSpace(node.parentNode, data.sc, data.so, data.ec, data.eo);
+ r = dom.isVoid(data.sc) ? range.createFromNode(data.sc) : range.create(data.sc, data.so);
+ r.select();
+ target = r.sc;
+ offset = r.so;
+ if (target.tagName && target.childNodes[offset]) {
+ target = target.childNodes[offset];
+ offset = 0;
+ node = dom.node(target);
+ }
+
+ while (node.parentNode && !dom.hasContentAfter(node) && !dom.hasContentBefore(node) && !dom.isImg(node)) {node = node.parentNode;}
+
+ var contentBefore = target.textContent.slice(0,offset).match(/\S|\u00A0/);
+ var content = target.textContent.replace(/[ \t\r\n]+$/, '');
+ var temp;
+ var temp2;
+ var prev;
+
+ // delete media
+ if (dom.isImg(node) || (!contentBefore && dom.isImg(dom.hasContentBefore(node)))) {
+ if (!dom.isImg(node)) {
+ node = dom.hasContentBefore(node);
+ }
+ range.createFromNode(node).select();
+ $.summernote.pluginEvents.delete(event, editor, layoutInfo);
+ }
+ // table tr td
+ else if (r.isOnCell() && !offset && (target === (temp = dom.ancestor(target, dom.isCell)) || target === temp.firstChild || (dom.isText(temp.firstChild) && !dom.isVisibleText(temp.firstChild) && target === temp.firstChild.nextSibling))) {
+ if (dom.previousElementSibling(temp)) {
+ var td = dom.previousElementSibling(temp);
+ node = td.lastChild || td;
+ } else {
+ var tr = temp.parentNode;
+ var prevTr = dom.previousElementSibling(tr);
+ if (!$(temp.parentNode).text().match(/\S|\u00A0/)) {
+ if (prevTr) {
+ node = dom.lastChild(dom.lastElementChild(prevTr));
+ } else {
+ node = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(tr)) || $editable.get(0));
+ }
+ $(tr).empty();
+ if (!$(tr).closest('table').has('td, th').length) {
+ $(tr).closest('table').remove();
+ }
+ $(tr).remove();
+ range.create(node, node.textContent.length, node, node.textContent.length).select();
+ } else {
+ node = dom.lastElementChild(prevTr).lastChild || dom.lastElementChild(prevTr);
+ }
+ }
+ if (dom.isBR(node)) {
+ range.createFromNode(node).select();
+ } else {
+ range.create(node, dom.nodeLength(node)).select();
+ }
+ }
+ // empty tag
+ else if (!content.length && target.tagName && dom.isRemovableEmptyNode(target)) {
+ if (node === $editable[0] || $.contains(node, $editable[0])) {
+ event.preventDefault();
+ return false;
+ }
+ var before = true;
+ prev = dom.hasContentBefore(dom.ancestorHavePreviousSibling(node));
+ if (!dom.isContentEditable(prev)) {
+ before = false;
+ prev = dom.hasContentAfter(dom.ancestorHaveNextSibling(node));
+ }
+ dom.removeSpace(prev.parentNode, prev, 0, prev, 0); // clean before jump for not select invisible space between 2 tag
+ prev = dom.lastChild(prev);
+ node.parentNode.removeChild(node);
+ range.createFromNode(prev).select();
+ range.create(prev, before ? prev.textContent.length : 0).select();
+ }
+ // normal feature if same tag and not the begin
+ else if (contentBefore) {
+ return true;
+ }
+ // merge with the previous text node
+ else if (dom.isText(target) && (temp = dom.hasContentBefore(target)) && (dom.isText(temp) || dom.isBR(temp))) {
+ return true;
+ }
+ //merge with the previous block
+ else if ((temp = dom.ancestorHavePreviousSibling(target)) &&
+ dom.isMergable(temp) &&
+ dom.isMergable(temp2 = dom.hasContentBefore(temp)) &&
+ temp.tagName === temp2.tagName &&
+ (temp.tagName !== "LI" || !$('ul,ol', temp).length) && (temp2.tagName !== "LI" || !$('ul,ol', temp2).length) && // protect li
+ !dom.isNotBreakable(temp) &&
+ !dom.isNotBreakable(temp2)) {
+ prev = dom.firstChild(target);
+ dom.autoMerge(target, true);
+ range.create(prev, 0).select();
+ }
+ // jump to previous node for delete
+ else if ((temp = dom.ancestorHavePreviousSibling(target)) && (temp2 = dom.hasContentBefore(temp)) && dom.isContentEditable(temp2)) {
+
+ dom.removeSpace(temp2.parentNode, temp2, 0, temp, 0); // clean before jump for not select invisible space between 2 tag
+ temp2 = dom.lastChild(temp2);
+
+ r = range.create(temp2, temp2.textContent.length, temp2, temp2.textContent.length);
+ r.select();
+
+ if ((dom.isText(temp) || dom.getComputedStyle(temp).display === "inline") && (dom.isText(temp2) || dom.getComputedStyle(temp2).display === "inline")) {
+ if (dom.isText(temp2)) {
+ temp2.textContent = temp2.textContent.replace(/\S\s*$/, '');
+ } else {
+ $.summernote.pluginEvents.backspace(event, editor, layoutInfo);
+ }
+ }
+ }
+
+ r = range.create();
+ if (r) {
+ $(dom.node(r.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler
+ dom.scrollIntoViewIfNeeded(r.sc.parentNode.previousElementSibling || r.sc);
+ }
+
+ event.preventDefault();
+ return false;
+};
+
+function isFormatNode(node) {
+ return node.tagName && options.styleTags.indexOf(node.tagName.toLowerCase()) !== -1;
+}
+
+$.summernote.pluginEvents.insertUnorderedList = function (event, editor, layoutInfo, type) {
+ var $editable = layoutInfo.editable();
+ $editable.focus();
+ $editable.data('NoteHistory').recordUndo($editable);
+
+ type = type || "UL";
+ var sorted = type === "OL";
+
+ var parent;
+ var r = range.create();
+ if (!r) return;
+ var node = r.sc;
+ while (node && node !== $editable[0]) {
+
+ parent = node.parentNode;
+ if (node.tagName === (sorted ? "UL" : "OL")) {
+
+ var ul = document.createElement(sorted ? "ol" : "ul");
+ ul.className = node.className;
+ if (type !== 'checklist') {
+ ul.classList.remove('o_checklist');
+ } else {
+ ul.classList.add('o_checklist');
+ }
+ parent.insertBefore(ul, node);
+ while (node.firstChild) {
+ ul.appendChild(node.firstChild);
+ }
+ parent.removeChild(node);
+ r.select();
+ return;
+
+ } else if (node.tagName === (sorted ? "OL" : "UL")) {
+
+ if (type === 'checklist' && !node.classList.contains('o_checklist')) {
+ node.classList.add('o_checklist');
+ return;
+ } else if (type === 'UL' && node.classList.contains('o_checklist')) {
+ node.classList.remove('o_checklist');
+ return;
+ }
+
+ var lis = [];
+ for (var i=0; i<node.children.length; i++) {
+ lis.push(node.children[i]);
+ }
+
+ if (parent.tagName === "LI") {
+ node = parent;
+ parent = node.parentNode;
+ _.each(lis, function (li) {
+ parent.insertBefore(li, node);
+ });
+ } else {
+ _.each(lis, function (li) {
+ while (li.firstChild) {
+ parent.insertBefore(li.firstChild, node);
+ }
+ });
+ }
+
+ parent.removeChild(node);
+ r.select();
+ return;
+
+ }
+ node = parent;
+ }
+
+ var p0 = r.sc;
+ while (p0 && p0.parentNode && p0.parentNode !== $editable[0] && !isFormatNode(p0)) {
+ p0 = p0.parentNode;
+ }
+ if (!p0) return;
+ var p1 = r.ec;
+ while (p1 && p1.parentNode && p1.parentNode !== $editable[0] && !isFormatNode(p1)) {
+ p1 = p1.parentNode;
+ }
+ if (!p0.parentNode || p0.parentNode !== p1.parentNode) {
+ return;
+ }
+
+ parent = p0.parentNode;
+ ul = document.createElement(sorted ? "ol" : "ul");
+ if (type === 'checklist') {
+ ul.classList.add('o_checklist');
+ }
+ parent.insertBefore(ul, p0);
+ var childNodes = parent.childNodes;
+ var brs = [];
+ var begin = false;
+ for (i = 0; i < childNodes.length; i++) {
+ if (begin && dom.isBR(childNodes[i])) {
+ parent.removeChild(childNodes[i]);
+ i--;
+ }
+ if ((!dom.isText(childNodes[i]) && !isFormatNode(childNodes[i])) || (!ul.firstChild && childNodes[i] !== p0) ||
+ $.contains(ul, childNodes[i]) || (dom.isText(childNodes[i]) && !childNodes[i].textContent.match(/\S|u00A0/))) {
+ continue;
+ }
+ begin = true;
+ var li = document.createElement('li');
+ ul.appendChild(li);
+ li.appendChild(childNodes[i]);
+ if (li.firstChild === p1) {
+ break;
+ }
+ i--;
+ }
+ if (dom.isBR(childNodes[i])) {
+ parent.removeChild(childNodes[i]);
+ }
+
+ for (i = 0; i < brs.length ; i++) {
+ parent.removeChild(brs[i]);
+ }
+ r.clean().select();
+ event.preventDefault();
+
+ return false;
+};
+$.summernote.pluginEvents.insertOrderedList = function (event, editor, layoutInfo) {
+ $.summernote.pluginEvents.insertUnorderedList(event, editor, layoutInfo, "OL");
+};
+$.summernote.pluginEvents.insertCheckList = function (event, editor, layoutInfo) {
+ $.summernote.pluginEvents.insertUnorderedList(event, editor, layoutInfo, "checklist");
+ $(range.create().sc.parentNode).trigger('input'); // to update checklist-id
+};
+$.summernote.pluginEvents.indent = function (event, editor, layoutInfo, outdent) {
+ var $editable = layoutInfo.editable();
+ $editable.data('NoteHistory').recordUndo($editable);
+ var r = range.create();
+ if (!r) return;
+
+ var flag = false;
+ function indentUL(UL, start, end) {
+ var next;
+ var previous;
+ var tagName = UL.tagName;
+ var node = UL.firstChild;
+ var ul = document.createElement(tagName);
+ ul.className = UL.className;
+ var li = document.createElement("li");
+ li.classList.add('o_indent');
+ li.appendChild(ul);
+
+ if (flag) {
+ flag = 1;
+ }
+
+ // create and fill ul into a li
+ while (node) {
+ if (flag === 1 || node === start || $.contains(node, start)) {
+ flag = true;
+ if (previous) {
+ if (dom.isList(previous.lastChild)) {
+ ul = previous.lastChild;
+ } else {
+ previous.appendChild(ul);
+ }
+ } else {
+ node.parentNode.insertBefore(li, node);
+ }
+ }
+ next = dom.nextElementSibling(node);
+ if (flag) {
+ ul.appendChild(node);
+ }
+ if (node === end || $.contains(node, end)) {
+ flag = false;
+ break;
+ }
+ previous = node;
+ node = next;
+ }
+
+ var temp;
+ var prev = dom.previousElementSibling(li);
+ if (prev && prev.tagName === "LI" && (temp = dom.firstElementChild(prev)) && temp.tagName === tagName && ((dom.firstElementChild(prev) || prev.firstChild) !== ul)) {
+ dom.doMerge(dom.firstElementChild(prev) || prev.firstChild, ul);
+ li = prev;
+ li.parentNode.removeChild(dom.nextElementSibling(li));
+ }
+ next = dom.nextElementSibling(li);
+ if (next && next.tagName === "LI" && (temp = dom.firstElementChild(next)) && temp.tagName === tagName && (dom.firstElementChild(li) !== dom.firstElementChild(next))) {
+ dom.doMerge(dom.firstElementChild(li), dom.firstElementChild(next));
+ li.parentNode.removeChild(dom.nextElementSibling(li));
+ }
+ }
+ function outdenttUL(UL, start, end) {
+ var next;
+ var node = UL.firstChild;
+ var parent = UL.parentNode;
+ var li = UL.parentNode.tagName === "LI" ? UL.parentNode : UL;
+ var ul = UL.parentNode.tagName === "LI" ? UL.parentNode.parentNode : UL.parentNode;
+ start = dom.ancestor(start, dom.isLi);
+ end = dom.ancestor(end, dom.isLi);
+
+ if (ul.tagName !== "UL" && ul.tagName !== "OL") return;
+
+ // create and fill ul into a li
+ while (node) {
+ if (node === start || $.contains(node, start)) {
+ flag = true;
+ if (dom.previousElementSibling(node) && li.tagName === "LI") {
+ li = dom.splitTree(li, dom.prevPoint({'node': node, 'offset': 0}));
+ }
+ }
+ next = dom.nextElementSibling(node);
+ if (flag) {
+ var $succeeding = $(node).nextAll();
+ ul = node.parentNode;
+ if (dom.previousElementSibling(ul)) {
+ dom.insertAfter(node, li);
+ } else {
+ li.parentNode.insertBefore(node, li);
+ }
+ $succeeding.insertAfter(node);
+ if (!ul.children.length) {
+ if (ul.parentNode.tagName === "LI" && !dom.previousElementSibling(ul)) {
+ ul = ul.parentNode;
+ }
+ ul.parentNode.removeChild(ul);
+ }
+ flag = false;
+ break;
+ }
+
+ if (node === end || $.contains(node, end)) {
+ flag = false;
+ break;
+ }
+ node = next;
+ }
+
+ dom.merge(parent, start, 0, end, 1, null, true);
+ }
+ function indentOther(p, start, end) {
+ if (p === start || $.contains(p, start) || $.contains(start, p)) {
+ flag = true;
+ }
+ if (flag) {
+ if (outdent) {
+ dom.outdent(p);
+ } else {
+ dom.indent(p);
+ }
+ }
+ if (p === end || $.contains(p, end) || $.contains(end, p)) {
+ flag = false;
+ }
+ }
+
+ var ancestor = r.commonAncestor();
+ var $dom = $(ancestor);
+
+ if (!dom.isList(ancestor)) {
+ if (dom.isList(ancestor.parentNode)) {
+ $dom = $(ancestor.parentNode);
+ } else {
+ // to indent a selection, we indent the child nodes of the common
+ // ancestor that contains this selection
+ $dom = $(dom.node(ancestor)).children();
+ }
+ }
+ if (!$dom.not('br').length) {
+ // if selection is inside a list, we indent its list items
+ $dom = $(dom.ancestor(r.sc, dom.isList));
+ if (!$dom.length) {
+ // if the selection is contained in a single HTML node, we indent
+ // the first ancestor 'content block' (P, H1, PRE, ...) or TD
+ $dom = $(r.sc).closest(options.styleTags.join(',')+',td');
+ }
+ }
+
+ // if select tr, take the first td
+ $dom = $dom.map(function () { return this.tagName === "TR" ? dom.firstElementChild(this) : this; });
+
+ $dom.each(function () {
+ if (flag || $.contains(this, r.sc)) {
+ if (dom.isList(this)) {
+ if (outdent) {
+ outdenttUL(this, r.sc, r.ec);
+ } else {
+ indentUL(this, r.sc, r.ec);
+ }
+ } else if (isFormatNode(this) || dom.ancestor(this, dom.isCell)) {
+ indentOther(this, r.sc, r.ec);
+ }
+ }
+ });
+
+ if ($dom.length) {
+ var $parent = $dom.parent();
+
+ // remove text nodes between lists
+ var $ul = $parent.find('ul, ol');
+ if (!$ul.length) {
+ $ul = $(dom.ancestor(r.sc, dom.isList));
+ }
+ $ul.each(function () {
+ if (this.previousSibling &&
+ this.previousSibling !== dom.previousElementSibling(this) &&
+ !this.previousSibling.textContent.match(/\S/)) {
+ this.parentNode.removeChild(this.previousSibling);
+ }
+ if (this.nextSibling &&
+ this.nextSibling !== dom.nextElementSibling(this) &&
+ !this.nextSibling.textContent.match(/\S/)) {
+ this.parentNode.removeChild(this.nextSibling);
+ }
+ });
+
+ // merge same ul or ol
+ r = dom.merge($parent[0], r.sc, r.so, r.ec, r.eo, function (prev, cur) {
+ if (prev && dom.isList(prev) && dom.isEqual(prev, cur)) {
+ return true;
+ }
+ }, true);
+ range.create(r.sc, r.so, r.ec, r.eo).select();
+ }
+ event.preventDefault();
+ return false;
+};
+$.summernote.pluginEvents.outdent = function (event, editor, layoutInfo) {
+ return $.summernote.pluginEvents.indent(event, editor, layoutInfo, true);
+};
+
+$.summernote.pluginEvents.formatBlock = function (event, editor, layoutInfo, sTagName) {
+ $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, null, null, "Default");
+ var $editable = layoutInfo.editable();
+ $editable.focus();
+ $editable.data('NoteHistory').recordUndo($editable);
+ event.preventDefault();
+
+ var r = range.create();
+ if (!r) {
+ return;
+ }
+ // select content since container (that firefox selects) may be removed
+ if (r.so === 0) {
+ r.sc = dom.firstChild(r.sc);
+ }
+ if (dom.nodeLength(r.ec) >= r.eo) {
+ r.ec = dom.lastChild(r.ec);
+ r.eo = dom.nodeLength(r.ec);
+ }
+ r = range.create(r.sc, r.so, r.ec, r.eo);
+ r.reRange().select();
+
+ if (sTagName === "blockquote" || sTagName === "pre") {
+ sTagName = $.summernote.core.agent.isMSIE ? '<' + sTagName + '>' : sTagName;
+ document.execCommand('FormatBlock', false, sTagName);
+ return;
+ }
+
+ // fix by odoo because if you select a style in a li with no p tag all the ul is wrapped by the style tag
+ var nodes = dom.listBetween(r.sc, r.ec, r.so, r.eo);
+ for (var i=0; i<nodes.length; i++) {
+ if (dom.isBR(nodes[i]) || (dom.isText(nodes[i]) && dom.isVisibleText(nodes[i])) || dom.isB(nodes[i]) || dom.isU(nodes[i]) || dom.isS(nodes[i]) || dom.isI(nodes[i]) || dom.isFont(nodes[i])) {
+ var ancestor = dom.ancestor(nodes[i], isFormatNode);
+ if ($(ancestor).parent().is('blockquote')) {
+ // firefox may wrap formatting block in blockquote
+ $(ancestor).unwrap();
+ }
+ if (!ancestor) {
+ dom.wrap(nodes[i], sTagName);
+ } else if (ancestor.tagName.toLowerCase() !== sTagName) {
+ var tag = document.createElement(sTagName);
+ ancestor.parentNode.insertBefore(tag, ancestor);
+ dom.moveContent(ancestor, tag);
+ if (ancestor.className) {
+ tag.className = ancestor.className;
+ }
+ ancestor.parentNode.removeChild(ancestor);
+ }
+ }
+ }
+ r.select();
+};
+$.summernote.pluginEvents.removeFormat = function (event, editor, layoutInfo, value) {
+ var $editable = layoutInfo.editable();
+ $editable.data('NoteHistory').recordUndo($editable);
+ var r = range.create();
+ if (!r) return;
+ var node = range.create().sc.parentNode;
+ document.execCommand('removeFormat');
+ document.execCommand('removeFormat');
+ r = range.create();
+ if (!r) return;
+ r = dom.merge(node, r.sc, r.so, r.ec, r.eo, null, true);
+ range.create(r.sc, r.so, r.ec, r.eo).select();
+ event.preventDefault();
+ return false;
+};
+
+eventHandler.modules.editor.undo = function ($popover) {
+ if (!$popover.attr('disabled')) $popover.data('NoteHistory').undo();
+};
+eventHandler.modules.editor.redo = function ($popover) {
+ if (!$popover.attr('disabled')) $popover.data('NoteHistory').redo();
+};
+
+// Get color and background color of node to update recent color button
+var fn_from_node = eventHandler.modules.editor.style.fromNode;
+eventHandler.modules.editor.style.fromNode = function ($node) {
+ var styleInfo = fn_from_node.apply(this, arguments);
+ styleInfo['color'] = $node.css('color');
+ styleInfo['background-color'] = $node.css('background-color');
+ return styleInfo;
+};
+
+// use image toolbar if current range is on image
+var fn_editor_currentstyle = eventHandler.modules.editor.currentStyle;
+eventHandler.modules.editor.currentStyle = function (target) {
+ var styleInfo = fn_editor_currentstyle.apply(this, arguments);
+ // with our changes for inline editor, the targeted element could be a button of the editor
+ if (!styleInfo.image || !dom.isEditable(styleInfo.image)) {
+ styleInfo.image = undefined;
+ var r = range.create();
+ if (r && r.isOnEditable()) {
+ styleInfo.image = r.isOnImg();
+ }
+ }
+ // Fix when the target is a link: the text-align buttons state should
+ // indicate the alignment of the link in the parent, not the text inside
+ // the link (which is not possible to customize with summernote). Summernote fixed
+ // this in their newest version... by just not showing the active button
+ // for alignments.
+ if (styleInfo.anchor) {
+ styleInfo['text-align'] = $(styleInfo.anchor).parent().css('text-align');
+ }
+ return styleInfo;
+};
+
+options.fontSizes = weDefaultOptions.fontSizes;
+$.summernote.pluginEvents.applyFont = function (event, editor, layoutInfo, color, bgcolor, size) {
+ var r = range.create();
+ if (!r) return;
+ var startPoint = r.getStartPoint();
+ var endPoint = r.getEndPoint();
+
+ if (r.isCollapsed() && !dom.isFont(r.sc)) {
+ return {
+ sc: startPoint.node,
+ so: startPoint.offset,
+ ec: endPoint.node,
+ offset: endPoint.offset
+ };
+ }
+
+ if (startPoint.node.tagName && startPoint.node.childNodes[startPoint.offset]) {
+ startPoint.node = startPoint.node.childNodes[startPoint.offset];
+ startPoint.offset = 0;
+ }
+ if (endPoint.node.tagName && endPoint.node.childNodes[endPoint.offset]) {
+ endPoint.node = endPoint.node.childNodes[endPoint.offset];
+ endPoint.offset = 0;
+ }
+
+ // get first and last point
+ var ancestor;
+ var node;
+ if (endPoint.offset && endPoint.offset !== dom.nodeLength(endPoint.node)) {
+ ancestor = dom.ancestor(endPoint.node, dom.isFont) || endPoint.node;
+ dom.splitTree(ancestor, endPoint);
+ }
+ if (startPoint.offset && startPoint.offset !== dom.nodeLength(startPoint.node)) {
+ ancestor = dom.ancestor(startPoint.node, dom.isFont) || startPoint.node;
+ node = dom.splitTree(ancestor, startPoint);
+ if (endPoint.node === startPoint.node) {
+ endPoint.node = node;
+ endPoint.offset = dom.nodeLength(node);
+ }
+ startPoint.node = node;
+ startPoint.offset = 0;
+ }
+
+ // get list of nodes to change
+ var nodes = [];
+ dom.walkPoint(startPoint, endPoint, function (point) {
+ var node = point.node;
+ if (((dom.isText(node) && dom.isVisibleText(node)) ||
+ (dom.isFont(node) && !dom.isVisibleText(node))) &&
+ (node !== endPoint.node || endPoint.offset)) {
+
+ nodes.push(point.node);
+
+ }
+ });
+ nodes = list.unique(nodes);
+
+ // If ico fa
+ if (r.isCollapsed()) {
+ nodes.push(startPoint.node);
+ }
+
+ // apply font: foreColor, backColor, size (the color can be use a class text-... or bg-...)
+ var font, $font, fonts = [], className;
+ var i;
+ if (color || bgcolor || size) {
+ for (i=0; i<nodes.length; i++) {
+ node = nodes[i];
+
+ font = dom.ancestor(node, dom.isFont);
+ if (!font) {
+ if (node.textContent.match(/^[ ]|[ ]$/)) {
+ node.textContent = node.textContent.replace(/^[ ]|[ ]$/g, '\u00A0');
+ }
+
+ font = dom.create("font");
+ node.parentNode.insertBefore(font, node);
+ font.appendChild(node);
+ }
+
+ fonts.push(font);
+
+ className = font.className.split(/\s+/);
+
+ var k;
+ if (color) {
+ for (k=0; k<className.length; k++) {
+ if (className[k].length && className[k].slice(0,5) === "text-") {
+ className.splice(k,1);
+ k--;
+ }
+ }
+
+ if (color.indexOf('text-') !== -1) {
+ font.className = className.join(" ") + " " + color;
+ font.style.color = "inherit";
+ } else {
+ font.className = className.join(" ");
+ font.style.color = color;
+ }
+ }
+ if (bgcolor) {
+ for (k=0; k<className.length; k++) {
+ if (className[k].length && className[k].slice(0,3) === "bg-") {
+ className.splice(k,1);
+ k--;
+ }
+ }
+
+ if (bgcolor.indexOf('bg-') !== -1) {
+ font.className = className.join(" ") + " " + bgcolor;
+ font.style.backgroundColor = "inherit";
+ } else {
+ font.className = className.join(" ");
+ font.style.backgroundColor = bgcolor;
+ }
+ }
+ if (size) {
+ font.style.fontSize = "inherit";
+ if (!isNaN(size) && Math.abs(parseInt(dom.getComputedStyle(font).fontSize, 10)-size)/size > 0.05) {
+ font.style.fontSize = size + "px";
+ }
+ }
+ }
+ }
+
+ // remove empty values
+ // we must remove the value in 2 steps (applay inherit then remove) because some
+ // browser like chrome have some time an error for the rendering and/or keep inherit
+ for (i=0; i<fonts.length; i++) {
+ font = fonts[i];
+ if (font.style.backgroundColor === "inherit") {
+ font.style.backgroundColor = "";
+ }
+ if (font.style.color === "inherit") {
+ font.style.color = "";
+ }
+ if (font.style.fontSize === "inherit") {
+ font.style.fontSize = "";
+ }
+
+ $font = $(font);
+
+ if (!$font.css("color") && !$font.css("background-color") && !$font.css("font-size")) {
+ $font.removeAttr("style");
+ }
+ if (!font.className.length) {
+ $font.removeAttr("class");
+ }
+ }
+
+ // select nodes to clean (to remove empty font and merge same nodes)
+ nodes = [];
+ dom.walkPoint(startPoint, endPoint, function (point) {
+ nodes.push(point.node.childNodes[point.offset] || point.node);
+ });
+ nodes = list.unique(nodes);
+
+ function remove(node, to) {
+ if (node === endPoint.node) {
+ endPoint = dom.prevPoint(endPoint);
+ }
+ if (to) {
+ dom.moveContent(node, to);
+ }
+ dom.remove(node);
+ }
+
+ // remove node without attributes (move content), and merge the same nodes
+ var className2, style, style2, hasBefore, hasAfter;
+ var noContent = ['none', null, undefined];
+ for (i=0; i<nodes.length; i++) {
+ node = nodes[i];
+
+ if (dom.isText(node) && !node.nodeValue) {
+ remove(node);
+ continue;
+ }
+
+ font = dom.ancestor(node, dom.isFont);
+ node = font || dom.ancestor(node, dom.isSpan);
+
+ if (!node) {
+ continue;
+ }
+
+ $font = $(node);
+ className = dom.orderClass(node);
+ style = dom.orderStyle(node);
+ hasBefore = noContent.indexOf(window.getComputedStyle(node, '::before').content) === -1;
+ hasAfter = noContent.indexOf(window.getComputedStyle(node, '::after').content) === -1;
+
+ if (!className && !style && !hasBefore && !hasAfter) {
+ remove(node, node.parentNode);
+ continue;
+ }
+
+ if (font = dom.ancestor(node.previousSibling, dom.isFont)) {
+ className2 = font.getAttribute('class');
+ style2 = font.getAttribute('style');
+ if (node !== font && className === className2 && style === style2) {
+ remove(node, font);
+ continue;
+ }
+ }
+ }
+
+ range.create(startPoint.node, startPoint.offset, endPoint.node, endPoint.offset).select();
+};
+$.summernote.pluginEvents.fontSize = function (event, editor, layoutInfo, value) {
+ var $editable = layoutInfo.editable();
+ event.preventDefault();
+ $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, null, null, value);
+ editor.afterCommand($editable);
+};
+$.summernote.pluginEvents.color = function (event, editor, layoutInfo, sObjColor) {
+ var oColor = JSON.parse(sObjColor);
+ var foreColor = oColor.foreColor, backColor = oColor.backColor;
+
+ if (foreColor) { $.summernote.pluginEvents.foreColor(event, editor, layoutInfo, foreColor); }
+ if (backColor) { $.summernote.pluginEvents.backColor(event, editor, layoutInfo, backColor); }
+};
+$.summernote.pluginEvents.foreColor = function (event, editor, layoutInfo, foreColor, preview) {
+ var $editable = layoutInfo.editable();
+ $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, foreColor, null, null);
+ if (!preview) {
+ editor.afterCommand($editable);
+ }
+};
+$.summernote.pluginEvents.backColor = function (event, editor, layoutInfo, backColor, preview) {
+ var $editable = layoutInfo.editable();
+ var r = range.create();
+ if (!r) return;
+ if (r.isCollapsed() && r.isOnCell()) {
+ var cell = dom.ancestor(r.sc, dom.isCell);
+ cell.className = cell.className.replace(new RegExp('(^|\\s+)bg-[^\\s]+(\\s+|$)', 'gi'), '');
+ cell.style.backgroundColor = "";
+ if (backColor.indexOf('bg-') !== -1) {
+ cell.className += ' ' + backColor;
+ } else if (backColor !== 'inherit') {
+ cell.style.backgroundColor = backColor;
+ }
+ return;
+ }
+ $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, null, backColor, null);
+ if (!preview) {
+ editor.afterCommand($editable);
+ }
+};
+
+options.onCreateLink = function (sLinkUrl) {
+ if (sLinkUrl.indexOf('mailto:') === 0 || sLinkUrl.indexOf('tel:') === 0) {
+ sLinkUrl = sLinkUrl.replace(/^tel:([0-9]+)$/, 'tel://$1');
+ } else if (sLinkUrl.indexOf('@') !== -1 && sLinkUrl.indexOf(':') === -1) {
+ sLinkUrl = 'mailto:' + sLinkUrl;
+ } else if (sLinkUrl.indexOf('://') === -1 && sLinkUrl[0] !== '/'
+ && sLinkUrl[0] !== '#' && sLinkUrl.slice(0, 2) !== '${') {
+ sLinkUrl = 'http://' + sLinkUrl;
+ }
+ return sLinkUrl;
+};
+
+function summernote_table_scroll(event) {
+ var r = range.create();
+ if (r && r.isOnCell()) {
+ $('.o_table_handler').remove();
+ }
+}
+function summernote_table_update(oStyle) {
+ var r = range.create();
+ if (!oStyle.range || !r || !r.isOnCell() || !r.isOnCellFirst()) {
+ $('.o_table_handler').remove();
+ return;
+ }
+ var table = dom.ancestor(oStyle.range.sc, dom.isTable);
+ if (!table) { // if the editable tag is inside the table
+ return;
+ }
+ var $editable = $(table).closest('.o_editable');
+
+ $('.o_table_handler').remove();
+
+ var $dels = $();
+ var $adds = $();
+ var $tds = $('tr:first', table).children();
+ $tds.each(function () {
+ var $td = $(this);
+ var pos = $td.offset();
+
+ var $del = $('<span class="o_table_handler fa fa-minus-square"/>').appendTo('body');
+ $del.data('td', this);
+ $dels = $dels.add($del);
+ $del.css({
+ left: ((pos.left + $td.outerWidth()/2)-6) + "px",
+ top: (pos.top-6) + "px"
+ });
+
+ var $add = $('<span class="o_table_handler fa fa-plus-square"/>').appendTo('body');
+ $add.data('td', this);
+ $adds = $adds.add($add);
+ $add.css({
+ left: (pos.left-6) + "px",
+ top: (pos.top-6) + "px"
+ });
+ });
+
+ var $last = $tds.last();
+ var pos = $last.offset();
+ var $add = $('<span class="o_table_handler fa fa-plus-square"/>').appendTo('body');
+ $adds = $adds.add($add);
+ $add.css({
+ left: (pos.left+$last.outerWidth()-6) + "px",
+ top: (pos.top-6) + "px"
+ });
+
+ var $table = $(table);
+ $dels.data('table', table).on('mousedown', function (event) {
+ var td = $(this).data('td');
+ $editable.data('NoteHistory').recordUndo($editable);
+
+ var newTd;
+ if ($(td).siblings().length) {
+ var eq = $(td).index();
+ $table.find('tr').each(function () {
+ $('> td:eq('+eq+')', this).remove();
+ });
+ newTd = $table.find('tr:first > td:eq('+eq+'), tr:first > td:last').first();
+ } else {
+ var prev = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling($table[0])));
+ $table.remove();
+ $('.o_table_handler').remove();
+ r = range.create(prev, prev.textContent.length);
+ r.select();
+ $(r.sc).trigger('mouseup');
+ return;
+ }
+
+ $('.o_table_handler').remove();
+ range.create(newTd[0], 0, newTd[0], 0).select();
+ newTd.trigger('mouseup');
+ });
+ $adds.data('table', table).on('mousedown', function (event) {
+ var td = $(this).data('td');
+ $editable.data('NoteHistory').recordUndo($editable);
+
+ var newTd;
+ if (td) {
+ var eq = $(td).index();
+ $table.find('tr').each(function () {
+ $('td:eq('+eq+')', this).before('<td>'+dom.blank+'</td>');
+ });
+ newTd = $table.find('tr:first td:eq('+eq+')');
+ } else {
+ $table.find('tr').each(function () {
+ $(this).append('<td>'+dom.blank+'</td>');
+ });
+ newTd = $table.find('tr:first td:last');
+ }
+
+ $('.o_table_handler').remove();
+ range.create(newTd[0], 0, newTd[0], 0).select();
+ newTd.trigger('mouseup');
+ });
+
+ $dels.css({
+ 'position': 'absolute',
+ 'cursor': 'pointer',
+ 'background-color': '#fff',
+ 'color': '#ff0000'
+ });
+ $adds.css({
+ 'position': 'absolute',
+ 'cursor': 'pointer',
+ 'background-color': '#fff',
+ 'color': '#00ff00'
+ });
+}
+var fn_popover_update = eventHandler.modules.popover.update;
+eventHandler.modules.popover.update = function ($popover, oStyle, isAirMode) {
+ fn_popover_update.call(this, $popover, oStyle, isAirMode);
+ if ((isAirMode ? $popover : $popover.parent()).find('.note-table').length) {
+ summernote_table_update(oStyle);
+ }
+};
+
+function mouseDownChecklist (e) {
+ if (!dom.isLi(e.target) || !$(e.target).parent('ul.o_checklist').length || e.offsetX > 0) {
+ return;
+ }
+ e.stopPropagation();
+ e.preventDefault();
+ var checked = $(e.target).hasClass('o_checked');
+ $(e.target).toggleClass('o_checked', !checked);
+ var $sublevel = $(e.target).next('ul.o_checklist, li:has(> ul.o_checklist)').find('> li, ul.o_checklist > li');
+ var $parents = $(e.target).parents('ul.o_checklist').map(function () {
+ return this.parentNode.tagName === 'LI' ? this.parentNode : this;
+ });
+ if (checked) {
+ $sublevel.removeClass('o_checked');
+ do {
+ $parents = $parents.prev('ul.o_checklist li').removeClass('o_checked');
+ } while ($parents.length);
+ } else {
+ $sublevel.addClass('o_checked');
+ var $lis;
+ do {
+ $lis = $parents.not(':has(li[id^="checklist-id"]:not(.o_checked))').prev('ul.o_checklist li:not(.o_checked)');
+ $lis.addClass('o_checked');
+ } while ($lis.length);
+ }
+}
+
+var fn_attach = eventHandler.attach;
+eventHandler.attach = function (oLayoutInfo, options) {
+ var $editable = oLayoutInfo.editor().hasClass('note-editable') ? oLayoutInfo.editor() : oLayoutInfo.editor().find('.note-editable');
+ fn_attach.call(this, oLayoutInfo, options);
+ $editable.on("scroll", summernote_table_scroll);
+ $editable.on("mousedown", mouseDownChecklist);
+};
+var fn_detach = eventHandler.detach;
+eventHandler.detach = function (oLayoutInfo, options) {
+ var $editable = oLayoutInfo.editor().hasClass('note-editable') ? oLayoutInfo.editor() : oLayoutInfo.editor().find('.note-editable');
+ fn_detach.call(this, oLayoutInfo, options);
+ $editable.off("scroll", summernote_table_scroll);
+ $editable.off("mousedown", mouseDownChecklist);
+ $('.o_table_handler').remove();
+};
+
+options.icons.image.image = "file-image-o";
+$.summernote.lang['en-US'].image.image = "File / Image";
+
+return $.summernote;
+});
diff --git a/addons/web_editor/static/src/js/frontend/loader.js b/addons/web_editor/static/src/js/frontend/loader.js
new file mode 100644
index 00000000..9c6cf0ef
--- /dev/null
+++ b/addons/web_editor/static/src/js/frontend/loader.js
@@ -0,0 +1,28 @@
+odoo.define('web_editor.loader', function (require) {
+'use strict';
+
+var Wysiwyg = require('web_editor.wysiwyg.root');
+
+function load(parent, textarea, options) {
+ var loading = textarea.nextElementSibling;
+ if (loading && !loading.classList.contains('o_wysiwyg_loading')) {
+ loading = null;
+ }
+
+ if (!textarea.value.match(/\S/)) {
+ textarea.value = '<p><br/></p>';
+ }
+
+ var wysiwyg = new Wysiwyg(parent, options);
+ return wysiwyg.attachTo(textarea).then(() => {
+ if (loading) {
+ loading.parentNode.removeChild(loading);
+ }
+ return wysiwyg;
+ });
+}
+
+return {
+ load: load,
+};
+});
diff --git a/addons/web_editor/static/src/js/frontend/loader_loading.js b/addons/web_editor/static/src/js/frontend/loader_loading.js
new file mode 100644
index 00000000..f5e8eda7
--- /dev/null
+++ b/addons/web_editor/static/src/js/frontend/loader_loading.js
@@ -0,0 +1,33 @@
+(function () {
+'use strict';
+
+/**
+ * This file makes sure textarea elements with a specific editor class are
+ * tweaked as soon as the DOM is ready so that they appear to be loading.
+ *
+ * They must then be loaded using standard Odoo modules system. In particular,
+ * @see web_editor.loader
+ */
+
+document.addEventListener('DOMContentLoaded', () => {
+ // Standard loop for better browser support
+ var textareaEls = document.querySelectorAll('textarea.o_wysiwyg_loader');
+ for (var i = 0; i < textareaEls.length; i++) {
+ var textarea = textareaEls[i];
+ var wrapper = document.createElement('div');
+ wrapper.classList.add('position-relative', 'o_wysiwyg_wrapper');
+
+ var loadingElement = document.createElement('div');
+ loadingElement.classList.add('o_wysiwyg_loading');
+ var loadingIcon = document.createElement('i');
+ loadingIcon.classList.add('text-600', 'text-center',
+ 'fa', 'fa-circle-o-notch', 'fa-spin', 'fa-2x');
+ loadingElement.appendChild(loadingIcon);
+ wrapper.appendChild(loadingElement);
+
+ textarea.parentNode.insertBefore(wrapper, textarea);
+ wrapper.insertBefore(textarea, loadingElement);
+ }
+});
+
+})();
diff --git a/addons/web_editor/static/src/js/wysiwyg/fonts.js b/addons/web_editor/static/src/js/wysiwyg/fonts.js
new file mode 100644
index 00000000..257ccaf2
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/fonts.js
@@ -0,0 +1,99 @@
+odoo.define('wysiwyg.fonts', function (require) {
+'use strict';
+
+return {
+ /**
+ * Retrieves all the CSS rules which match the given parser (Regex).
+ *
+ * @param {Regex} filter
+ * @returns {Object[]} Array of CSS rules descriptions (objects). A rule is
+ * defined by 3 values: 'selector', 'css' and 'names'. 'selector'
+ * is a string which contains the whole selector, 'css' is a string
+ * which contains the css properties and 'names' is an array of the
+ * first captured groups for each selector part. E.g.: if the
+ * filter is set to match .fa-* rules and capture the icon names,
+ * the rule:
+ * '.fa-alias1::before, .fa-alias2::before { hello: world; }'
+ * will be retrieved as
+ * {
+ * selector: '.fa-alias1::before, .fa-alias2::before',
+ * css: 'hello: world;',
+ * names: ['.fa-alias1', '.fa-alias2'],
+ * }
+ */
+ cacheCssSelectors: {},
+ getCssSelectors: function (filter) {
+ if (this.cacheCssSelectors[filter]) {
+ return this.cacheCssSelectors[filter];
+ }
+ this.cacheCssSelectors[filter] = [];
+ var sheets = document.styleSheets;
+ for (var i = 0; i < sheets.length; i++) {
+ var rules;
+ try {
+ // try...catch because Firefox not able to enumerate
+ // document.styleSheets[].cssRules[] for cross-domain
+ // stylesheets.
+ rules = sheets[i].rules || sheets[i].cssRules;
+ } catch (e) {
+ console.warn("Can't read the css rules of: " + sheets[i].href, e);
+ continue;
+ }
+ if (!rules) {
+ continue;
+ }
+
+ for (var r = 0 ; r < rules.length ; r++) {
+ var selectorText = rules[r].selectorText;
+ if (!selectorText) {
+ continue;
+ }
+ var selectors = selectorText.split(/\s*,\s*/);
+ var data = null;
+ for (var s = 0; s < selectors.length; s++) {
+ var match = selectors[s].trim().match(filter);
+ if (!match) {
+ continue;
+ }
+ if (!data) {
+ data = {
+ selector: match[0],
+ css: rules[r].cssText.replace(/(^.*\{\s*)|(\s*\}\s*$)/g, ''),
+ names: [match[1]]
+ };
+ } else {
+ data.selector += (', ' + match[0]);
+ data.names.push(match[1]);
+ }
+ }
+ if (data) {
+ this.cacheCssSelectors[filter].push(data);
+ }
+ }
+ }
+ return this.cacheCssSelectors[filter];
+ },
+ /**
+ * List of font icons to load by editor. The icons are displayed in the media
+ * editor and identified like font and image (can be colored, spinned, resized
+ * with fa classes).
+ * To add font, push a new object {base, parser}
+ *
+ * - base: class who appear on all fonts
+ * - parser: regular expression used to select all font in css stylesheets
+ *
+ * @type Array
+ */
+ fontIcons: [{base: 'fa', parser: /\.(fa-(?:\w|-)+)::?before/i}],
+ /**
+ * Searches the fonts described by the @see fontIcons variable.
+ */
+ computeFonts: _.once(function () {
+ var self = this;
+ _.each(this.fontIcons, function (data) {
+ data.cssData = self.getCssSelectors(data.parser);
+ data.alias = _.flatten(_.map(data.cssData, _.property('names')));
+ });
+ }),
+};
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/root.js b/addons/web_editor/static/src/js/wysiwyg/root.js
new file mode 100644
index 00000000..57e9f65e
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/root.js
@@ -0,0 +1,91 @@
+odoo.define('web_editor.wysiwyg.root', function (require) {
+'use strict';
+
+var Widget = require('web.Widget');
+
+var assetsLoaded = false;
+
+var WysiwygRoot = Widget.extend({
+ assetLibs: ['web_editor.compiled_assets_wysiwyg'],
+ _loadLibsTplRoute: '/web_editor/public_render_template',
+
+ publicMethods: ['isDirty', 'save', 'getValue', 'setValue', 'getEditable', 'on', 'trigger', 'focus', 'saveModifiedImages'],
+
+ /**
+ * @see 'web_editor.wysiwyg' module
+ **/
+ init: function (parent, params) {
+ this._super.apply(this, arguments);
+ this._params = params;
+ this.$editor = null;
+ },
+ /**
+ * Load assets
+ *
+ * @override
+ **/
+ willStart: function () {
+ var self = this;
+
+ var $target = this.$el;
+ this.$el = null;
+
+ return this._super().then(function () {
+ // FIXME: this code works by pure luck. If the web_editor.wysiwyg
+ // JS module was requiring a delayed module, using it here right
+ // away would lead to a crash.
+ if (!assetsLoaded) {
+ var Wysiwyg = odoo.__DEBUG__.services['web_editor.wysiwyg'];
+ _.each(['getRange', 'setRange', 'setRangeFromNode'], function (methodName) {
+ WysiwygRoot[methodName] = Wysiwyg[methodName].bind(Wysiwyg);
+ });
+ assetsLoaded = true;
+ }
+
+ var Wysiwyg = self._getWysiwygContructor();
+ var instance = new Wysiwyg(self, self._params);
+ if (self.__extraAssetsForIframe) {
+ instance.__extraAssetsForIframe = self.__extraAssetsForIframe;
+ }
+ self._params = null;
+
+ _.each(self.publicMethods, function (methodName) {
+ self[methodName] = instance[methodName].bind(instance);
+ });
+
+ return instance.attachTo($target).then(function () {
+ self.$editor = instance.$editor || instance.$el;
+ });
+ });
+ },
+
+ _getWysiwygContructor: function () {
+ return odoo.__DEBUG__.services['web_editor.wysiwyg'];
+ }
+});
+
+return WysiwygRoot;
+
+});
+
+odoo.define('web_editor.wysiwyg.default_options', function (require) {
+'use strict';
+
+/**
+ * TODO this should be refactored to be done another way, same as the 'root'
+ * module that should be done another way.
+ *
+ * This allows to have access to default options that are used in the summernote
+ * editor so that they can be tweaked (instead of entirely replaced) when using
+ * the editor on an editable content.
+ */
+
+var core = require('web.core');
+
+var _lt = core._lt;
+
+return {
+ styleTags: ['p', 'pre', 'small', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'],
+ fontSizes: [_lt('Default'), 8, 9, 10, 11, 12, 14, 18, 24, 36, 48, 62],
+};
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js
new file mode 100644
index 00000000..80f143b6
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js
@@ -0,0 +1,62 @@
+odoo.define('wysiwyg.widgets.AltDialog', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('wysiwyg.widgets.Dialog');
+
+var _t = core._t;
+
+/**
+ * Let users change the alt & title of a media.
+ */
+var AltDialog = Dialog.extend({
+ template: 'wysiwyg.widgets.alt',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/web_editor/static/src/xml/wysiwyg.xml']
+ ),
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options, media) {
+ options = options || {};
+ this._super(parent, _.extend({}, {
+ title: _t("Change media description and tooltip")
+ }, options));
+
+ this.trigger_up('getRecordInfo', {
+ recordInfo: options,
+ callback: function (recordInfo) {
+ _.defaults(options, recordInfo);
+ },
+ });
+
+ this.media = media;
+ var allEscQuots = /&quot;/g;
+ this.alt = ($(this.media).attr('alt') || "").replace(allEscQuots, '"');
+ var title = $(this.media).attr('title') || $(this.media).data('original-title') || "";
+ this.tag_title = (title).replace(allEscQuots, '"');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function () {
+ var alt = this.$('#alt').val();
+ var title = this.$('#title').val();
+ var allNonEscQuots = /"/g;
+ $(this.media).attr('alt', alt ? alt.replace(allNonEscQuots, "&quot;") : null)
+ .attr('title', title ? title.replace(allNonEscQuots, "&quot;") : null);
+ $(this.media).trigger('content_changed');
+ this.final_data = this.media;
+ return this._super.apply(this, arguments);
+ },
+});
+
+
+return AltDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js b/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js
new file mode 100644
index 00000000..d00abd1a
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js
@@ -0,0 +1,410 @@
+odoo.define('web_editor.ColorPalette', function (require) {
+'use strict';
+
+const ajax = require('web.ajax');
+const core = require('web.core');
+const session = require('web.session');
+const {ColorpickerWidget} = require('web.Colorpicker');
+const Widget = require('web.Widget');
+const summernoteCustomColors = require('web_editor.rte.summernote_custom_colors');
+const weUtils = require('web_editor.utils');
+
+const qweb = core.qweb;
+
+const ColorPaletteWidget = Widget.extend({
+ // ! for xmlDependencies, see loadDependencies function
+ template: 'web_editor.snippet.option.colorpicker',
+ events: {
+ 'click .o_we_color_btn': '_onColorButtonClick',
+ 'mouseenter .o_we_color_btn': '_onColorButtonEnter',
+ 'mouseleave .o_we_color_btn': '_onColorButtonLeave',
+ 'click .o_we_colorpicker_switch_pane_btn': '_onSwitchPaneButtonClick',
+ },
+ custom_events: {
+ 'colorpicker_select': '_onColorPickerSelect',
+ 'colorpicker_preview': '_onColorPickerPreview',
+ },
+ /**
+ * @override
+ *
+ * @param {Object} [options]
+ * @param {string} [options.selectedColor] The class or css attribute color selected by default.
+ * @param {boolean} [options.resetButton=true] Whether to display or not the reset button.
+ * @param {string[]} [options.excluded=[]] Sections not to display.
+ * @param {string[]} [options.excludeSectionOf] Extra section to exclude: the one containing the named color.
+ * @param {JQuery} [options.$editable=$()] Editable content from which the custom colors are retrieved.
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.summernoteCustomColorsArray = [].concat(...summernoteCustomColors);
+ this.style = window.getComputedStyle(document.documentElement);
+ this.options = _.extend({
+ selectedColor: false,
+ resetButton: true,
+ excluded: [],
+ excludeSectionOf: null,
+ $editable: $(),
+ withCombinations: false,
+ }, options || {});
+
+ this.selectedColor = '';
+ this.resetButton = this.options.resetButton;
+ this.withCombinations = this.options.withCombinations;
+
+ this.trigger_up('request_editable', {callback: val => this.options.$editable = val});
+ },
+ /**
+ * @override
+ */
+ willStart: async function () {
+ await this._super(...arguments);
+ await ColorPaletteWidget.loadDependencies(this);
+ },
+ /**
+ * @override
+ */
+ start: async function () {
+ const res = this._super.apply(this, arguments);
+
+ const $colorSection = this.$('.o_colorpicker_sections[data-color-tab="theme-colors"]');
+ const $clpicker = qweb.has_template('web_editor.colorpicker')
+ ? $(qweb.render('web_editor.colorpicker'))
+ : $(`<colorpicker><div class="o_colorpicker_section" data-name="common"></div></colorpicker>`);
+ $clpicker.find('button').addClass('o_we_color_btn');
+ $clpicker.appendTo($colorSection);
+
+ // Remove excluded palettes (note: only hide them to still be able
+ // to remove their related colors on the DOM target)
+ _.each(this.options.excluded, exc => {
+ this.$('[data-name="' + exc + '"]').addClass('d-none');
+ });
+ if (this.options.excludeSectionOf) {
+ this.$('[data-name]:has([data-color="' + this.options.excludeSectionOf + '"])').addClass('d-none');
+ }
+
+ this.el.querySelectorAll('.o_colorpicker_section').forEach(elem => {
+ $(elem).prepend('<div>' + (elem.dataset.display || '') + '</div>');
+ });
+
+ // Render common colors
+ if (!this.options.excluded.includes('common')) {
+ const $commonColorSection = this.$('[data-name="common"]');
+ summernoteCustomColors.forEach((colorRow, i) => {
+ if (i === 0) {
+ return; // Ignore the summernote gray palette and use ours
+ }
+ const $div = $('<div/>', {class: 'clearfix'}).appendTo($commonColorSection);
+ colorRow.forEach(color => {
+ $div.append(this._createColorButton(color, ['o_common_color']));
+ });
+ });
+ }
+
+ // Compute class colors
+ const compatibilityColorNames = ['primary', 'secondary', 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'success', 'info', 'warning', 'danger'];
+ this.colorNames = [...compatibilityColorNames];
+ this.colorToColorNames = {};
+ this.el.querySelectorAll('button[data-color]').forEach(elem => {
+ const colorName = elem.dataset.color;
+ const $color = $(elem);
+ const isCCName = weUtils.isColorCombinationName(colorName);
+ if (isCCName) {
+ $color.find('.o_we_cc_preview_wrapper').addClass(`o_cc o_cc${colorName}`);
+ } else {
+ $color.addClass(`bg-${colorName}`);
+ }
+ this.colorNames.push(colorName);
+ if (!isCCName && !elem.classList.contains('d-none')) {
+ const color = weUtils.getCSSVariableValue(colorName, this.style);
+ this.colorToColorNames[color] = colorName;
+ }
+ });
+
+ // Select selected Color and build customColors.
+ // If no color is selected selectedColor is an empty string (transparent is interpreted as no color)
+ if (this.options.selectedColor) {
+ let selectedColor = this.options.selectedColor;
+ if (compatibilityColorNames.includes(selectedColor)) {
+ selectedColor = weUtils.getCSSVariableValue(selectedColor, this.style) || selectedColor;
+ }
+ selectedColor = ColorpickerWidget.normalizeCSSColor(selectedColor);
+ if (selectedColor !== 'rgba(0, 0, 0, 0)') {
+ this.selectedColor = this.colorToColorNames[selectedColor] || selectedColor;
+ }
+ }
+ this._buildCustomColors();
+ this._markSelectedColor();
+
+ // Colorpicker
+ let defaultColor = this.selectedColor;
+ if (defaultColor && !ColorpickerWidget.isCSSColor(defaultColor)) {
+ defaultColor = weUtils.getCSSVariableValue(defaultColor, this.style);
+ }
+ this.colorPicker = new ColorpickerWidget(this, {
+ defaultColor: defaultColor,
+ });
+ await this.colorPicker.prependTo($colorSection);
+
+ // TODO Added as a fix. In master, the widget should probably not be
+ // instantiated at all.
+ if (this.options.excluded.includes('custom')) {
+ this.colorPicker.$el.addClass('d-none');
+ }
+
+ return res;
+ },
+ /**
+ * Return a list of the color names used in the color palette
+ */
+ getColorNames: function () {
+ return this.colorNames;
+ },
+ /**
+ * Sets the currently selected color
+ *
+ * @param {string} color rgb[a]
+ */
+ setSelectedColor: function (color) {
+ this._selectColor({color: color});
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _buildCustomColors: function () {
+ if (this.options.excluded.includes('custom')) {
+ return;
+ }
+ this.el.querySelectorAll('.o_custom_color').forEach(el => el.remove());
+ const existingColors = new Set(this.summernoteCustomColorsArray.concat(
+ Object.keys(this.colorToColorNames)
+ ));
+ this.trigger_up('get_custom_colors', {
+ onSuccess: (colors) => {
+ colors.forEach(color => {
+ this._addCustomColor(existingColors, color);
+ });
+ },
+ });
+ weUtils.getCSSVariableValue('custom-colors', this.style).split(' ').forEach(v => {
+ const color = weUtils.getCSSVariableValue(v.substring(1, v.length - 1), this.style);
+ if (ColorpickerWidget.isCSSColor(color)) {
+ this._addCustomColor(existingColors, color);
+ }
+ });
+ _.each(this.options.$editable.find('[style*="color"]'), el => {
+ for (const colorProp of ['color', 'backgroundColor']) {
+ this._addCustomColor(existingColors, el.style[colorProp]);
+ }
+ });
+ if (this.selectedColor) {
+ this._addCustomColor(existingColors, this.selectedColor);
+ }
+ },
+ /**
+ * Add the color to the custom color section if it is not in the existingColors.
+ *
+ * @param {string[]} existingColors Colors currently in the colorpicker
+ * @param {string} color Color to add to the cuustom colors
+ */
+ _addCustomColor: function (existingColors, color) {
+ if (!color) {
+ return;
+ }
+ if (!ColorpickerWidget.isCSSColor(color)) {
+ color = weUtils.getCSSVariableValue(color, this.style);
+ }
+ const normColor = ColorpickerWidget.normalizeCSSColor(color);
+ if (!existingColors.has(normColor)) {
+ this._addCustomColorButton(normColor);
+ existingColors.add(normColor);
+ }
+ },
+ /**
+ * Add a custom button in the coresponding section.
+ *
+ * @private
+ * @param {string} color
+ * @param {string[]} classes - classes added to the button
+ * @returns {jQuery}
+ */
+ _addCustomColorButton: function (color, classes = []) {
+ classes.push('o_custom_color');
+ const $themeSection = this.$('.o_colorpicker_section[data-name="theme"]');
+ const $button = this._createColorButton(color, classes);
+ return $button.appendTo($themeSection);
+ },
+ /**
+ * Return a color button.
+ *
+ * @param {string} color
+ * @param {string[]} classes - classes added to the button
+ * @returns {jQuery}
+ */
+ _createColorButton: function (color, classes) {
+ return $('<button/>', {
+ class: 'o_we_color_btn ' + classes.join(' '),
+ style: 'background-color:' + color + ';',
+ });
+ },
+ /**
+ * Gets normalized information about a color button.
+ *
+ * @private
+ * @param {HTMLElement} buttonEl
+ * @returns {Object}
+ */
+ _getButtonInfo: function (buttonEl) {
+ const bgColor = buttonEl.style.backgroundColor;
+ return {
+ color: bgColor ? ColorpickerWidget.normalizeCSSColor(bgColor) : buttonEl.dataset.color || '',
+ target: buttonEl,
+ };
+ },
+ /**
+ * Set the selectedColor and trigger an event
+ *
+ * @param {Object} color
+ * @param {string} [eventName]
+ */
+ _selectColor: function (colorInfo, eventName) {
+ this.selectedColor = colorInfo.color = this.colorToColorNames[colorInfo.color] || colorInfo.color;
+ if (eventName) {
+ this.trigger_up(eventName, colorInfo);
+ }
+ this._buildCustomColors();
+ this._markSelectedColor();
+ this.colorPicker.setSelectedColor(colorInfo.color);
+ },
+ /**
+ * Mark the selected color
+ *
+ * @private
+ */
+ _markSelectedColor: function () {
+ this.el.querySelectorAll('button.selected').forEach(el => el.classList.remove('selected'));
+ const selectedButton = this.el.querySelector(`button[data-color="${this.selectedColor}"], button[style*="background-color:${this.selectedColor};"]`);
+ if (selectedButton) {
+ selectedButton.classList.add('selected');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a color button is clicked.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorButtonClick: function (ev) {
+ const buttonEl = ev.currentTarget;
+ const colorInfo = this._getButtonInfo(buttonEl);
+ this._selectColor(colorInfo, 'color_picked');
+ },
+ /**
+ * Called when a color button is entered.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorButtonEnter: function (ev) {
+ ev.stopPropagation();
+ this.trigger_up('color_hover', this._getButtonInfo(ev.currentTarget));
+ },
+ /**
+ * Called when a color button is left the data color is the color currently selected.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorButtonLeave: function (ev) {
+ ev.stopPropagation();
+ this.trigger_up('color_leave', {
+ color: this.selectedColor,
+ target: ev.target,
+ });
+ },
+ /**
+ * Called when an update is made on the colorpicker.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorPickerPreview: function (ev) {
+ this.trigger_up('color_hover', {
+ color: ev.data.cssColor,
+ target: this.colorPicker.el,
+ });
+ },
+ /**
+ * Called when a color is selected on the colorpicker (mouseup).
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorPickerSelect: function (ev) {
+ this._selectColor({
+ color: ev.data.cssColor,
+ target: this.colorPicker.el,
+ }, 'custom_color_picked');
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onSwitchPaneButtonClick(ev) {
+ ev.stopPropagation();
+ this.el.querySelectorAll('.o_we_colorpicker_switch_pane_btn').forEach(el => {
+ el.classList.remove('active');
+ });
+ ev.currentTarget.classList.add('active');
+ this.el.querySelectorAll('.o_colorpicker_sections').forEach(el => {
+ el.classList.toggle('d-none', el.dataset.colorTab !== ev.currentTarget.dataset.target);
+ });
+ },
+});
+
+//------------------------------------------------------------------------------
+// Static
+//------------------------------------------------------------------------------
+
+/**
+ * Load ColorPaletteWidget dependencies. This allows to load them without
+ * instantiating the widget itself.
+ *
+ * @static
+ */
+let colorpickerTemplateProm;
+ColorPaletteWidget.loadDependencies = async function (rpcCapableObj) {
+ const proms = [ajax.loadXML('/web_editor/static/src/xml/snippets.xml', qweb)];
+
+ // Public user using the editor may have a colorpalette but with
+ // the default summernote ones.
+ if (!session.is_website_user) {
+ // We can call the colorPalette multiple times but only need 1 rpc
+ if (!colorpickerTemplateProm && !qweb.has_template('web_editor.colorpicker')) {
+ colorpickerTemplateProm = rpcCapableObj._rpc({
+ model: 'ir.ui.view',
+ method: 'read_template',
+ args: ['web_editor.colorpicker'],
+ }).then(template => {
+ return qweb.add_template('<templates>' + template + '</templates>');
+ });
+ }
+ proms.push(colorpickerTemplateProm);
+ }
+
+ return Promise.all(proms);
+};
+
+return {
+ ColorPaletteWidget: ColorPaletteWidget,
+};
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js
new file mode 100644
index 00000000..516aa4be
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js
@@ -0,0 +1,81 @@
+odoo.define('wysiwyg.widgets.Dialog', function (require) {
+'use strict';
+
+var config = require('web.config');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+
+var _t = core._t;
+
+/**
+ * Extend Dialog class to handle save/cancel of edition components.
+ */
+var SummernoteDialog = Dialog.extend({
+ /**
+ * @constructor
+ */
+ init: function (parent, options) {
+ this.options = options || {};
+ if (config.device.isMobile) {
+ options.fullscreen = true;
+ }
+ this._super(parent, _.extend({}, {
+ buttons: [{
+ text: this.options.save_text || _t("Save"),
+ classes: 'btn-primary',
+ click: this.save,
+ },
+ {
+ text: _t("Discard"),
+ close: true,
+ }
+ ]
+ }, this.options));
+
+ this.destroyAction = 'cancel';
+
+ var self = this;
+ this.opened(function () {
+ self.$('input:visible:first').focus();
+ self.$el.closest('.modal').addClass('o_web_editor_dialog');
+ self.$el.closest('.modal').on('hidden.bs.modal', self.options.onClose);
+ });
+ this.on('closed', this, function () {
+ self._toggleFullScreen();
+ this.trigger(this.destroyAction, this.final_data || null);
+ });
+ },
+ /**
+ * Only use on config.device.isMobile, it's used by mass mailing to allow the dialog opening on fullscreen
+ * @private
+ */
+ _toggleFullScreen: function() {
+ if (config.device.isMobile && !this.hasFullScreen) {
+ $('#iframe_target[isMobile="true"] #web_editor-top-edit .o_fullscreen').click();
+ }
+ },
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the dialog is saved. Set the destroy action type to "save"
+ * and should set the final_data variable correctly before closing.
+ */
+ save: function () {
+ this.destroyAction = "save";
+ this.close();
+ },
+ /**
+ * @override
+ * @returns {*}
+ */
+ open: function() {
+ this.hasFullScreen = $(window.top.document.body).hasClass('o_field_widgetTextHtml_fullscreen');
+ this._toggleFullScreen();
+ return this._super.apply(this, arguments);
+ },
+});
+
+return SummernoteDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
new file mode 100644
index 00000000..27444e06
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
@@ -0,0 +1,213 @@
+odoo.define('wysiwyg.widgets.ImageCropWidget', function (require) {
+'use strict';
+
+const core = require('web.core');
+const Widget = require('web.Widget');
+const {applyModifications, cropperDataFields, activateCropper, loadImage, loadImageInfo} = require('web_editor.image_processing');
+
+const _t = core._t;
+
+const ImageCropWidget = Widget.extend({
+ template: ['wysiwyg.widgets.crop'],
+ xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'],
+ events: {
+ 'click.crop_options [data-action]': '_onCropOptionClick',
+ // zoom event is triggered by the cropperjs library when the user zooms.
+ 'zoom': '_onCropZoom',
+ },
+
+ /**
+ * @constructor
+ */
+ init(parent, media) {
+ this._super(...arguments);
+ this.media = media;
+ this.$media = $(media);
+ // Needed for editors in iframes.
+ this.document = media.ownerDocument;
+ // key: ratio identifier, label: displayed to user, value: used by cropper lib
+ this.aspectRatios = {
+ "0/0": {label: _t("Free"), value: 0},
+ "16/9": {label: "16:9", value: 16 / 9},
+ "4/3": {label: "4:3", value: 4 / 3},
+ "1/1": {label: "1:1", value: 1},
+ "2/3": {label: "2:3", value: 2 / 3},
+ };
+ const src = this.media.getAttribute('src');
+ const data = Object.assign({}, media.dataset);
+ this.initialSrc = src;
+ this.aspectRatio = data.aspectRatio || "0/0";
+ this.mimetype = data.mimetype || src.endsWith('.png') ? 'image/png' : 'image/jpeg';
+ },
+ /**
+ * @override
+ */
+ async willStart() {
+ await this._super.apply(this, arguments);
+ await loadImageInfo(this.media, this._rpc.bind(this));
+ if (this.media.dataset.originalSrc) {
+ this.originalSrc = this.media.dataset.originalSrc;
+ this.originalId = this.media.dataset.originalId;
+ return;
+ }
+ // Couldn't find an attachment: not croppable.
+ this.uncroppable = true;
+ },
+ /**
+ * @override
+ */
+ async start() {
+ if (this.uncroppable) {
+ this.displayNotification({
+ type: 'warning',
+ title: _t("This image is an external image"),
+ message: _t("This type of image is not supported for cropping.<br/>If you want to crop it, please first download it from the original source and upload it in Odoo."),
+ });
+ return this.destroy();
+ }
+ const _super = this._super.bind(this);
+ const $cropperWrapper = this.$('.o_we_cropper_wrapper');
+
+ // Replacing the src with the original's so that the layout is correct.
+ await loadImage(this.originalSrc, this.media);
+ this.$cropperImage = this.$('.o_we_cropper_img');
+ const cropperImage = this.$cropperImage[0];
+ [cropperImage.style.width, cropperImage.style.height] = [this.$media.width() + 'px', this.$media.height() + 'px'];
+
+ // Overlaying the cropper image over the real image
+ const offset = this.$media.offset();
+ offset.left += parseInt(this.$media.css('padding-left'));
+ offset.top += parseInt(this.$media.css('padding-right'));
+ $cropperWrapper.offset(offset);
+
+ await loadImage(this.originalSrc, cropperImage);
+ await activateCropper(cropperImage, this.aspectRatios[this.aspectRatio].value, this.media.dataset);
+ core.bus.trigger('deactivate_snippet');
+
+ this._onDocumentMousedown = this._onDocumentMousedown.bind(this);
+ // We use capture so that the handler is called before other editor handlers
+ // like save, such that we can restore the src before a save.
+ this.document.addEventListener('mousedown', this._onDocumentMousedown, {capture: true});
+ return _super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ if (this.$cropperImage) {
+ this.$cropperImage.cropper('destroy');
+ this.document.removeEventListener('mousedown', this._onDocumentMousedown, {capture: true});
+ }
+ this.media.setAttribute('src', this.initialSrc);
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Updates the DOM image with cropped data and associates required
+ * information for a potential future save (where required cropped data
+ * attachments will be created).
+ *
+ * @private
+ */
+ async _save() {
+ // Mark the media for later creation of cropped attachment
+ this.media.classList.add('o_modified_image_to_save');
+
+ [...cropperDataFields, 'aspectRatio'].forEach(attr => {
+ delete this.media.dataset[attr];
+ const value = this._getAttributeValue(attr);
+ if (value) {
+ this.media.dataset[attr] = value;
+ }
+ });
+ delete this.media.dataset.resizeWidth;
+ this.initialSrc = await applyModifications(this.media);
+ this.$media.trigger('image_cropped');
+ this.destroy();
+ },
+ /**
+ * Returns an attribute's value for saving.
+ *
+ * @private
+ */
+ _getAttributeValue(attr) {
+ if (cropperDataFields.includes(attr)) {
+ return this.$cropperImage.cropper('getData')[attr];
+ }
+ return this[attr];
+ },
+ /**
+ * Resets the crop box to prevent it going outside the image.
+ *
+ * @private
+ */
+ _resetCropBox() {
+ this.$cropperImage.cropper('clear');
+ this.$cropperImage.cropper('crop');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a crop option is clicked -> change the crop area accordingly.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onCropOptionClick(ev) {
+ const {action, value, scaleDirection} = ev.currentTarget.dataset;
+ switch (action) {
+ case 'ratio':
+ this.$cropperImage.cropper('reset');
+ this.aspectRatio = value;
+ this.$cropperImage.cropper('setAspectRatio', this.aspectRatios[this.aspectRatio].value);
+ break;
+ case 'zoom':
+ case 'reset':
+ this.$cropperImage.cropper(action, value);
+ break;
+ case 'rotate':
+ this.$cropperImage.cropper(action, value);
+ this._resetCropBox();
+ break;
+ case 'flip': {
+ const amount = this.$cropperImage.cropper('getData')[scaleDirection] * -1;
+ return this.$cropperImage.cropper(scaleDirection, amount);
+ }
+ case 'apply':
+ return this._save();
+ case 'discard':
+ return this.destroy();
+ }
+ },
+ /**
+ * Discards crop if the user clicks outside of the widget.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onDocumentMousedown(ev) {
+ if (document.body.contains(ev.target) && this.$(ev.target).length === 0) {
+ return this.destroy();
+ }
+ },
+ /**
+ * Resets the cropbox on zoom to prevent crop box overflowing.
+ *
+ * @private
+ */
+ async _onCropZoom() {
+ // Wait for the zoom event to be fully processed before reseting.
+ await new Promise(res => setTimeout(res, 0));
+ this._resetCropBox();
+ },
+});
+
+return ImageCropWidget;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js
new file mode 100644
index 00000000..2a18ba2b
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js
@@ -0,0 +1,339 @@
+odoo.define('wysiwyg.widgets.LinkDialog', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('wysiwyg.widgets.Dialog');
+
+var dom = $.summernote.core.dom;
+var range = $.summernote.core.range;
+
+var _t = core._t;
+
+/**
+ * Allows to customize link content and style.
+ */
+var LinkDialog = Dialog.extend({
+ template: 'wysiwyg.widgets.link',
+ xmlDependencies: (Dialog.prototype.xmlDependencies || []).concat([
+ '/web_editor/static/src/xml/wysiwyg.xml'
+ ]),
+ events: _.extend({}, Dialog.prototype.events || {}, {
+ 'input': '_onAnyChange',
+ 'change [name="link_style_color"]': '_onTypeChange',
+ 'change': '_onAnyChange',
+ 'input input[name="url"]': '_onURLInput',
+ }),
+
+ /**
+ * @constructor
+ * @param {Boolean} linkInfo.isButton - whether if the target is a button element.
+ */
+ init: function (parent, options, editable, linkInfo) {
+ this.options = options || {};
+ this._super(parent, _.extend({
+ title: _t("Link to"),
+ }, this.options));
+
+ this.trigger_up('getRecordInfo', {
+ recordInfo: this.options,
+ callback: recordInfo => {
+ _.defaults(this.options, recordInfo);
+ },
+ });
+
+ this.data = linkInfo || {};
+ this.isButton = this.data.isButton;
+ // Using explicit type 'link' to preserve style when the target is <button class="...btn-link"/>.
+ this.colorsData = [
+ {type: this.isButton ? 'link' : '', label: _t("Link"), btnPreview: 'link'},
+ {type: 'primary', label: _t("Primary"), btnPreview: 'primary'},
+ {type: 'secondary', label: _t("Secondary"), btnPreview: 'secondary'},
+ // Note: by compatibility the dialog should be able to remove old
+ // colors that were suggested like the BS status colors or the
+ // alpha -> epsilon classes. This is currently done by removing
+ // all btn-* classes anyway.
+ ];
+
+ this.editable = editable;
+ this.data.className = "";
+ this.data.iniClassName = "";
+
+ var r = this.data.range;
+ this.needLabel = !r || (r.sc === r.ec && r.so === r.eo);
+
+ if (this.data.range) {
+ const $el = $(this.data.range.sc).filter(this.isButton ? "button" : "a");
+ this.data.iniClassName = $el.attr("class") || "";
+ this.colorCombinationClass = false;
+ let $node = $el;
+ while ($node.length && !$node.is('body')) {
+ const className = $node.attr('class') || '';
+ const m = className.match(/\b(o_cc\d+)\b/g);
+ if (m) {
+ this.colorCombinationClass = m[0];
+ break;
+ }
+ $node = $node.parent();
+ }
+ this.data.className = this.data.iniClassName.replace(/(^|\s+)btn(-[a-z0-9_-]*)?/gi, ' ');
+
+ var is_link = this.data.range.isOnAnchor();
+
+ var sc = r.sc;
+ var so = r.so;
+ var ec = r.ec;
+ var eo = r.eo;
+
+ var nodes;
+ if (!is_link) {
+ if (sc.tagName) {
+ sc = dom.firstChild(so ? sc.childNodes[so] : sc);
+ so = 0;
+ } else if (so !== sc.textContent.length) {
+ if (sc === ec) {
+ ec = sc = sc.splitText(so);
+ eo -= so;
+ } else {
+ sc = sc.splitText(so);
+ }
+ so = 0;
+ }
+ if (ec.tagName) {
+ ec = dom.lastChild(eo ? ec.childNodes[eo-1] : ec);
+ eo = ec.textContent.length;
+ } else if (eo !== ec.textContent.length) {
+ ec.splitText(eo);
+ }
+
+ nodes = dom.listBetween(sc, ec);
+
+ // browsers can't target a picture or void node
+ if (dom.isVoid(sc) || dom.isImg(sc)) {
+ so = dom.listPrev(sc).length-1;
+ sc = sc.parentNode;
+ }
+ if (dom.isBR(ec)) {
+ eo = dom.listPrev(ec).length-1;
+ ec = ec.parentNode;
+ } else if (dom.isVoid(ec) || dom.isImg(sc)) {
+ eo = dom.listPrev(ec).length;
+ ec = ec.parentNode;
+ }
+
+ this.data.range = range.create(sc, so, ec, eo);
+ $(editable).data("range", this.data.range);
+ this.data.range.select();
+ } else {
+ nodes = dom.ancestor(sc, dom.isAnchor).childNodes;
+ }
+
+ if (dom.isImg(sc) && nodes.indexOf(sc) === -1) {
+ nodes.push(sc);
+ }
+ if (nodes.length > 1 || dom.ancestor(nodes[0], dom.isImg)) {
+ var text = "";
+ this.data.images = [];
+ for (var i=0; i<nodes.length; i++) {
+ if (dom.ancestor(nodes[i], dom.isImg)) {
+ this.data.images.push(dom.ancestor(nodes[i], dom.isImg));
+ text += '[IMG]';
+ } else if (!is_link && nodes[i].nodeType === 1) {
+ // just use text nodes from listBetween
+ } else if (!is_link && i===0) {
+ text += nodes[i].textContent.slice(so, Infinity);
+ } else if (!is_link && i===nodes.length-1) {
+ text += nodes[i].textContent.slice(0, eo);
+ } else {
+ text += nodes[i].textContent;
+ }
+ }
+ this.data.text = text;
+ }
+ }
+
+ this.data.text = this.data.text.replace(/[ \t\r\n]+/g, ' ');
+
+ var allBtnClassSuffixes = /(^|\s+)btn(-[a-z0-9_-]*)?/gi;
+ var allBtnShapes = /\s*(rounded-circle|flat)\s*/gi;
+ this.data.className = this.data.iniClassName
+ .replace(allBtnClassSuffixes, ' ')
+ .replace(allBtnShapes, ' ');
+ // 'o_submit' class will force anchor to be handled as a button in linkdialog.
+ if (/(?:s_website_form_send|o_submit)/.test(this.data.className)) {
+ this.isButton = true;
+ }
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.buttonOptsCollapseEl = this.el.querySelector('#o_link_dialog_button_opts_collapse');
+
+ this.$styleInputs = this.$('input.link-style');
+ this.$styleInputs.prop('checked', false).filter('[value=""]').prop('checked', true);
+ if (this.data.iniClassName) {
+ _.each(this.$('input[name="link_style_color"], select[name="link_style_size"] > option, select[name="link_style_shape"] > option'), el => {
+ var $option = $(el);
+ if ($option.val() && this.data.iniClassName.match(new RegExp('(^|btn-| |btn-outline-)' + $option.val()))) {
+ if ($option.is("input")) {
+ $option.prop("checked", true);
+ } else {
+ $option.parent().find('option').removeAttr('selected').removeProp('selected');
+ $option.parent().val($option.val());
+ $option.attr('selected', 'selected').prop('selected', 'selected');
+ }
+ }
+ });
+ }
+ if (this.data.url) {
+ var match = /mailto:(.+)/.exec(this.data.url);
+ this.$('input[name="url"]').val(match ? match[1] : this.data.url);
+ this._onURLInput();
+ }
+
+ this._updateOptionsUI();
+ this._adaptPreview();
+
+ this.$('input:visible:first').focus();
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function () {
+ var data = this._getData();
+ if (data === null) {
+ var $url = this.$('input[name="url"]');
+ $url.closest('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
+ $url.focus();
+ return Promise.reject();
+ }
+ this.data.text = data.label;
+ this.data.url = data.url;
+ var allWhitespace = /\s+/gi;
+ var allStartAndEndSpace = /^\s+|\s+$/gi;
+ var allBtnTypes = /(^|[ ])(btn-secondary|btn-success|btn-primary|btn-info|btn-warning|btn-danger)([ ]|$)/gi;
+ this.data.className = data.classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, '');
+ if (data.classes.replace(allBtnTypes, ' ')) {
+ this.data.style = {
+ 'background-color': '',
+ 'color': '',
+ };
+ }
+ this.data.isNewWindow = data.isNewWindow;
+ this.final_data = this.data;
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adapt the link preview to changes.
+ *
+ * @private
+ */
+ _adaptPreview: function () {
+ var data = this._getData();
+ if (data === null) {
+ return;
+ }
+ const attrs = {
+ target: data.isNewWindow ? '_blank' : '',
+ href: data.url && data.url.length ? data.url : '#',
+ class: `${data.classes.replace(/float-\w+/, '')} o_btn_preview`,
+ };
+ this.$("#link-preview").attr(attrs).html((data.label && data.label.length) ? data.label : data.url);
+ },
+ /**
+ * Get the link's data (url, label and styles).
+ *
+ * @private
+ * @returns {Object} {label: String, url: String, classes: String, isNewWindow: Boolean}
+ */
+ _getData: function () {
+ var $url = this.$('input[name="url"]');
+ var url = $url.val();
+ var label = _.escape(this.$('input[name="label"]').val() || url);
+
+ if (label && this.data.images) {
+ for (var i = 0; i < this.data.images.length; i++) {
+ label = label.replace(/\[IMG\]/, this.data.images[i].outerHTML);
+ }
+ }
+
+ if (!this.isButton && $url.prop('required') && (!url || !$url[0].checkValidity())) {
+ return null;
+ }
+
+ const type = this.$('input[name="link_style_color"]:checked').val() || '';
+ const size = this.$('select[name="link_style_size"]').val() || '';
+ const shape = this.$('select[name="link_style_shape"]').val() || '';
+ const shapes = shape ? shape.split(',') : [];
+ const style = ['outline', 'fill'].includes(shapes[0]) ? `${shapes[0]}-` : '';
+ const shapeClasses = shapes.slice(style ? 1 : 0).join(' ');
+ const classes = (this.data.className || '') +
+ (type ? (` btn btn-${style}${type}`) : '') +
+ (shapeClasses ? (` ${shapeClasses}`) : '') +
+ (size ? (' btn-' + size) : '');
+ var isNewWindow = this.$('input[name="is_new_window"]').prop('checked');
+ if (url.indexOf('@') >= 0 && url.indexOf('mailto:') < 0 && !url.match(/^http[s]?/i)) {
+ url = ('mailto:' + url);
+ } else if (url.indexOf(location.origin) === 0 && this.$('#o_link_dialog_url_strip_domain').prop("checked")) {
+ url = url.slice(location.origin.length);
+ }
+ var allWhitespace = /\s+/gi;
+ var allStartAndEndSpace = /^\s+|\s+$/gi;
+ return {
+ label: label,
+ url: url,
+ classes: classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, ''),
+ isNewWindow: isNewWindow,
+ };
+ },
+ /**
+ * @private
+ */
+ _updateOptionsUI: function () {
+ const el = this.el.querySelector('[name="link_style_color"]:checked');
+ $(this.buttonOptsCollapseEl).collapse(el && el.value ? 'show' : 'hide');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAnyChange: function () {
+ this._adaptPreview();
+ },
+ /**
+ * @private
+ */
+ _onTypeChange() {
+ this._updateOptionsUI();
+ },
+ /**
+ * @private
+ */
+ _onURLInput: function () {
+ var $linkUrlInput = this.$('#o_link_dialog_url_input');
+ $linkUrlInput.closest('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
+ let value = $linkUrlInput.val();
+ let isLink = value.indexOf('@') < 0;
+ this.$('input[name="is_new_window"]').closest('.form-group').toggleClass('d-none', !isLink);
+ this.$('.o_strip_domain').toggleClass('d-none', value.indexOf(window.location.origin) !== 0);
+ },
+});
+
+return LinkDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
new file mode 100644
index 00000000..99ecabb1
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
@@ -0,0 +1,1463 @@
+odoo.define('wysiwyg.widgets.media', function (require) {
+'use strict';
+
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var dom = require('web.dom');
+var fonts = require('wysiwyg.fonts');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+var session = require('web.session');
+const {removeOnImageChangeAttrs} = require('web_editor.image_processing');
+const {getCSSVariableValue, DEFAULT_PALETTE} = require('web_editor.utils');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+var MediaWidget = Widget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'],
+
+ /**
+ * @constructor
+ * @param {Element} media: the target Element for which we select a media
+ * @param {Object} options: useful parameters such as res_id, res_model,
+ * context, user_id, ...
+ */
+ init: function (parent, media, options) {
+ this._super.apply(this, arguments);
+ this.media = media;
+ this.$media = $(media);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @todo comment
+ */
+ clear: function () {
+ if (!this.media) {
+ return;
+ }
+ this._clear();
+ },
+ /**
+ * Saves the currently configured media on the target media.
+ *
+ * @abstract
+ * @returns {Promise}
+ */
+ save: function () {},
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @abstract
+ */
+ _clear: function () {},
+});
+
+var SearchableMediaWidget = MediaWidget.extend({
+ events: _.extend({}, MediaWidget.prototype.events || {}, {
+ 'input .o_we_search': '_onSearchInput',
+ }),
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._onSearchInput = _.debounce(this._onSearchInput, 500);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Finds and displays existing attachments related to the target media.
+ *
+ * @abstract
+ * @param {string} needle: only return attachments matching this parameter
+ * @returns {Promise}
+ */
+ search: function (needle) {},
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Renders thumbnails for the attachments.
+ *
+ * @abstract
+ * @returns {Promise}
+ */
+ _renderThumbnails: function () {},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onSearchInput: function (ev) {
+ this.attachments = [];
+ this.search($(ev.currentTarget).val() || '').then(() => this._renderThumbnails());
+ this.hasSearched = true;
+ },
+});
+
+/**
+ * Let users choose a file, including uploading a new file in odoo.
+ */
+var FileWidget = SearchableMediaWidget.extend({
+ events: _.extend({}, SearchableMediaWidget.prototype.events || {}, {
+ 'click .o_upload_media_button': '_onUploadButtonClick',
+ 'change .o_file_input': '_onFileInputChange',
+ 'click .o_upload_media_url_button': '_onUploadURLButtonClick',
+ 'input .o_we_url_input': '_onURLInputChange',
+ 'click .o_existing_attachment_cell': '_onAttachmentClick',
+ 'click .o_existing_attachment_remove': '_onRemoveClick',
+ 'click .o_load_more': '_onLoadMoreClick',
+ }),
+ existingAttachmentsTemplate: undefined,
+
+ IMAGE_MIMETYPES: ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png', 'image/svg+xml'],
+ NUMBER_OF_ATTACHMENTS_TO_DISPLAY: 30,
+ MAX_DB_ATTACHMENTS: 5,
+
+ /**
+ * @constructor
+ */
+ init: function (parent, media, options) {
+ this._super.apply(this, arguments);
+ this._mutex = new concurrency.Mutex();
+
+ this.numberOfAttachmentsToDisplay = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY;
+
+ this.options = _.extend({
+ mediaWidth: media && media.parentElement && $(media.parentElement).width(),
+ useMediaLibrary: true,
+ }, options || {});
+
+ this.attachments = [];
+ this.selectedAttachments = [];
+ this.libraryMedia = [];
+ this.selectedMedia = [];
+
+ this._onUploadURLButtonClick = dom.makeAsyncHandler(this._onUploadURLButtonClick);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+ var self = this;
+ this.$urlInput = this.$('.o_we_url_input');
+ this.$form = this.$('form');
+ this.$fileInput = this.$('.o_file_input');
+ this.$uploadButton = this.$('.o_upload_media_button');
+ this.$addUrlButton = this.$('.o_upload_media_url_button');
+ this.$urlSuccess = this.$('.o_we_url_success');
+ this.$urlWarning = this.$('.o_we_url_warning');
+ this.$urlError = this.$('.o_we_url_error');
+ this.$errorText = this.$('.o_we_error_text');
+
+ // If there is already an attachment on the target, select by default
+ // that attachment if it is among the loaded images.
+ var o = {
+ url: null,
+ alt: null,
+ };
+ if (this.$media.is('img')) {
+ o.url = this.$media.attr('src');
+ } else if (this.$media.is('a.o_image')) {
+ o.url = this.$media.attr('href').replace(/[?].*/, '');
+ o.id = +o.url.match(/\/web\/content\/(\d+)/, '')[1];
+ }
+
+ return this.search('').then(async () => {
+ await this._renderThumbnails();
+ if (o.url) {
+ self._selectAttachement(_.find(self.attachments, function (attachment) {
+ return o.url === attachment.image_src;
+ }) || o);
+ }
+ return def;
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Saves the currently selected image on the target media. If new files are
+ * currently being added, delays the save until all files have been added.
+ *
+ * @override
+ */
+ save: function () {
+ return this._mutex.exec(this._save.bind(this));
+ },
+ /**
+ * @override
+ */
+ search: function (needle) {
+ this.needle = needle;
+ return this.fetchAttachments(this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY, 0);
+ },
+ /**
+ * @param {Number} number - the number of attachments to fetch
+ * @param {Number} offset - from which result to start fetching
+ */
+ fetchAttachments: function (number, offset) {
+ return this._rpc({
+ model: 'ir.attachment',
+ method: 'search_read',
+ args: [],
+ kwargs: {
+ domain: this._getAttachmentsDomain(this.needle),
+ fields: ['name', 'mimetype', 'description', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height', 'original_id'],
+ order: [{name: 'id', asc: false}],
+ context: this.options.context,
+ // Try to fetch first record of next page just to know whether there is a next page.
+ limit: number + 1,
+ offset: offset,
+ },
+ }).then(attachments => {
+ this.attachments = this.attachments.slice();
+ Array.prototype.splice.apply(this.attachments, [offset, attachments.length].concat(attachments));
+ });
+ },
+ /**
+ * Computes whether there is content to display in the template.
+ */
+ hasContent() {
+ return this.attachments.length;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _clear: function () {
+ this.media.className = this.media.className && this.media.className.replace(/(^|\s+)(o_image)(?=\s|$)/g, ' ');
+ },
+ /**
+ * Returns the domain for attachments used in media dialog.
+ * We look for attachments related to the current document. If there is a value for the model
+ * field, it is used to search attachments, and the attachments from the current document are
+ * filtered to display only user-created documents.
+ * In the case of a wizard such as mail, we have the documents uploaded and those of the model
+ *
+ * @private
+ * @params {string} needle
+ * @returns {Array} "ir.attachment" odoo domain.
+ */
+ _getAttachmentsDomain: function (needle) {
+ var domain = this.options.attachmentIDs && this.options.attachmentIDs.length ? ['|', ['id', 'in', this.options.attachmentIDs]] : [];
+
+ var attachedDocumentDomain = [
+ '&',
+ ['res_model', '=', this.options.res_model],
+ ['res_id', '=', this.options.res_id|0]
+ ];
+ // if the document is not yet created, do not see the documents of other users
+ if (!this.options.res_id) {
+ attachedDocumentDomain.unshift('&');
+ attachedDocumentDomain.push(['create_uid', '=', this.options.user_id]);
+ }
+ if (this.options.data_res_model) {
+ var relatedDomain = ['&',
+ ['res_model', '=', this.options.data_res_model],
+ ['res_id', '=', this.options.data_res_id|0]];
+ if (!this.options.data_res_id) {
+ relatedDomain.unshift('&');
+ relatedDomain.push(['create_uid', '=', session.uid]);
+ }
+ domain = domain.concat(['|'], attachedDocumentDomain, relatedDomain);
+ } else {
+ domain = domain.concat(attachedDocumentDomain);
+ }
+ domain = ['|', ['public', '=', true]].concat(domain);
+ domain = domain.concat(this.options.mimetypeDomain);
+ if (needle && needle.length) {
+ domain.push(['name', 'ilike', needle]);
+ }
+ if (!this.options.useMediaLibrary) {
+ domain.push('|', ['url', '=', false], '!', ['url', '=ilike', '/web_editor/shape/%']);
+ }
+ domain.push('!', ['name', '=like', '%.crop']);
+ domain.push('|', ['type', '=', 'binary'], '!', ['url', '=like', '/%/static/%']);
+ return domain;
+ },
+ /**
+ * @private
+ */
+ _highlightSelected: function () {
+ var self = this;
+ this.$('.o_existing_attachment_cell.o_we_attachment_selected').removeClass("o_we_attachment_selected");
+ _.each(this.selectedAttachments, function (attachment) {
+ self.$('.o_existing_attachment_cell[data-id=' + attachment.id + ']')
+ .addClass("o_we_attachment_selected").css('display', '');
+ });
+ },
+ /**
+ * @private
+ * @param {object} attachment
+ */
+ _handleNewAttachment: function (attachment) {
+ this.attachments = this.attachments.filter(att => att.id !== attachment.id);
+ this.attachments.unshift(attachment);
+ this._renderThumbnails();
+ this._selectAttachement(attachment);
+ },
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _loadMoreImages: function (forceSearch) {
+ return this.fetchAttachments(10, this.numberOfAttachmentsToDisplay).then(() => {
+ this.numberOfAttachmentsToDisplay += 10;
+ if (!forceSearch) {
+ this._renderThumbnails();
+ return Promise.resolve();
+ } else {
+ return this.search(this.$('.o_we_search').val() || '');
+ }
+ });
+ },
+ /**
+ * Renders the existing attachments and returns the result as a string.
+ *
+ * @param {Object[]} attachments
+ * @returns {string}
+ */
+ _renderExisting: function (attachments) {
+ return QWeb.render(this.existingAttachmentsTemplate, {
+ attachments: attachments,
+ widget: this,
+ });
+ },
+ /**
+ * @private
+ */
+ _renderThumbnails: function () {
+ var attachments = this.attachments.slice(0, this.numberOfAttachmentsToDisplay);
+
+ // Render menu & content
+ this.$('.o_we_existing_attachments').replaceWith(
+ this._renderExisting(attachments)
+ );
+
+ this._highlightSelected();
+
+ // adapt load more
+ this.$('.o_we_load_more').toggleClass('d-none', !this.hasContent());
+ var noLoadMoreButton = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY >= this.attachments.length;
+ var noMoreImgToLoad = this.numberOfAttachmentsToDisplay >= this.attachments.length;
+ this.$('.o_load_done_msg').toggleClass('d-none', noLoadMoreButton || !noMoreImgToLoad);
+ this.$('.o_load_more').toggleClass('d-none', noMoreImgToLoad);
+ },
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _save: async function () {
+ // Create all media-library attachments.
+ const toSave = Object.fromEntries(this.selectedMedia.map(media => [
+ media.id, {
+ query: media.query || '',
+ is_dynamic_svg: !!media.isDynamicSVG,
+ }
+ ]));
+ let mediaAttachments = [];
+ if (Object.keys(toSave).length !== 0) {
+ mediaAttachments = await this._rpc({
+ route: '/web_editor/save_library_media',
+ params: {
+ media: toSave,
+ },
+ });
+ }
+ const selected = this.selectedAttachments.concat(mediaAttachments).map(attachment => {
+ // Color-customize dynamic SVGs with the primary theme color
+ if (attachment.image_src && attachment.image_src.startsWith('/web_editor/shape/')) {
+ const colorCustomizedURL = new URL(attachment.image_src, window.location.origin);
+ colorCustomizedURL.searchParams.set('c1', getCSSVariableValue('o-color-1'));
+ attachment.image_src = colorCustomizedURL.pathname + colorCustomizedURL.search;
+ }
+ return attachment;
+ });
+ if (this.options.multiImages) {
+ return selected;
+ }
+
+ const img = selected[0];
+ if (!img || !img.id || this.$media.attr('src') === img.image_src) {
+ return this.media;
+ }
+
+ if (!img.public && !img.access_token) {
+ await this._rpc({
+ model: 'ir.attachment',
+ method: 'generate_access_token',
+ args: [[img.id]]
+ }).then(function (access_token) {
+ img.access_token = access_token[0];
+ });
+ }
+
+ if (img.image_src) {
+ var src = img.image_src;
+ if (!img.public && img.access_token) {
+ src += _.str.sprintf('?access_token=%s', img.access_token);
+ }
+ if (!this.$media.is('img')) {
+
+ // Note: by default the images receive the bootstrap opt-in
+ // img-fluid class. We cannot make them all responsive
+ // by design because of libraries and client databases img.
+ this.$media = $('<img/>', {class: 'img-fluid o_we_custom_image'});
+ this.media = this.$media[0];
+ }
+ this.$media.attr('src', src);
+ } else {
+ if (!this.$media.is('a')) {
+ $('.note-control-selection').hide();
+ this.$media = $('<a/>');
+ this.media = this.$media[0];
+ }
+ var href = '/web/content/' + img.id + '?';
+ if (!img.public && img.access_token) {
+ href += _.str.sprintf('access_token=%s&', img.access_token);
+ }
+ href += 'unique=' + img.checksum + '&download=true';
+ this.$media.attr('href', href);
+ this.$media.addClass('o_image').attr('title', img.name);
+ }
+
+ this.$media.attr('alt', img.alt || img.description || '');
+ var style = this.style;
+ if (style) {
+ this.$media.css(style);
+ }
+
+ // Remove image modification attributes
+ removeOnImageChangeAttrs.forEach(attr => {
+ delete this.media.dataset[attr];
+ });
+ // Add mimetype for documents
+ if (!img.image_src) {
+ this.media.dataset.mimetype = img.mimetype;
+ }
+ this.media.classList.remove('o_modified_image_to_save');
+ this.$media.trigger('image_changed');
+ return this.media;
+ },
+ /**
+ * @param {object} attachment
+ * @param {boolean} [save=true] to save the given attachment in the DOM and
+ * and to close the media dialog
+ * @private
+ */
+ _selectAttachement: function (attachment, save, {type = 'attachment'} = {}) {
+ const possibleProps = {
+ 'attachment': 'selectedAttachments',
+ 'media': 'selectedMedia'
+ };
+ const prop = possibleProps[type];
+ if (this.options.multiImages) {
+ // if the clicked attachment is already selected then unselect it
+ // unless it was a save request (then keep the current selection)
+ const index = this[prop].indexOf(attachment);
+ if (index !== -1) {
+ if (!save) {
+ this[prop].splice(index, 1);
+ }
+ } else {
+ // if the clicked attachment is not selected, add it to selected
+ this[prop].push(attachment);
+ }
+ } else {
+ Object.values(possibleProps).forEach(prop => {
+ this[prop] = [];
+ });
+ // select the clicked attachment
+ this[prop] = [attachment];
+ }
+ this._highlightSelected();
+ if (save) {
+ this.trigger_up('save_request');
+ }
+ },
+ /**
+ * Updates the add by URL UI.
+ *
+ * @private
+ * @param {boolean} emptyValue
+ * @param {boolean} isURL
+ * @param {boolean} isImage
+ */
+ _updateAddUrlUi: function (emptyValue, isURL, isImage) {
+ this.$addUrlButton.toggleClass('btn-secondary', emptyValue)
+ .toggleClass('btn-primary', !emptyValue)
+ .prop('disabled', !isURL);
+ this.$urlSuccess.toggleClass('d-none', !isURL);
+ this.$urlError.toggleClass('d-none', emptyValue || isURL);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAttachmentClick: function (ev) {
+ const attachment = ev.currentTarget;
+ const {id: attachmentID, mediaId} = attachment.dataset;
+ if (attachmentID) {
+ const attachment = this.attachments.find(attachment => attachment.id === parseInt(attachmentID));
+ this._selectAttachement(attachment, !this.options.multiImages);
+ } else if (mediaId) {
+ const media = this.libraryMedia.find(media => media.id === parseInt(mediaId));
+ this._selectAttachement(media, !this.options.multiImages, {type: 'media'});
+ }
+ },
+ /**
+ * Handles change of the file input: create attachments with the new files
+ * and open the Preview dialog for each of them. Locks the save button until
+ * all new files have been processed.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _onFileInputChange: function () {
+ return this._mutex.exec(this._addData.bind(this));
+ },
+ /**
+ * Uploads the files that are currently selected on the file input, which
+ * creates new attachments. Then inserts them on the media dialog and
+ * selects them. If multiImages is not set, also triggers up the
+ * save_request event to insert the attachment in the DOM.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ async _addData() {
+ let files = this.$fileInput[0].files;
+ if (!files.length) {
+ // Case if the input is emptied, return resolved promise
+ return;
+ }
+
+ var self = this;
+ var uploadMutex = new concurrency.Mutex();
+
+ // Upload the smallest file first to block the user the least possible.
+ files = _.sortBy(files, 'size');
+ _.each(files, function (file) {
+ // Upload one file at a time: no need to parallel as upload is
+ // limited by bandwidth.
+ uploadMutex.exec(function () {
+ return utils.getDataURLFromFile(file).then(function (result) {
+ return self._rpc({
+ route: '/web_editor/attachment/add_data',
+ params: {
+ 'name': file.name,
+ 'data': result.split(',')[1],
+ 'res_id': self.options.res_id,
+ 'res_model': self.options.res_model,
+ 'width': 0,
+ 'quality': 0,
+ },
+ }).then(function (attachment) {
+ self._handleNewAttachment(attachment);
+ });
+ });
+ });
+ });
+
+ return uploadMutex.getUnlockedDef().then(function () {
+ if (!self.options.multiImages && !self.noSave) {
+ self.trigger_up('save_request');
+ }
+ self.noSave = false;
+ });
+ },
+ /**
+ * @private
+ */
+ _onRemoveClick: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ Dialog.confirm(this, _t("Are you sure you want to delete this file ?"), {
+ confirm_callback: function () {
+ var $a = $(ev.currentTarget).closest('.o_existing_attachment_cell');
+ var id = parseInt($a.data('id'), 10);
+ var attachment = _.findWhere(self.attachments, {id: id});
+ return self._rpc({
+ route: '/web_editor/attachment/remove',
+ params: {
+ ids: [id],
+ },
+ }).then(function (prevented) {
+ if (_.isEmpty(prevented)) {
+ self.attachments = _.without(self.attachments, attachment);
+ self.attachments.filter(at => at.original_id[0] === attachment.id).forEach(at => delete at.original_id);
+ if (!self.attachments.length) {
+ self._renderThumbnails(); //render the message and image if empty
+ } else {
+ $a.closest('.o_existing_attachment_cell').remove();
+ }
+ return;
+ }
+ self.$errorText.replaceWith(QWeb.render('wysiwyg.widgets.image.existing.error', {
+ views: prevented[id],
+ widget: self,
+ }));
+ });
+ }
+ });
+ },
+ /**
+ * @private
+ */
+ _onURLInputChange: function () {
+ var inputValue = this.$urlInput.val();
+ var emptyValue = (inputValue === '');
+
+ var isURL = /^.+\..+$/.test(inputValue); // TODO improve
+ var isImage = _.any(['.gif', '.jpeg', '.jpe', '.jpg', '.png'], function (format) {
+ return inputValue.endsWith(format);
+ });
+
+ this._updateAddUrlUi(emptyValue, isURL, isImage);
+ },
+ /**
+ * @private
+ */
+ _onUploadButtonClick: function () {
+ this.$fileInput.click();
+ },
+ /**
+ * @private
+ */
+ _onUploadURLButtonClick: function () {
+ if (this.$urlInput.is('.o_we_horizontal_collapse')) {
+ this.$urlInput.removeClass('o_we_horizontal_collapse');
+ this.$addUrlButton.attr('disabled', 'disabled');
+ return;
+ }
+ return this._mutex.exec(this._addUrl.bind(this));
+ },
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _addUrl: function () {
+ var self = this;
+ return this._rpc({
+ route: '/web_editor/attachment/add_url',
+ params: {
+ 'url': this.$urlInput.val(),
+ 'res_id': this.options.res_id,
+ 'res_model': this.options.res_model,
+ },
+ }).then(function (attachment) {
+ self.$urlInput.val('');
+ self._onURLInputChange();
+ self._handleNewAttachment(attachment);
+ if (!self.options.multiImages) {
+ self.trigger_up('save_request');
+ }
+ });
+ },
+ /**
+ * @private
+ */
+ _onLoadMoreClick: function () {
+ this._loadMoreImages();
+ },
+ /**
+ * @override
+ */
+ _onSearchInput: function () {
+ this.attachments = [];
+ this.numberOfAttachmentsToDisplay = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY;
+ this._super.apply(this, arguments);
+ },
+});
+
+/**
+ * Let users choose an image, including uploading a new image in odoo.
+ */
+var ImageWidget = FileWidget.extend({
+ template: 'wysiwyg.widgets.image',
+ existingAttachmentsTemplate: 'wysiwyg.widgets.image.existing.attachments',
+ events: Object.assign({}, FileWidget.prototype.events, {
+ 'change input.o_we_show_optimized': '_onShowOptimizedChange',
+ 'change .o_we_search_select': '_onSearchSelect',
+ }),
+ MIN_ROW_HEIGHT: 128,
+
+ /**
+ * @constructor
+ */
+ init: function (parent, media, options) {
+ this.searchService = 'all';
+ options = _.extend({
+ accept: 'image/*',
+ mimetypeDomain: [['mimetype', 'in', this.IMAGE_MIMETYPES]],
+ }, options || {});
+ // Binding so we can add/remove it as an addEventListener
+ this._onAttachmentImageLoad = this._onAttachmentImageLoad.bind(this);
+ this._super(parent, media, options);
+ },
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+ this.el.addEventListener('load', this._onAttachmentImageLoad, true);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this.el.removeEventListener('load', this._onAttachmentImageLoad, true);
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ async fetchAttachments(number, offset) {
+ if (this.needle && this.searchService !== 'database') {
+ number = this.MAX_DB_ATTACHMENTS;
+ offset = 0;
+ }
+ const result = await this._super(number, offset);
+ // Color-substitution for dynamic SVG attachment
+ const primaryColor = getCSSVariableValue('o-color-1');
+ this.attachments.forEach(attachment => {
+ if (attachment.image_src.startsWith('/')) {
+ const newURL = new URL(attachment.image_src, window.location.origin);
+ // Set the main color of dynamic SVGs to o-color-1
+ if (attachment.image_src.startsWith('/web_editor/shape/')) {
+ newURL.searchParams.set('c1', primaryColor);
+ } else {
+ // Set height so that db images load faster
+ newURL.searchParams.set('height', 2 * this.MIN_ROW_HEIGHT);
+ }
+ attachment.thumbnail_src = newURL.pathname + newURL.search;
+ }
+ });
+ if (this.needle && this.options.useMediaLibrary) {
+ try {
+ const response = await this._rpc({
+ route: '/web_editor/media_library_search',
+ params: {
+ 'query': this.needle,
+ 'offset': this.libraryMedia.length,
+ },
+ });
+ const newMedia = response.media;
+ this.nbMediaResults = response.results;
+ this.libraryMedia.push(...newMedia);
+ } catch (e) {
+ // Either API endpoint doesn't exist or is misconfigured.
+ console.error(`Couldn't reach API endpoint.`);
+ }
+ }
+ return result;
+ },
+ /**
+ * @override
+ */
+ hasContent() {
+ if (this.searchService === 'all') {
+ return this._super(...arguments) || this.libraryMedia.length;
+ } else if (this.searchService === 'media-library') {
+ return !!this.libraryMedia.length;
+ }
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _updateAddUrlUi: function (emptyValue, isURL, isImage) {
+ this._super.apply(this, arguments);
+ this.$addUrlButton.text((isURL && !isImage) ? _t("Add as document") : _t("Add image"));
+ const warning = isURL && !isImage;
+ this.$urlWarning.toggleClass('d-none', !warning);
+ if (warning) {
+ this.$urlSuccess.addClass('d-none');
+ }
+ },
+ /**
+ * @override
+ */
+ _renderThumbnails: function () {
+ const alreadyLoaded = this.$('.o_existing_attachment_cell[data-loaded="true"]');
+ this._super(...arguments);
+ // Hide images until they're loaded
+ this.$('.o_existing_attachment_cell').addClass('d-none');
+ // Replace images that had been previously loaded if any to prevent scroll resetting to top
+ alreadyLoaded.each((index, el) => {
+ const toReplace = this.$(`.o_existing_attachment_cell[data-id="${el.dataset.id}"], .o_existing_attachment_cell[data-media-id="${el.dataset.mediaId}"]`);
+ if (toReplace.length) {
+ toReplace.replaceWith(el);
+ }
+ });
+ this._toggleOptimized(this.$('input.o_we_show_optimized')[0].checked);
+ // Placeholders have a 3:2 aspect ratio like most photos.
+ const placeholderWidth = 3 / 2 * this.MIN_ROW_HEIGHT;
+ this.$('.o_we_attachment_placeholder').css({
+ flexGrow: placeholderWidth,
+ flexBasis: placeholderWidth,
+ });
+ if (this.needle && ['media-library', 'all'].includes(this.searchService)) {
+ const noMoreImgToLoad = this.libraryMedia.length === this.nbMediaResults;
+ const noLoadMoreButton = noMoreImgToLoad && this.libraryMedia.length <= 15;
+ this.$('.o_load_done_msg').toggleClass('d-none', noLoadMoreButton || !noMoreImgToLoad);
+ this.$('.o_load_more').toggleClass('d-none', noMoreImgToLoad);
+ }
+ },
+ /**
+ * @override
+ */
+ _renderExisting: function (attachments) {
+ if (this.needle && this.searchService !== 'database') {
+ attachments = attachments.slice(0, this.MAX_DB_ATTACHMENTS);
+ }
+ return QWeb.render(this.existingAttachmentsTemplate, {
+ attachments: attachments,
+ libraryMedia: this.libraryMedia,
+ widget: this,
+ });
+ },
+ /**
+ * @private
+ *
+ * @param {boolean} value whether to toggle optimized attachments on or off
+ */
+ _toggleOptimized: function (value) {
+ this.$('.o_we_attachment_optimized').each((i, cell) => cell.style.setProperty('display', value ? null : 'none', 'important'));
+ },
+ /**
+ * @override
+ */
+ _highlightSelected: function () {
+ this._super(...arguments);
+ this.selectedMedia.forEach(media => {
+ this.$(`.o_existing_attachment_cell[data-media-id=${media.id}]`)
+ .addClass("o_we_attachment_selected");
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onAttachmentImageLoad: async function (ev) {
+ const img = ev.target;
+ const cell = img.closest('.o_existing_attachment_cell');
+ if (!cell) {
+ return;
+ }
+ if (cell.dataset.mediaId && !img.src.startsWith('blob')) {
+ const mediaUrl = img.src;
+ try {
+ const response = await fetch(mediaUrl);
+ if (response.headers.get('content-type') === 'image/svg+xml') {
+ const svg = await response.text();
+ const colorRegex = new RegExp(DEFAULT_PALETTE['1'], 'gi');
+ if (colorRegex.test(svg)) {
+ const fileName = mediaUrl.split('/').pop();
+ const file = new File([svg.replace(colorRegex, getCSSVariableValue('o-color-1'))], fileName, {
+ type: "image/svg+xml",
+ });
+ img.src = URL.createObjectURL(file);
+ const media = this.libraryMedia.find(media => media.id === parseInt(cell.dataset.mediaId));
+ if (media) {
+ media.isDynamicSVG = true;
+ }
+ // We changed the src: wait for the next load event to do the styling
+ return;
+ }
+ }
+ } catch (e) {
+ console.error('CORS is misconfigured on the API server, image will be treated as non-dynamic.');
+ }
+ }
+ let aspectRatio = img.naturalWidth / img.naturalHeight;
+ // Special case for SVGs with no instrinsic sizes on firefox
+ // See https://github.com/whatwg/html/issues/3510#issuecomment-369982529
+ if (img.naturalHeight === 0) {
+ img.width = 1000;
+ // Position fixed so that the image doesn't affect layout while rendering
+ img.style.position = 'fixed';
+ // Make invisible so the image doesn't briefly appear on the screen
+ img.style.opacity = '0';
+ // Image needs to be in the DOM for dimensions to be correct after render
+ const originalParent = img.parentElement;
+ document.body.appendChild(img);
+
+ aspectRatio = img.width / img.height;
+ originalParent.appendChild(img);
+ img.removeAttribute('width');
+ img.style.removeProperty('position');
+ img.style.removeProperty('opacity');
+ }
+ const width = aspectRatio * this.MIN_ROW_HEIGHT;
+ cell.style.flexGrow = width;
+ cell.style.flexBasis = `${width}px`;
+ cell.classList.remove('d-none');
+ cell.classList.add('d-flex');
+ cell.dataset.loaded = 'true';
+ },
+ /**
+ * @override
+ */
+ _onShowOptimizedChange: function (ev) {
+ this._toggleOptimized(ev.target.checked);
+ },
+ /**
+ * @override
+ */
+ _onSearchSelect: function (ev) {
+ const {value} = ev.target;
+ this.searchService = value;
+ this.$('.o_we_search').trigger('input');
+ },
+ /**
+ * @private
+ */
+ _onSearchInput: function (ev) {
+ this.libraryMedia = [];
+ this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _clear: function (type) {
+ // Not calling _super: we don't want to call the document widget's _clear method on images
+ var allImgClasses = /(^|\s+)(img|img-\S*|o_we_custom_image|rounded-circle|rounded|thumbnail|shadow)(?=\s|$)/g;
+ this.media.className = this.media.className && this.media.className.replace(allImgClasses, ' ');
+ },
+});
+
+
+/**
+ * Let users choose a document, including uploading a new document in odoo.
+ */
+var DocumentWidget = FileWidget.extend({
+ template: 'wysiwyg.widgets.document',
+ existingAttachmentsTemplate: 'wysiwyg.widgets.document.existing.attachments',
+
+ /**
+ * @constructor
+ */
+ init: function (parent, media, options) {
+ options = _.extend({
+ accept: '*/*',
+ mimetypeDomain: [['mimetype', 'not in', this.IMAGE_MIMETYPES]],
+ }, options || {});
+ this._super(parent, media, options);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _updateAddUrlUi: function (emptyValue, isURL, isImage) {
+ this._super.apply(this, arguments);
+ this.$addUrlButton.text((isURL && isImage) ? _t("Add as image") : _t("Add document"));
+ const warning = isURL && isImage;
+ this.$urlWarning.toggleClass('d-none', !warning);
+ if (warning) {
+ this.$urlSuccess.addClass('d-none');
+ }
+ },
+ /**
+ * @override
+ */
+ _getAttachmentsDomain: function (needle) {
+ var domain = this._super.apply(this, arguments);
+ // the assets should not be part of the documents
+ return domain.concat('!', utils.assetsDomain());
+ },
+});
+
+/**
+ * Let users choose a font awesome icon, support all font awesome loaded in the
+ * css files.
+ */
+var IconWidget = SearchableMediaWidget.extend({
+ template: 'wysiwyg.widgets.font-icons',
+ events: _.extend({}, SearchableMediaWidget.prototype.events || {}, {
+ 'click .font-icons-icon': '_onIconClick',
+ }),
+
+ /**
+ * @constructor
+ */
+ init: function (parent, media) {
+ this._super.apply(this, arguments);
+
+ fonts.computeFonts();
+ this.iconsParser = fonts.fontIcons;
+ this.alias = _.flatten(_.map(this.iconsParser, function (data) {
+ return data.alias;
+ }));
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$icons = this.$('.font-icons-icon');
+ var classes = (this.media && this.media.className || '').split(/\s+/);
+ for (var i = 0; i < classes.length; i++) {
+ var cls = classes[i];
+ if (_.contains(this.alias, cls)) {
+ this.selectedIcon = cls;
+ this.initialIcon = cls;
+ this._highlightSelectedIcon();
+ }
+ }
+ // Kept for compat in stable, no longer in use: remove in master
+ this.nonIconClasses = _.without(classes, 'media_iframe_video', this.selectedIcon);
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function () {
+ var style = this.$media.attr('style') || '';
+ var iconFont = this._getFont(this.selectedIcon) || {base: 'fa', font: ''};
+ if (!this.$media.is('span, i')) {
+ var $span = $('<span/>');
+ $span.data(this.$media.data());
+ this.$media = $span;
+ this.media = this.$media[0];
+ style = style.replace(/\s*width:[^;]+/, '');
+ }
+ this.$media.removeClass(this.initialIcon).addClass([iconFont.base, iconFont.font]);
+ this.$media.attr('style', style || null);
+ return Promise.resolve(this.media);
+ },
+ /**
+ * @override
+ */
+ search: function (needle) {
+ var iconsParser = this.iconsParser;
+ if (needle && needle.length) {
+ iconsParser = [];
+ _.filter(this.iconsParser, function (data) {
+ var cssData = _.filter(data.cssData, function (cssData) {
+ return _.find(cssData.names, function (alias) {
+ return alias.indexOf(needle) >= 0;
+ });
+ });
+ if (cssData.length) {
+ iconsParser.push({
+ base: data.base,
+ cssData: cssData,
+ });
+ }
+ });
+ }
+ this.$('div.font-icons-icons').html(
+ QWeb.render('wysiwyg.widgets.font-icons.icons', {iconsParser: iconsParser, widget: this})
+ );
+ return Promise.resolve();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _clear: function () {
+ var allFaClasses = /(^|\s)(fa|(text-|bg-|fa-)\S*|rounded-circle|rounded|thumbnail|shadow)(?=\s|$)/g;
+ this.media.className = this.media.className && this.media.className.replace(allFaClasses, ' ');
+ },
+ /**
+ * @private
+ */
+ _getFont: function (classNames) {
+ if (!(classNames instanceof Array)) {
+ classNames = (classNames || "").split(/\s+/);
+ }
+ var fontIcon, cssData;
+ for (var k = 0; k < this.iconsParser.length; k++) {
+ fontIcon = this.iconsParser[k];
+ for (var s = 0; s < fontIcon.cssData.length; s++) {
+ cssData = fontIcon.cssData[s];
+ if (_.intersection(classNames, cssData.names).length) {
+ return {
+ base: fontIcon.base,
+ parser: fontIcon.parser,
+ font: cssData.names[0],
+ };
+ }
+ }
+ }
+ return null;
+ },
+ /**
+ * @private
+ */
+ _highlightSelectedIcon: function () {
+ var self = this;
+ this.$icons.removeClass('o_we_attachment_selected');
+ this.$icons.filter(function (i, el) {
+ return _.contains($(el).data('alias').split(','), self.selectedIcon);
+ }).addClass('o_we_attachment_selected');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onIconClick: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ this.selectedIcon = $(ev.currentTarget).data('id');
+ this._highlightSelectedIcon();
+ this.trigger_up('save_request');
+ },
+});
+
+/**
+ * Let users choose a video, support all summernote video, and embed iframe.
+ */
+var VideoWidget = MediaWidget.extend({
+ template: 'wysiwyg.widgets.video',
+ events: _.extend({}, MediaWidget.prototype.events || {}, {
+ 'change .o_video_dialog_options input': '_onUpdateVideoOption',
+ 'input textarea#o_video_text': '_onVideoCodeInput',
+ 'change textarea#o_video_text': '_onVideoCodeChange',
+ }),
+
+ /**
+ * @constructor
+ */
+ init: function (parent, media, options) {
+ this._super.apply(this, arguments);
+ this.isForBgVideo = !!options.isForBgVideo;
+ this._onVideoCodeInput = _.debounce(this._onVideoCodeInput, 1000);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$content = this.$('.o_video_dialog_iframe');
+
+ if (this.media) {
+ var $media = $(this.media);
+ var src = $media.data('oe-expression') || $media.data('src') || ($media.is('iframe') ? $media.attr('src') : '') || '';
+ this.$('textarea#o_video_text').val(src);
+
+ this.$('input#o_video_autoplay').prop('checked', src.indexOf('autoplay=1') >= 0);
+ this.$('input#o_video_hide_controls').prop('checked', src.indexOf('controls=0') >= 0);
+ this.$('input#o_video_loop').prop('checked', src.indexOf('loop=1') >= 0);
+ this.$('input#o_video_hide_fullscreen').prop('checked', src.indexOf('fs=0') >= 0);
+ this.$('input#o_video_hide_yt_logo').prop('checked', src.indexOf('modestbranding=1') >= 0);
+ this.$('input#o_video_hide_dm_logo').prop('checked', src.indexOf('ui-logo=0') >= 0);
+ this.$('input#o_video_hide_dm_share').prop('checked', src.indexOf('sharing-enable=0') >= 0);
+
+ this._updateVideo();
+ }
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function () {
+ this._updateVideo();
+ if (this.isForBgVideo) {
+ return Promise.resolve({bgVideoSrc: this.$content.attr('src')});
+ }
+ if (this.$('.o_video_dialog_iframe').is('iframe')) {
+ this.$media = $(
+ '<div class="media_iframe_video" data-oe-expression="' + this.$content.attr('src') + '">' +
+ '<div class="css_editable_mode_display">&nbsp;</div>' +
+ '<div class="media_iframe_video_size" contenteditable="false">&nbsp;</div>' +
+ '<iframe src="' + this.$content.attr('src') + '" frameborder="0" contenteditable="false" allowfullscreen="allowfullscreen"></iframe>' +
+ '</div>'
+ );
+ this.media = this.$media[0];
+ }
+ return Promise.resolve(this.media);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _clear: function () {
+ if (this.media.dataset.src) {
+ try {
+ delete this.media.dataset.src;
+ } catch (e) {
+ this.media.dataset.src = undefined;
+ }
+ }
+ var allVideoClasses = /(^|\s)media_iframe_video(\s|$)/g;
+ var isVideo = this.media.className && this.media.className.match(allVideoClasses);
+ if (isVideo) {
+ this.media.className = this.media.className.replace(allVideoClasses, ' ');
+ this.media.innerHTML = '';
+ }
+ },
+ /**
+ * Creates a video node according to the given URL and options. If not
+ * possible, returns an error code.
+ *
+ * @private
+ * @param {string} url
+ * @param {Object} options
+ * @returns {Object}
+ * $video -> the created video jQuery node
+ * type -> the type of the created video
+ * errorCode -> if defined, either '0' for invalid URL or '1' for
+ * unsupported video provider
+ */
+ _createVideoNode: function (url, options) {
+ options = options || {};
+ const videoData = this._getVideoURLData(url, options);
+ if (videoData.error) {
+ return {errorCode: 0};
+ }
+ if (!videoData.type) {
+ return {errorCode: 1};
+ }
+ const $video = $('<iframe>').width(1280).height(720)
+ .attr('frameborder', 0)
+ .attr('src', videoData.embedURL)
+ .addClass('o_video_dialog_iframe');
+
+ return {$video: $video, type: videoData.type};
+ },
+ /**
+ * Updates the video preview according to video code and enabled options.
+ *
+ * @private
+ */
+ _updateVideo: function () {
+ // Reset the feedback
+ this.$content.empty();
+ this.$('#o_video_form_group').removeClass('o_has_error o_has_success').find('.form-control, .custom-select').removeClass('is-invalid is-valid');
+ this.$('.o_video_dialog_options div').addClass('d-none');
+
+ // Check video code
+ var $textarea = this.$('textarea#o_video_text');
+ var code = $textarea.val().trim();
+ if (!code) {
+ return;
+ }
+
+ // Detect if we have an embed code rather than an URL
+ var embedMatch = code.match(/(src|href)=["']?([^"']+)?/);
+ if (embedMatch && embedMatch[2].length > 0 && embedMatch[2].indexOf('instagram')) {
+ embedMatch[1] = embedMatch[2]; // Instagram embed code is different
+ }
+ var url = embedMatch ? embedMatch[1] : code;
+
+ var query = this._createVideoNode(url, {
+ 'autoplay': this.isForBgVideo || this.$('input#o_video_autoplay').is(':checked'),
+ 'hide_controls': this.isForBgVideo || this.$('input#o_video_hide_controls').is(':checked'),
+ 'loop': this.isForBgVideo || this.$('input#o_video_loop').is(':checked'),
+ 'hide_fullscreen': this.isForBgVideo || this.$('input#o_video_hide_fullscreen').is(':checked'),
+ 'hide_yt_logo': this.isForBgVideo || this.$('input#o_video_hide_yt_logo').is(':checked'),
+ 'hide_dm_logo': this.isForBgVideo || this.$('input#o_video_hide_dm_logo').is(':checked'),
+ 'hide_dm_share': this.isForBgVideo || this.$('input#o_video_hide_dm_share').is(':checked'),
+ });
+
+ var $optBox = this.$('.o_video_dialog_options');
+
+ // Show / Hide preview elements
+ this.$el.find('.o_video_dialog_preview_text, .media_iframe_video_size').add($optBox).toggleClass('d-none', !query.$video);
+ // Toggle validation classes
+ this.$el.find('#o_video_form_group')
+ .toggleClass('o_has_error', !query.$video).find('.form-control, .custom-select').toggleClass('is-invalid', !query.$video)
+ .end()
+ .toggleClass('o_has_success', !!query.$video).find('.form-control, .custom-select').toggleClass('is-valid', !!query.$video);
+
+ // Individually show / hide options base on the video provider
+ $optBox.find('div.o_' + query.type + '_option').removeClass('d-none');
+
+ // Hide the entire options box if no options are available or if the
+ // dialog is opened for a background-video
+ $optBox.toggleClass('d-none', this.isForBgVideo || $optBox.find('div:not(.d-none)').length === 0);
+
+ if (query.type === 'youtube') {
+ // Youtube only: If 'hide controls' is checked, hide 'fullscreen'
+ // and 'youtube logo' options too
+ this.$('input#o_video_hide_fullscreen, input#o_video_hide_yt_logo').closest('div').toggleClass('d-none', this.$('input#o_video_hide_controls').is(':checked'));
+ }
+
+ var $content = query.$video;
+ if (!$content) {
+ switch (query.errorCode) {
+ case 0:
+ $content = $('<div/>', {
+ class: 'alert alert-danger o_video_dialog_iframe mb-2 mt-2',
+ text: _t("The provided url is not valid"),
+ });
+ break;
+ case 1:
+ $content = $('<div/>', {
+ class: 'alert alert-warning o_video_dialog_iframe mb-2 mt-2',
+ text: _t("The provided url does not reference any supported video"),
+ });
+ break;
+ }
+ }
+ this.$content.replaceWith($content);
+ this.$content = $content;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a video option changes -> Updates the video preview.
+ *
+ * @private
+ */
+ _onUpdateVideoOption: function () {
+ this._updateVideo();
+ },
+ /**
+ * Called when the video code (URL / Iframe) change is confirmed -> Updates
+ * the video preview immediately.
+ *
+ * @private
+ */
+ _onVideoCodeChange: function () {
+ this._updateVideo();
+ },
+ /**
+ * Called when the video code (URL / Iframe) changes -> Updates the video
+ * preview (note: this function is automatically debounced).
+ *
+ * @private
+ */
+ _onVideoCodeInput: function () {
+ this._updateVideo();
+ },
+ /**
+ * Parses a URL and returns the provider type and an emebedable URL.
+ *
+ * @private
+ */
+ _getVideoURLData: function (url, options) {
+ if (!url.match(/^(http:\/\/|https:\/\/|\/\/)[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/i)) {
+ return {
+ error: true,
+ message: 'The provided url is invalid',
+ };
+ }
+ const regexes = {
+ youtube: /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube(-nocookie)?\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((?:\w|-){11})(?:\S+)?$/,
+ instagram: /(.*)instagram.com\/p\/(.[a-zA-Z0-9]*)/,
+ vine: /\/\/vine.co\/v\/(.[a-zA-Z0-9]*)/,
+ vimeo: /\/\/(player.)?vimeo.com\/([a-z]*\/)*([0-9]{6,11})[?]?.*/,
+ dailymotion: /.+dailymotion.com\/(video|hub|embed)\/([^_?]+)[^#]*(#video=([^_&]+))?/,
+ youku: /(.*).youku\.com\/(v_show\/id_|embed\/)(.+)/,
+ };
+ const matches = _.mapObject(regexes, regex => url.match(regex));
+ const autoplay = options.autoplay ? '?autoplay=1&mute=1' : '?autoplay=0';
+ const controls = options.hide_controls ? '&controls=0' : '';
+ const loop = options.loop ? '&loop=1' : '';
+
+ let embedURL;
+ let type;
+ if (matches.youtube && matches.youtube[2].length === 11) {
+ const fullscreen = options.hide_fullscreen ? '&fs=0' : '';
+ const ytLoop = loop ? loop + `&playlist=${matches.youtube[2]}` : '';
+ const logo = options.hide_yt_logo ? '&modestbranding=1' : '';
+ embedURL = `//www.youtube${matches.youtube[1] || ''}.com/embed/${matches.youtube[2]}${autoplay}&rel=0${ytLoop}${controls}${fullscreen}${logo}`;
+ type = 'youtube';
+ } else if (matches.instagram && matches.instagram[2].length) {
+ embedURL = `//www.instagram.com/p/${matches.instagram[2]}/embed/`;
+ type = 'instagram';
+ } else if (matches.vine && matches.vine[0].length) {
+ embedURL = `${matches.vine[0]}/embed/simple`;
+ type = 'vine';
+ } else if (matches.vimeo && matches.vimeo[3].length) {
+ const vimeoAutoplay = autoplay.replace('mute', 'muted');
+ embedURL = `//player.vimeo.com/video/${matches.vimeo[3]}${vimeoAutoplay}${loop}`;
+ type = 'vimeo';
+ } else if (matches.dailymotion && matches.dailymotion[2].length) {
+ const videoId = matches.dailymotion[2].replace('video/', '');
+ const logo = options.hide_dm_logo ? '&ui-logo=0' : '';
+ const share = options.hide_dm_share ? '&sharing-enable=0' : '';
+ embedURL = `//www.dailymotion.com/embed/video/${videoId}${autoplay}${controls}${logo}${share}`;
+ type = 'dailymotion';
+ } else if (matches.youku && matches.youku[3].length) {
+ const videoId = matches.youku[3].indexOf('.html?') >= 0 ? matches.youku[3].substring(0, matches.youku[3].indexOf('.html?')) : matches.youku[3];
+ embedURL = `//player.youku.com/embed/${videoId}`;
+ type = 'youku';
+ }
+
+ return {type: type, embedURL: embedURL};
+ },
+});
+
+return {
+ MediaWidget: MediaWidget,
+ SearchableMediaWidget: SearchableMediaWidget,
+ FileWidget: FileWidget,
+ ImageWidget: ImageWidget,
+ DocumentWidget: DocumentWidget,
+ IconWidget: IconWidget,
+ VideoWidget: VideoWidget,
+};
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js
new file mode 100644
index 00000000..0832aa45
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js
@@ -0,0 +1,279 @@
+odoo.define('wysiwyg.widgets.MediaDialog', function (require) {
+'use strict';
+
+var core = require('web.core');
+var MediaModules = require('wysiwyg.widgets.media');
+var Dialog = require('wysiwyg.widgets.Dialog');
+
+var _t = core._t;
+
+/**
+ * Lets the user select a media. The media can be existing or newly uploaded.
+ *
+ * The media can be one of the following types: image, document, video or
+ * font awesome icon (only existing icons).
+ *
+ * The user may change a media into another one depending on the given options.
+ */
+var MediaDialog = Dialog.extend({
+ template: 'wysiwyg.widgets.media',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/web_editor/static/src/xml/wysiwyg.xml']
+ ),
+ events: _.extend({}, Dialog.prototype.events, {
+ 'click #editor-media-image-tab': '_onClickImageTab',
+ 'click #editor-media-document-tab': '_onClickDocumentTab',
+ 'click #editor-media-icon-tab': '_onClickIconTab',
+ 'click #editor-media-video-tab': '_onClickVideoTab',
+ }),
+ custom_events: _.extend({}, Dialog.prototype.custom_events || {}, {
+ save_request: '_onSaveRequest',
+ show_parent_dialog_request: '_onShowRequest',
+ hide_parent_dialog_request: '_onHideRequest',
+ }),
+
+ /**
+ * @constructor
+ * @param {Element} media
+ */
+ init: function (parent, options, media) {
+ var $media = $(media);
+ media = $media[0];
+ this.media = media;
+
+ options = _.extend({}, options);
+ var onlyImages = options.onlyImages || this.multiImages || (media && ($media.parent().data('oeField') === 'image' || $media.parent().data('oeType') === 'image'));
+ options.noDocuments = onlyImages || options.noDocuments;
+ options.noIcons = onlyImages || options.noIcons;
+ options.noVideos = onlyImages || options.noVideos;
+
+ this._super(parent, _.extend({}, {
+ title: _t("Select a Media"),
+ save_text: _t("Add"),
+ }, options));
+
+ this.trigger_up('getRecordInfo', {
+ recordInfo: options,
+ type: 'media',
+ callback: function (recordInfo) {
+ _.defaults(options, recordInfo);
+ },
+ });
+
+ if (!options.noImages) {
+ this.imageWidget = new MediaModules.ImageWidget(this, media, options);
+ }
+ if (!options.noDocuments) {
+ this.documentWidget = new MediaModules.DocumentWidget(this, media, options);
+ }
+ if (!options.noIcons) {
+ this.iconWidget = new MediaModules.IconWidget(this, media, options);
+ }
+ if (!options.noVideos) {
+ this.videoWidget = new MediaModules.VideoWidget(this, media, options);
+ }
+
+ if (this.imageWidget && $media.is('img')) {
+ this.activeWidget = this.imageWidget;
+ } else if (this.documentWidget && $media.is('a.o_image')) {
+ this.activeWidget = this.documentWidget;
+ } else if (this.videoWidget && $media.is('.media_iframe_video, .o_bg_video_iframe')) {
+ this.activeWidget = this.videoWidget;
+ } else if (this.iconWidget && $media.is('span, i')) {
+ this.activeWidget = this.iconWidget;
+ } else {
+ this.activeWidget = [this.imageWidget, this.documentWidget, this.videoWidget, this.iconWidget].find(w => !!w);
+ }
+ this.initiallyActiveWidget = this.activeWidget;
+ },
+ /**
+ * Adds the appropriate class to the current modal and appends the media
+ * widgets to their respective tabs.
+ *
+ * @override
+ */
+ start: function () {
+ var promises = [this._super.apply(this, arguments)];
+ this.$modal.find('.modal-dialog').addClass('o_select_media_dialog');
+
+ if (this.imageWidget) {
+ promises.push(this.imageWidget.appendTo(this.$("#editor-media-image")));
+ }
+ if (this.documentWidget) {
+ promises.push(this.documentWidget.appendTo(this.$("#editor-media-document")));
+ }
+ if (this.iconWidget) {
+ promises.push(this.iconWidget.appendTo(this.$("#editor-media-icon")));
+ }
+ if (this.videoWidget) {
+ promises.push(this.videoWidget.appendTo(this.$("#editor-media-video")));
+ }
+
+ this.opened(() => this.$('input.o_we_search:visible:first').focus());
+
+ return Promise.all(promises);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns whether the document widget is currently active.
+ *
+ * @returns {boolean}
+ */
+ isDocumentActive: function () {
+ return this.activeWidget === this.documentWidget;
+ },
+ /**
+ * Returns whether the icon widget is currently active.
+ *
+ * @returns {boolean}
+ */
+ isIconActive: function () {
+ return this.activeWidget === this.iconWidget;
+ },
+ /**
+ * Returns whether the image widget is currently active.
+ *
+ * @returns {boolean}
+ */
+ isImageActive: function () {
+ return this.activeWidget === this.imageWidget;
+ },
+ /**
+ * Returns whether the video widget is currently active.
+ *
+ * @returns {boolean}
+ */
+ isVideoActive: function () {
+ return this.activeWidget === this.videoWidget;
+ },
+ /**
+ * Saves the currently selected media from the currently active widget.
+ *
+ * The save event data `final_data` will be one Element in general, but it
+ * will be an Array of Element if `multiImages` is set.
+ *
+ * @override
+ */
+ save: function () {
+ var self = this;
+ var _super = this._super;
+ var args = arguments;
+ return this.activeWidget.save().then(function (data) {
+ if (self.activeWidget !== self.initiallyActiveWidget) {
+ self._clearWidgets();
+ }
+ // Restore classes if the media was replaced (when changing type)
+ if (self.media !== data) {
+ var oldClasses = self.media && _.toArray(self.media.classList);
+ if (oldClasses) {
+ data.className = _.union(_.toArray(data.classList), oldClasses).join(' ');
+ }
+ }
+ self.final_data = data;
+ _super.apply(self, args);
+ $(data).trigger('content_changed');
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Call clear on all the widgets except the activeWidget.
+ * We clear because every widgets are modifying the "media" element.
+ * All widget have the responsibility to clear a previous element that
+ * was created from them.
+ */
+ _clearWidgets: function () {
+ [ this.imageWidget,
+ this.documentWidget,
+ this.iconWidget,
+ this.videoWidget
+ ].forEach( (widget) => {
+ if (widget !== this.activeWidget) {
+ widget && widget.clear();
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the document widget as the active widget.
+ *
+ * @private
+ */
+ _onClickDocumentTab: function () {
+ this.activeWidget = this.documentWidget;
+ },
+ /**
+ * Sets the icon widget as the active widget.
+ *
+ * @private
+ */
+ _onClickIconTab: function () {
+ this.activeWidget = this.iconWidget;
+ },
+ /**
+ * Sets the image widget as the active widget.
+ *
+ * @private
+ */
+ _onClickImageTab: function () {
+ this.activeWidget = this.imageWidget;
+ },
+ /**
+ * Sets the video widget as the active widget.
+ *
+ * @private
+ */
+ _onClickVideoTab: function () {
+ this.activeWidget = this.videoWidget;
+ },
+ /**
+ * Handles hide request from child widgets.
+ *
+ * This is for usability, to allow hiding the modal for example when another
+ * smaller modal would be displayed on top.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onHideRequest: function (ev) {
+ this.$modal.addClass('d-none');
+ },
+ /**
+ * Handles save request from the child widgets.
+ *
+ * This is for usability, to allow the user to save from other ways than
+ * click on the modal button, such as double clicking a media to select it.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSaveRequest: function (ev) {
+ ev.stopPropagation();
+ this.save();
+ },
+ /**
+ * Handles show request from the child widgets.
+ *
+ * This is for usability, it is the counterpart of @see _onHideRequest.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onShowRequest: function (ev) {
+ this.$modal.removeClass('d-none');
+ },
+});
+
+return MediaDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js
new file mode 100644
index 00000000..64a9dc06
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js
@@ -0,0 +1,29 @@
+odoo.define('wysiwyg.widgets', function (require) {
+'use strict';
+
+var Dialog = require('wysiwyg.widgets.Dialog');
+var AltDialog = require('wysiwyg.widgets.AltDialog');
+var MediaDialog = require('wysiwyg.widgets.MediaDialog');
+var LinkDialog = require('wysiwyg.widgets.LinkDialog');
+var ImageCropWidget = require('wysiwyg.widgets.ImageCropWidget');
+const {ColorpickerDialog} = require('web.Colorpicker');
+
+var media = require('wysiwyg.widgets.media');
+
+return {
+ Dialog: Dialog,
+ AltDialog: AltDialog,
+ MediaDialog: MediaDialog,
+ LinkDialog: LinkDialog,
+ ImageCropWidget: ImageCropWidget,
+ ColorpickerDialog: ColorpickerDialog,
+
+ MediaWidget: media.MediaWidget,
+ SearchableMediaWidget: media.SearchableMediaWidget,
+ FileWidget: media.FileWidget,
+ ImageWidget: media.ImageWidget,
+ DocumentWidget: media.DocumentWidget,
+ IconWidget: media.IconWidget,
+ VideoWidget: media.VideoWidget,
+};
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
new file mode 100644
index 00000000..6a1924e1
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
@@ -0,0 +1,274 @@
+odoo.define('web_editor.wysiwyg', function (require) {
+'use strict';
+var Widget = require('web.Widget');
+var SummernoteManager = require('web_editor.rte.summernote');
+var summernoteCustomColors = require('web_editor.rte.summernote_custom_colors');
+var id = 0;
+
+// core.bus
+// media_dialog_demand
+var Wysiwyg = Widget.extend({
+ xmlDependencies: [
+ ],
+ defaultOptions: {
+ 'focus': false,
+ 'toolbar': [
+ ['style', ['style']],
+ ['font', ['bold', 'italic', 'underline', 'clear']],
+ ['fontsize', ['fontsize']],
+ ['color', ['color']],
+ ['para', ['ul', 'ol', 'paragraph']],
+ ['table', ['table']],
+ ['insert', ['link', 'picture']],
+ ['history', ['undo', 'redo']],
+ ],
+ 'styleWithSpan': false,
+ 'inlinemedia': ['p'],
+ 'lang': 'odoo',
+ 'colors': summernoteCustomColors,
+ recordInfo: {
+ context: {},
+ },
+ },
+ /**
+ * @options {Object} options
+ * @options {Object} options.recordInfo
+ * @options {Object} options.recordInfo.context
+ * @options {String} [options.recordInfo.context]
+ * @options {integer} [options.recordInfo.res_id]
+ * @options {String} [options.recordInfo.data_res_model]
+ * @options {integer} [options.recordInfo.data_res_id]
+ * @see _onGetRecordInfo
+ * @see _getAttachmentsDomain in /wysiwyg/widgets/media.js
+ * @options {Object} options.attachments
+ * @see _onGetRecordInfo
+ * @see _getAttachmentsDomain in /wysiwyg/widgets/media.js (for attachmentIDs)
+ * @options {function} options.generateOptions
+ * called with the summernote configuration object used before sending to summernote
+ * @see _editorOptions
+ **/
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.id = ++id;
+ this.options = options;
+ },
+ /**
+ * Load assets and color picker template then call summernote API
+ * and replace $el by the summernote editable node.
+ *
+ * @override
+ **/
+ willStart: function () {
+ this._summernoteManager = new SummernoteManager(this);
+ this.$target = this.$el;
+ return this._super();
+ },
+ /**
+ *
+ * @override
+ */
+ start: function () {
+ this.$target.wrap('<odoo-wysiwyg-container>');
+ this.$el = this.$target.parent();
+ var options = this._editorOptions();
+ this.$target.summernote(options);
+ this.$editor = this.$('.note-editable:first');
+ this.$editor.data('wysiwyg', this);
+ this.$editor.data('oe-model', options.recordInfo.res_model);
+ this.$editor.data('oe-id', options.recordInfo.res_id);
+ $(document).on('mousedown', this._blur);
+ this._value = this.$target.html() || this.$target.val();
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ $(document).off('mousedown', this._blur);
+ if (this.$target && this.$target.is('textarea') && this.$target.next('.note-editor').length) {
+ this.$target.summernote('destroy');
+ }
+ this._super();
+ },
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * Return the editable area.
+ *
+ * @returns {jQuery}
+ */
+ getEditable: function () {
+ return this.$editor;
+ },
+ /**
+ * Return true if the content has changed.
+ *
+ * @returns {Boolean}
+ */
+ isDirty: function () {
+ return this._value !== (this.$editor.html() || this.$editor.val());
+ },
+ /**
+ * Set the focus on the element.
+ */
+ focus: function () {
+ console.log('focus');
+ },
+ /**
+ * Get the value of the editable element.
+ *
+ * @param {object} [options]
+ * @param {jQueryElement} [options.$layout]
+ * @returns {String}
+ */
+ getValue: function (options) {
+ var $editable = options && options.$layout || this.$editor.clone();
+ $editable.find('[contenteditable]').removeAttr('contenteditable');
+ $editable.find('[class=""]').removeAttr('class');
+ $editable.find('[style=""]').removeAttr('style');
+ $editable.find('[title=""]').removeAttr('title');
+ $editable.find('[alt=""]').removeAttr('alt');
+ $editable.find('[data-original-title=""]').removeAttr('data-original-title');
+ if (!options || !options['style-inline']) {
+ $editable.find('a.o_image, span.fa, i.fa').html('');
+ }
+ $editable.find('[aria-describedby]').removeAttr('aria-describedby').removeAttr('data-original-title');
+ return $editable.html();
+ },
+ /**
+ * Save the content in the target
+ * - in init option beforeSave
+ * - receive editable jQuery DOM as attribute
+ * - called after deactivate codeview if needed
+ * @returns {Promise}
+ * - resolve with true if the content was dirty
+ */
+ save: function (options) {
+ var isDirty = this.isDirty();
+ var html = this.getValue(options);
+ if (this.$target.is('textarea')) {
+ this.$target.val(html);
+ } else {
+ this.$target.html(html);
+ }
+ return Promise.resolve({isDirty:isDirty, html:html});
+ },
+ /**
+ * Create/Update cropped attachments.
+ *
+ * @param {jQuery} $editable
+ * @returns {Promise}
+ */
+ saveModifiedImages: function ($editable) {
+ return this._summernoteManager.saveModifiedImages($editable);
+ },
+ /**
+ * @param {String} value
+ * @param {Object} options
+ * @param {Boolean} [options.notifyChange]
+ * @returns {String}
+ */
+ setValue: function (value, options) {
+ if (this.$editor.is('textarea')) {
+ this.$target.val(value);
+ } else {
+ this.$target.html(value);
+ }
+ this.$editor.html(value);
+ },
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+ _editorOptions: function () {
+ var self = this;
+ var options = Object.assign({}, $.summernote.options, this.defaultOptions, this.options);
+ if (this.options.generateOptions) {
+ options = this.options.generateOptions(options);
+ }
+ options.airPopover = options.toolbar;
+ options.onChange = function (html, $editable) {
+ $editable.trigger('content_changed');
+ self.trigger_up('wysiwyg_change');
+ };
+ options.onUpload = function (attachments) {
+ self.trigger_up('wysiwyg_attachment', attachments);
+ };
+ options.onFocus = function () {
+ self.trigger_up('wysiwyg_focus');
+ };
+ options.onBlur = function () {
+ self.trigger_up('wysiwyg_blur');
+ };
+ return options;
+ },
+});
+//--------------------------------------------------------------------------
+// Public helper
+//--------------------------------------------------------------------------
+/**
+ * @param {Node} node (editable or node inside)
+ * @returns {Object}
+ * @returns {Node} sc - start container
+ * @returns {Number} so - start offset
+ * @returns {Node} ec - end container
+ * @returns {Number} eo - end offset
+ */
+Wysiwyg.getRange = function (node) {
+ var range = $.summernote.core.range.create();
+ return range && {
+ sc: range.sc,
+ so: range.so,
+ ec: range.ec,
+ eo: range.eo,
+ };
+};
+/**
+ * @param {Node} startNode
+ * @param {Number} startOffset
+ * @param {Node} endNode
+ * @param {Number} endOffset
+ */
+Wysiwyg.setRange = function (startNode, startOffset, endNode, endOffset) {
+ $(startNode).focus();
+ if (endNode) {
+ $.summernote.core.range.create(startNode, startOffset, endNode, endOffset).select();
+ } else {
+ $.summernote.core.range.create(startNode, startOffset).select();
+ }
+ // trigger for Unbreakable
+ $(startNode.tagName ? startNode : startNode.parentNode).trigger('wysiwyg.range');
+};
+/**
+ * @param {Node} node - dom node
+ * @param {Object} [options]
+ * @param {Boolean} options.begin move the range to the beginning of the first node.
+ * @param {Boolean} options.end move the range to the end of the last node.
+ */
+Wysiwyg.setRangeFromNode = function (node, options) {
+ var last = node;
+ while (last.lastChild) {
+ last = last.lastChild;
+ }
+ var first = node;
+ while (first.firstChild) {
+ first = first.firstChild;
+ }
+ if (options && options.begin && !options.end) {
+ Wysiwyg.setRange(first, 0);
+ } else if (options && !options.begin && options.end) {
+ Wysiwyg.setRange(last, last.textContent.length);
+ } else {
+ Wysiwyg.setRange(first, 0, last, last.tagName ? last.childNodes.length : last.textContent.length);
+ }
+};
+return Wysiwyg;
+});
+odoo.define('web_editor.widget', function (require) {
+'use strict';
+ return {
+ Dialog: require('wysiwyg.widgets.Dialog'),
+ MediaDialog: require('wysiwyg.widgets.MediaDialog'),
+ LinkDialog: require('wysiwyg.widgets.LinkDialog'),
+ };
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js
new file mode 100644
index 00000000..f56f5b26
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js
@@ -0,0 +1,132 @@
+odoo.define('web_editor.wysiwyg.iframe', function (require) {
+'use strict';
+
+var Wysiwyg = require('web_editor.wysiwyg');
+var ajax = require('web.ajax');
+var core = require('web.core');
+var config = require('web.config');
+
+var qweb = core.qweb;
+var promiseCommon;
+var promiseWysiwyg;
+
+
+/**
+ * Add option (inIframe) to load Wysiwyg in an iframe.
+ **/
+Wysiwyg.include({
+ /**
+ * Add options to load Wysiwyg in an iframe.
+ *
+ * @override
+ * @param {boolean} options.inIframe
+ **/
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ if (this.options.inIframe) {
+ this._onUpdateIframeId = 'onLoad_' + this.id;
+ }
+ this.__extraAssetsForIframe = [];
+ },
+ /**
+ * Load assets to inject into iframe.
+ *
+ * @override
+ **/
+ willStart: function () {
+ if (!this.options.inIframe) {
+ return this._super();
+ }
+
+ var defAsset;
+ if (this.options.iframeCssAssets) {
+ defAsset = ajax.loadAsset(this.options.iframeCssAssets);
+ } else {
+ defAsset = Promise.resolve({
+ cssLibs: [],
+ cssContents: []
+ });
+ }
+
+ promiseWysiwyg = promiseWysiwyg || ajax.loadAsset('web_editor.wysiwyg_iframe_editor_assets');
+ this.defAsset = Promise.all([promiseWysiwyg, defAsset]);
+
+ this.$target = this.$el;
+ return this.defAsset
+ .then(this._loadIframe.bind(this))
+ .then(this._super.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Create iframe, inject css and create a link with the content,
+ * then inject the target inside.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _loadIframe: function () {
+ var self = this;
+ this.$iframe = $('<iframe class="wysiwyg_iframe">').css({
+ 'min-height': '55vh',
+ width: '100%'
+ });
+ var avoidDoubleLoad = 0; // this bug only appears on some configurations.
+
+ // resolve promise on load
+ var def = new Promise(function (resolve) {
+ window.top[self._onUpdateIframeId] = function (Editor, _avoidDoubleLoad) {
+ if (_avoidDoubleLoad !== avoidDoubleLoad) {
+ console.warn('Wysiwyg iframe double load detected');
+ return;
+ }
+ delete window.top[self._onUpdateIframeId];
+ var $iframeTarget = self.$iframe.contents().find('#iframe_target');
+ $iframeTarget.attr("isMobile", config.device.isMobile);
+ $iframeTarget.find('.o_editable').html(self.$target.val());
+ self.options.toolbarHandler = $('#web_editor-top-edit', self.$iframe[0].contentWindow.document);
+ $(qweb.render('web_editor.FieldTextHtml.fullscreen'))
+ .appendTo(self.options.toolbarHandler)
+ .on('click', '.o_fullscreen', function () {
+ $("body").toggleClass("o_field_widgetTextHtml_fullscreen");
+ var full = $("body").hasClass("o_field_widgetTextHtml_fullscreen");
+ self.$iframe.parents().toggleClass('o_form_fullscreen_ancestor', full);
+ $(window).trigger("resize"); // induce a resize() call and let other backend elements know (the navbar extra items management relies on this)
+ });
+ self.Editor = Editor;
+ resolve();
+ };
+ });
+ this.$iframe.data('loadDef', def); // for unit test
+
+ // inject content in iframe
+
+ this.$iframe.on('load', function onLoad (ev) {
+ var _avoidDoubleLoad = ++avoidDoubleLoad;
+ self.defAsset.then(function (assets) {
+ if (_avoidDoubleLoad !== avoidDoubleLoad) {
+ console.warn('Wysiwyg immediate iframe double load detected');
+ return;
+ }
+
+ var iframeContent = qweb.render('wysiwyg.iframeContent', {
+ assets: assets.concat(self.__extraAssetsForIframe),
+ updateIframeId: self._onUpdateIframeId,
+ avoidDoubleLoad: _avoidDoubleLoad
+ });
+ self.$iframe[0].contentWindow.document
+ .open("text/html", "replace")
+ .write(iframeContent);
+ });
+ });
+
+ this.$iframe.insertAfter(this.$target);
+
+ return def;
+ },
+});
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_snippets.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_snippets.js
new file mode 100644
index 00000000..73801dc9
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_snippets.js
@@ -0,0 +1,56 @@
+odoo.define('web_editor.wysiwyg.snippets', function (require) {
+'use strict';
+var editor = require('web_editor.editor');
+var Wysiwyg = require('web_editor.wysiwyg');
+
+
+Wysiwyg.include({
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.Editor = editor.Class;
+ if (!this.options.toolbarHandler) {
+ this.options.toolbarHandler = $('#web_editor-top-edit');
+ }
+ },
+ start: async function () {
+ if (this.options.snippets) {
+ var self = this;
+ this.editor = new (this.Editor)(this, this.options);
+ this.$editor = this.editor.rte.editable();
+ const $body = this.$editor[0] ? this.$editor[0].ownerDocument.body : document.body;
+ await this.editor.prependTo($body);
+ this._relocateEditorBar();
+ this.$el.on('content_changed', function (e) {
+ self.trigger_up('wysiwyg_change');
+ });
+ } else {
+ return this._super();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _relocateEditorBar: function () {
+ if (!this.options.toolbarHandler.length) {
+ this.options.toolbarHandler = $('.o_we_snippet_text_tools');
+ }
+ this.options.toolbarHandler.append(this.editor.$el);
+
+ // TODO the next four lines are a huge hack: since the editor.$el
+ // is repositioned, the snippetsMenu elements are not at the
+ // correct position anymore if it was repositioned outside of it...
+ // the whole logic has to be refactored... hopefully not needed anymore
+ // with editor team changes
+ if (this.editor.snippetsMenu && !this.editor.snippetsMenu.$el.has(this.options.toolbarHandler).length) {
+ this.editor.snippetsMenu.$el.insertAfter(this.options.toolbarHandler);
+ this.editor.snippetsMenu.$snippetEditorArea.insertAfter(this.editor.snippetsMenu.$el);
+ }
+ },
+});
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_translate_attributes.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_translate_attributes.js
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_translate_attributes.js
diff --git a/addons/web_editor/static/src/scss/13_0_color_system_support_primary_variables.scss b/addons/web_editor/static/src/scss/13_0_color_system_support_primary_variables.scss
new file mode 100644
index 00000000..4fb5d42c
--- /dev/null
+++ b/addons/web_editor/static/src/scss/13_0_color_system_support_primary_variables.scss
@@ -0,0 +1 @@
+$o-support-13-0-color-system: true;
diff --git a/addons/web_editor/static/src/scss/bootstrap_overridden.scss b/addons/web_editor/static/src/scss/bootstrap_overridden.scss
new file mode 100644
index 00000000..f48d9bda
--- /dev/null
+++ b/addons/web_editor/static/src/scss/bootstrap_overridden.scss
@@ -0,0 +1,76 @@
+// Use auto threshold for yiq colors
+$yiq-contrasted-threshold: false !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-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: () !default;
+$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);
+}
+
+// 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 gray('100') is blue but $gray-100 is still gray... so BS4 is not
+// correctly generated as BS4 uses $gray-100 in _variables.scss
+$primary: theme-color('primary') !default;
+$secondary: theme-color('secondary') !default;
+$success: theme-color('success') !default;
+$info: theme-color('info') !default;
+$warning: theme-color('warning') !default;
+$danger: theme-color('danger') !default;
+$light: theme-color('light') !default;
+$dark: theme-color('dark') !default;
+
+$white: gray('white') !default;
+$gray-100: gray('100') !default;
+$gray-200: gray('200') !default;
+$gray-300: gray('300') !default;
+$gray-400: gray('400') !default;
+$gray-500: gray('500') !default;
+$gray-600: gray('600') !default;
+$gray-700: gray('700') !default;
+$gray-800: gray('800') !default;
+$gray-900: gray('900') !default;
+$black: gray('black') !default;
+
+$o-color-system-initialized: true;
diff --git a/addons/web_editor/static/src/scss/bootstrap_overridden_backend.scss b/addons/web_editor/static/src/scss/bootstrap_overridden_backend.scss
new file mode 100644
index 00000000..ef2d6cf0
--- /dev/null
+++ b/addons/web_editor/static/src/scss/bootstrap_overridden_backend.scss
@@ -0,0 +1,14 @@
+
+$o-theme-color-palette: map-remove($o-theme-color-palette, 'primary', 'secondary', 'success', 'info', 'warning', 'danger', 'light', 'dark');
+$o-gray-color-palette: map-remove($o-gray-color-palette, '100', '200', '300', '400', '500', '600', '700', '800', '900');
+
+$o-btn-bg-colors: () !default;
+$o-btn-bg-colors: map-merge((
+ 'primary': null,
+ 'secondary': null,
+), $o-btn-bg-colors);
+$o-btn-border-colors: () !default;
+$o-btn-border-colors: map-merge((
+ 'primary': null,
+ 'secondary': null,
+), $o-btn-border-colors);
diff --git a/addons/web_editor/static/src/scss/secondary_variables.scss b/addons/web_editor/static/src/scss/secondary_variables.scss
new file mode 100644
index 00000000..815e2c72
--- /dev/null
+++ b/addons/web_editor/static/src/scss/secondary_variables.scss
@@ -0,0 +1,137 @@
+
+//------------------------------------------------------------------------------
+// Colors
+//------------------------------------------------------------------------------
+
+// Color combinations
+$o-color-combinations: o-safe-nth($o-color-combinations-presets, $o-color-combinations-preset-number, ()) !default;
+$-combination-additions: ();
+@for $index from 1 through length($o-color-combinations) {
+ $combination: map-merge($o-base-color-combination, nth($o-color-combinations, $index));
+
+ @each $element, $color in $combination {
+ $-combination-additions: map-merge($-combination-additions, (
+ 'o-cc#{$index}-#{$element}': $color,
+ ));
+ }
+}
+
+// Colors
+$o-color-palette: o-safe-nth($o-color-palettes, $o-color-palette-number, ()) !default;
+// Original color palette can contain override of the default combinations (so keep 'null' values for this merge)
+$o-color-palette: map-merge($-combination-additions, $o-color-palette);
+$o-color-palette: map-merge($o-base-color-palette, o-map-omit($o-color-palette));
+
+// Theme colors
+$o-theme-color-palette: o-safe-nth($o-theme-color-palettes, $o-theme-color-palette-number, ()) !default;
+@if not $o-support-13-0-color-system {
+ $o-theme-color-palette: map-remove($o-theme-color-palette, 'alpha', 'beta', 'gamma', 'delta', 'epsilon');
+}
+$-main-color: map-get($o-color-palette, 'o-color-1');
+$-main-color-lightness: lightness($-main-color);
+$o-theme-color-palette: map-merge((
+ // color 1 and 2 are used to override primary and secondary BS4
+ // colors by default, so that theme colors affect the default Odoo layouts
+ 'primary': $-main-color,
+ 'secondary': map-get($o-color-palette, 'o-color-2'),
+
+ // BS light and dark colors are not used for any BS component, just
+ // for color utilities. By default, we set them to a very light and
+ // very dark version of a desaturate version of the main color
+ 'light': lighten(desaturate($-main-color, 80%), min(70%, max(0%, 97% - $-main-color-lightness))), // Does not increase over 97% lightness
+ 'dark': darken(desaturate($-main-color, 80%), min(70%, max(0%, $-main-color-lightness - 10%))), // Does not lower under 10% lightness
+), o-map-omit($o-theme-color-palette));
+$o-theme-color-palette: map-merge($o-base-theme-color-palette, o-map-omit($o-theme-color-palette));
+
+// Gray colors
+// Extend grays with transparent ones (for some reason, BS4 create black-50 and
+// white-50 but does not allow overridding that with variables).
+$o-gray-color-palette: o-safe-nth($o-gray-color-palettes, $o-gray-color-palette-number, ()) !default;
+$o-gray-color-palette: map-merge($o-transparent-grays, o-map-omit($o-gray-color-palette));
+$o-gray-color-palette: map-merge($o-base-gray-color-palette, o-map-omit($o-gray-color-palette));
+
+$o-color-system-initialized: false;
+
+// Returns:
+// - true if the given name is a css color or null
+// - false if a potential valid color name
+// - throws an error if the given arg cannot reference a color
+@function check-color-identifier-type($name) {
+ $-type: type-of($name);
+ @if $-type == 'color' or $-type == 'null' {
+ @return true;
+ } @else if $-type != 'string' {
+ @error "Color name '#{$name}' is of unsupported type '#{$-type}'";
+ }
+ @return false;
+}
+@function use-cc-bg($name) {
+ @if type-of($name) == 'number' {
+ // Preset number, let's return the background color of the related
+ // preset.
+ @return 'o-cc#{$name}-bg';
+ }
+ @return $name;
+}
+// Looks up for the color related to the given name in the related odoo palettes
+// following redirection a maximum number of time (by default none).
+@function o-related-color($name, $max-recursions: 0, $original-name: $name, $use-cc-bg: false) {
+ @if $use-cc-bg {
+ $name: use-cc-bg($name);
+ } @else if type-of($name) == 'number' {
+ @return $name;
+ }
+
+ @if $max-recursions < 0 or check-color-identifier-type($name) {
+ @return $name;
+ }
+
+ $-value: null;
+ @if map-has-key($o-color-palette, $name) {
+ $-value: map-get($o-color-palette, $name);
+ } @else if map-has-key($o-theme-color-palette, $name) {
+ $-value: map-get($o-theme-color-palette, $name);
+ } @else if map-has-key($o-gray-color-palette, $name) {
+ $-value: map-get($o-gray-color-palette, $name);
+ }
+ @return o-related-color($-value, $max-recursions - 1, $original-name);
+}
+// Function which allows to retrieve a color value from a name, the color being
+// either in $theme-colors, $grays or $colors maps. If those maps are not
+// initialized yet, it will look up the color in the related odoo palettes.
+@function o-color($name) {
+ $name: use-cc-bg($name);
+
+ @if check-color-identifier-type($name) {
+ @return $name;
+ }
+
+ // When the system is initialized, it means that the bootstrap maps have
+ // been configured and contain a direct mapping between color name -> css
+ // value. We can thus search in those.
+ @if $o-color-system-initialized {
+ @if map-has-key($colors, $name) {
+ @return color($name);
+ }
+ @if map-has-key($theme-colors, $name) {
+ @return theme-color($name);
+ }
+ @if map-has-key($grays, $name) {
+ @return gray($name);
+ }
+ }
+
+ // If not initialized, search the css color value in selected color palettes
+ @return o-related-color($name, $max-recursions: 10, $use-cc-bg: true);
+}
+
+// Same as 'increase-contrast' except that the color is not changed if the given
+// related color name is part of the given exclusion list (default to a global
+// exclusion list which can be extended by other apps).
+$o-we-auto-contrast-exclusions: () !default;
+@function auto-contrast($color1, $color2, $color1-name, $exclude: $o-we-auto-contrast-exclusions) {
+ @if index($exclude, $color1-name) {
+ @return $color1;
+ }
+ @return increase-contrast($color1, $color2);
+}
diff --git a/addons/web_editor/static/src/scss/web_editor.backend.scss b/addons/web_editor/static/src/scss/web_editor.backend.scss
new file mode 100644
index 00000000..370baed9
--- /dev/null
+++ b/addons/web_editor/static/src/scss/web_editor.backend.scss
@@ -0,0 +1,69 @@
+.oe_form_field_html {
+ position: relative;
+ word-wrap: break-word;
+
+ .note-editable {
+ min-height: 330px;
+ font: inherit !important;
+ font-family: inherit !important;
+ line-height: inherit !important;
+ color: inherit !important;
+ overflow: visible;
+
+ p, div {
+ font-family: 'Lucida Grande', Helvetica, Verdana, Arial, sans-serif;
+ font-size: 13px;
+ }
+ }
+ ul > li > p, p {
+ margin: 0px;
+ }
+ > iframe {
+ display: block;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ ul > li > p {
+ margin: 0px;
+ }
+ min-height: 300px;
+ min-height: -webkit-calc(100vh - 170px);
+ min-height: calc(100vh - 170px);
+
+ &.o_readonly {
+ border: none;
+ }
+ }
+ .rounded {
+ border-radius: .25rem !important;
+ }
+ table.table.table-bordered {
+ table-layout: fixed;
+ }
+}
+
+.o_field_widgetTextHtml_fullscreen {
+ .oe_form_field_html.o_form_fullscreen_ancestor iframe {
+ position: absolute !important;
+ left: 0 !important;
+ right: 0 !important;
+ top: 0 !important;
+ bottom: 0 !important;
+ width: 100% !important;
+ min-height: 100% !important;
+ z-index: 1001 !important;
+ border: 0;
+ }
+ > :not(.modal):not(.modal-backdrop) {
+ display: none;
+ }
+ .o_form_fullscreen_ancestor {
+ display: block !important;
+ position: static !important;
+ top: 0 !important;
+ left: 0 !important;
+ width: auto !important;
+ overflow: hidden !important;
+ transform: none !important;
+ }
+}
diff --git a/addons/web_editor/static/src/scss/web_editor.common.scss b/addons/web_editor/static/src/scss/web_editor.common.scss
new file mode 100644
index 00000000..5b261c2f
--- /dev/null
+++ b/addons/web_editor/static/src/scss/web_editor.common.scss
@@ -0,0 +1,782 @@
+///
+/// This file regroups basic style rules for web_editor enable page edition and backend utils.
+///
+
+:root {
+ @each $color, $value in $grays {
+ @include print-variable($color, $value);
+ }
+
+ // Most of the keys of the color combination color should be null. We have
+ // to indicate their fallback values.
+ @for $index from 1 through length($o-color-combinations) {
+ $-bg-color: o-color(color('o-cc#{$index}-bg'));
+
+ $-text: color('o-cc#{$index}-text') or color-yiq(o-color('o-cc#{$index}-bg'));
+ $-headings: color('o-cc#{$index}-headings') or $-text;
+ $-h2: color('o-cc#{$index}-h2') or $-headings;
+ $-h3: color('o-cc#{$index}-h3') or $-h2;
+ $-h4: color('o-cc#{$index}-h4') or $-h3;
+ $-h5: color('o-cc#{$index}-h5') or $-h4;
+ $-h6: color('o-cc#{$index}-h6') or $-h5;
+
+ @if not color('o-cc#{$index}-text') {
+ @include print-variable('o-cc#{$index}-text', $-text);
+ }
+ @if not color('o-cc#{$index}-headings') {
+ @include print-variable('o-cc#{$index}-headings', $-headings);
+ }
+ @if not color('o-cc#{$index}-h2') {
+ @include print-variable('o-cc#{$index}-h2', $-h2);
+ }
+ @if not color('o-cc#{$index}-h3') {
+ @include print-variable('o-cc#{$index}-h3', $-h3);
+ }
+ @if not color('o-cc#{$index}-h4') {
+ @include print-variable('o-cc#{$index}-h4', $-h4);
+ }
+ @if not color('o-cc#{$index}-h5') {
+ @include print-variable('o-cc#{$index}-h5', $-h5);
+ }
+ @if not color('o-cc#{$index}-h6') {
+ @include print-variable('o-cc#{$index}-h6', $-h6);
+ }
+
+ $-link: color('o-cc#{$index}-link');
+ $-link-color: if($-link, o-color($-link), theme-color('primary'));
+ @include print-variable('o-cc#{$index}-link', auto-contrast($-link-color, $-bg-color, 'o-cc#{$index}-link'));
+
+ $-btn-primary: color('o-cc#{$index}-btn-primary');
+ @if not $-btn-primary {
+ @include print-variable('o-cc#{$index}-btn-primary', theme-color('primary'));
+ }
+ @if not color('o-cc#{$index}-btn-primary-border') {
+ @include print-variable('o-cc#{$index}-btn-primary-border', $-btn-primary or theme-color('primary'));
+ }
+
+ $-btn-secondary: color('o-cc#{$index}-btn-secondary');
+ @if not $-btn-secondary {
+ @include print-variable('o-cc#{$index}-btn-secondary', theme-color('secondary'));
+ }
+ @if not color('o-cc#{$index}-btn-secondary-border') {
+ @include print-variable('o-cc#{$index}-btn-secondary-border', $-btn-secondary or theme-color('secondary'));
+ }
+ }
+}
+
+html, body {
+ position: relative;
+ width: 100%;
+ height: 100%;
+}
+
+.css_non_editable_mode_hidden {
+ display: none !important;
+}
+.editor_enable .css_editable_mode_hidden {
+ display: none !important;
+}
+.note-toolbar {
+ margin-left: 0 !important;
+}
+.note-popover .popover > .arrow {
+ display: none;
+}
+
+.note-popover .popover, .note-editor {
+ .dropdown-menu .dropdown-item {
+ > i {
+ visibility: hidden;
+ }
+ &.checked > i {
+ visibility: visible;
+ }
+ }
+}
+
+/* ----- GENERIC LAYOUTING HELPERS ---- */
+/* table */
+#wrapwrap, .o_editable {
+ // Only style editor-made tables (shop/portal/... tables are not supposed to
+ // use table-bordered...)
+ table.table.table-bordered {
+ table-layout: fixed;
+ td {
+ min-width: 20px;
+ }
+ }
+ @include media-breakpoint-down(sm) {
+ .table-responsive > table.table {
+ table-layout: auto;
+ }
+ }
+}
+
+// List
+ul.o_checklist {
+ list-style: none;
+
+ >li {
+ position: relative;
+ margin-left: $o-checklist-margin-left;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: - $o-checklist-margin-left;
+ display: block;
+ height: $o-checklist-before-size;
+ width: $o-checklist-before-size;
+ margin-top: 4px;
+ border: 1px solid;
+ text-align: center;
+ cursor: pointer;
+ }
+ &.o_checked {
+ text-decoration: line-through;
+ &::after {
+ content: "✓";
+ position: absolute;
+ left: - ($o-checklist-margin-left - $o-checklist-checkmark-width);
+ top: +1px;
+ }
+ }
+ }
+}
+ol > li.o_indent, ul > li.o_indent {
+ margin-left: 0;
+ list-style: none;
+ &::before {
+ content: none;
+ }
+}
+
+// Medias
+img.o_we_custom_image {
+ // Images added with the editor are .img-fluid by default but should
+ // still behave like inline content.
+ display: inline-block;
+}
+
+img.shadow {
+ box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2);
+}
+img.padding-small, .img.padding-small, span.fa.padding-small, iframe.padding-small {
+ padding: 4px;
+}
+img.padding-medium, .img.padding-medium, span.fa.padding-medium, iframe.padding-medium {
+ padding: 8px;
+}
+img.padding-large, .img.padding-large, span.fa.padding-large, iframe.padding-large {
+ padding: 16px;
+}
+img.padding-xl, .img.padding-xl, span.fa.padding-xl, iframe.padding-xl {
+ padding: 32px;
+}
+img.ml-auto, img.mx-auto {
+ display: block;
+}
+
+.fa-6x {
+ font-size: 6em;
+}
+.fa-7x {
+ font-size: 7em;
+}
+.fa-8x {
+ font-size: 8em;
+}
+.fa-9x {
+ font-size: 9em;
+}
+.fa-10x {
+ font-size: 10em;
+}
+.fa.mx-auto {
+ display: block;
+ text-align: center;
+}
+
+div.media_iframe_video {
+ margin: 0 auto;
+ text-align: center;
+ position: relative;
+ overflow: hidden;
+ min-width: 100px;
+
+ iframe {
+ width: 100%;
+ height: 100%;
+ @include o-position-absolute($top: 0);
+ margin: 0 auto;
+ margin-left: -50%;
+ }
+ &.padding-small iframe {
+ padding: 4px;
+ }
+ &.padding-medium iframe {
+ padding: 8px;
+ }
+ &.padding-large iframe {
+ padding: 16px;
+ }
+ &.padding-xl iframe {
+ padding: 32px;
+ }
+
+ .media_iframe_video_size {
+ padding-bottom: 66.5%;
+ position: relative;
+ width: 100%;
+ height: 0;
+ }
+
+ .css_editable_mode_display {
+ @include o-position-absolute(0,0,0,0);
+ width: 100%;
+ height: 100%;
+ display: none;
+ z-index: 2;
+ }
+}
+
+html[data-browser^="msie"] div.media_iframe_video iframe {
+ margin-left: 0;
+}
+
+// Fields
+address {
+ .fa.fa-mobile-phone {
+ margin: 0 3px 0 2px;
+ }
+ .fa.fa-file-text-o {
+ margin-right: 1px;
+ }
+}
+
+span[data-oe-type="monetary"] {
+ white-space: nowrap;
+}
+
+// Menus
+// TODO should not be here but used by web_studio so must stay here for now
+ul.oe_menu_editor {
+ .oe_menu_placeholder {
+ outline: 1px dashed #4183C4;
+ }
+ ul {
+ list-style: none;
+ }
+ li div {
+ cursor: move;
+ }
+}
+
+// Generate all spacings for all sizes
+@mixin o-spacing-all($factor: 1) {
+ // Generate vertical margin/padding classes used by the editor
+ @for $i from 0 through (256 / 8) {
+ @include o-vspacing($i * 8, $factor);
+ }
+ @include o-vspacing(4, $factor);
+
+ // 92px vertical margin is kept for compatibility
+ @include o-vmargins(92, $factor);
+
+ // Some horizontal margin classes defined for convenience
+ // (and compatibility)
+ @include o-hmargins(0, $factor);
+ @include o-hmargins(4, $factor);
+ @include o-hmargins(8, $factor);
+ @include o-hmargins(16, $factor);
+ @include o-hmargins(32, $factor);
+ @include o-hmargins(64, $factor);
+}
+
+// Generate all spacings for one size, scalled by a given factor
+// (0 <= factor <= 1)
+@mixin o-vspacing($name, $factor: 1) {
+ @include o-vmargins($name, $factor);
+ @include o-vpaddings($name, $factor);
+}
+@mixin o-vmargins($name, $factor: 1) {
+ @include o-vmargins-define($name, $factor * $name);
+}
+@mixin o-vpaddings($name, $factor: 1) {
+ @include o-vpaddings-define($name, $factor * $name);
+}
+@mixin o-hspacing($name, $factor: 1) {
+ @include o-hmargins($name, $factor);
+ @include o-hpaddings($name, $factor);
+}
+@mixin o-hmargins($name, $factor: 1) {
+ @include o-hmargins-define($name, $factor * $name);
+}
+@mixin o-hpaddings($name, $factor: 1) {
+ @include o-hpaddings-define($name, $factor * $name);
+}
+
+// Generate all spacings for one size, given the name of the spacing and
+// intended size
+@mixin o-vmargins-define($name, $size: $name) {
+ .mt#{$name} { margin-top: $size * 1px !important; }
+ .mb#{$name} { margin-bottom: $size * 1px !important; }
+}
+@mixin o-vpaddings-define($name, $size: $name) {
+ .pt#{$name} { padding-top: $size * 1px !important; }
+ .pb#{$name} { padding-bottom: $size * 1px !important; }
+}
+@mixin o-hmargins-define($name, $size: $name) {
+ .ml#{$name} { margin-left: $size * 1px !important; }
+ .mr#{$name} { margin-right: $size * 1px !important; }
+}
+@mixin o-hpaddings-define($name, $size: $name) {
+ .pl#{$name} { padding-left: $size * 1px !important; }
+ .pr#{$name} { padding-right: $size * 1px !important; }
+}
+
+// Generate all margins
+@include o-spacing-all;
+
+// Underline
+a.o_underline {
+ text-decoration: underline;
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+// ACE EDITOR
+.o_ace_view_editor {
+ background: $o-we-ace-color;
+ color: white;
+ display: flex;
+ flex-flow: column nowrap;
+ opacity: 0.97;
+
+ .o_ace_view_editor_title {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ padding: $grid-gutter-width/4;
+
+ >.o_ace_type_switcher>button::after {
+ @include o-caret-down;
+ margin-left: 4px;
+ }
+
+ >* {
+ flex: 0 0 auto;
+ margin: 0 $grid-gutter-width/4;
+
+ &.o_include_option {
+ display: flex;
+ align-items: center;
+ font-size: 11px;
+
+ >.custom-control {
+ margin-right: $grid-gutter-width/4;
+ }
+ }
+
+ &.o_res_list {
+ flex: 1 1 auto;
+ min-width: 60px;
+ }
+ }
+ }
+
+ #ace-view-id {
+ flex: 0 0 auto;
+ padding: $grid-gutter-width/4 $grid-gutter-width/2;
+ background-color: lighten($o-we-ace-color, 10%);
+
+ .o_ace_editor_resource_info {
+ color: #ebecee;
+ }
+ }
+
+ #ace-view-editor {
+ @mixin ace-line-error-mixin {
+ content: "";
+ z-index: 1000;
+ display: block;
+ background-color: theme-color('danger');
+ opacity: 0.5;
+ pointer-events: none;
+ }
+
+ height: 70%; // in case flex is not supported
+ flex: 1 1 auto;
+
+ .ace_gutter {
+ cursor: ew-resize;
+
+ .ace_gutter-cell.o_error {
+ position: relative;
+
+ &::after {
+ @include o-position-absolute(-100%, 0, -100%, 0);
+ @include ace-line-error-mixin;
+ }
+ }
+ }
+
+ .ace_resize_bar {
+ @include o-position-absolute($right: 0);
+ width: 25px;
+ height: 100%;
+ cursor: ew-resize;
+ }
+
+ .ace_scroller.o_error::after {
+ @include o-position-absolute(0, auto, 0, 0);
+ width: 3px;
+ @include ace-line-error-mixin;
+ }
+ }
+}
+
+.ace_editor > .ace_gutter {
+ display: block !important; // display even with aria-hidden
+}
+
+.o_ace_select2_dropdown {
+ width: auto !important;
+ padding-top: 4px;
+ font-family: monospace !important;
+
+ >.select2-results {
+ max-height: none;
+ max-height: 70vh;
+
+ .select2-result-label {
+ padding-top: 1px;
+ padding-bottom: 2px;
+
+ >.o_ace_select2_result {
+ padding: 0;
+ font-size: 12px;
+ white-space: nowrap;
+ }
+ }
+ }
+}
+
+.o_nocontent_help {
+ @include o-nocontent-empty;
+
+ .o_empty_folder_image:before {
+ @extend %o-nocontent-empty-document;
+ }
+}
+
+.o_we_search_prompt {
+ position: relative;
+ min-height: 250px;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ & > h2 {
+ max-width: 500px;
+ text-align: center;
+ }
+
+ &::before {
+ content: "";
+ @include o-position-absolute($top: 0, $right: 50px);
+ width: 100px;
+ height: 150px;
+ opacity: .5;
+ background-image: url('/web_editor/static/src/img/curved_arrow.svg');
+ background-size: 100%;
+ background-repeat: no-repeat;
+ }
+}
+
+@include media-breakpoint-down(sm) {
+ odoo-wysiwyg-container {
+ .panel-heading.note-toolbar {
+ overflow-x: auto;
+ }
+ .btn-group {
+ position: static;
+ }
+ }
+ // modal select media
+ .o_technical_modal.o_web_editor_dialog {
+ // see template 'web_editor.FieldTextHtml.fullscreen'
+ z-index: 2001;
+
+ > .o_select_media_dialog {
+ max-width: inherit !important;
+ z-index: 2001;
+
+ .modal-dialog, .model-content {
+ height: 100%;
+ }
+
+ .modal-body {
+ .nav .nav-item.search {
+ width: 100%;
+
+ .btn-group {
+ display: flex;
+ justify-content: space-around;
+ padding: 5px;
+ }
+ }
+
+ // center pictogram
+ .font-icons-icons {
+ text-align: center;
+ }
+
+ // fix search image
+ .form-control.o_we_search {
+ height: inherit;
+ }
+
+ .form-inline {
+ .btn-group {
+ width: 100%;
+
+ .btn.btn-primary:not(.dropdown-toggle) {
+ width: 90%;
+ }
+ }
+
+ > .input-group.ml-2 {
+ margin-left: 0 !important;
+
+ > .input-group-append {
+ width: 100%;
+
+ > .btn {
+ width: 100%;
+ }
+
+ > .ml-2 {
+ margin-left: 0 !important;
+ }
+ }
+ }
+ }
+
+ // attachment cells
+ .o_we_existing_attachments > .row {
+ flex-direction: column;
+
+ > .o_existing_attachment_cell {
+ flex: initial;
+ max-width: 100%;
+
+ > .o_existing_attachment_remove {
+ opacity: inherit;
+ top: 10px;
+ }
+ }
+ }
+
+ // select media dialog unsplash error
+ #editor-media-image .unsplash_img_container .unsplash_error .mx-auto {
+ width: 100%;
+
+ .form-group {
+ input.w-100 {
+ min-width: 100px;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// BS4 blockquote has no style anymore, except bloquote <footer>
+blockquote {
+ padding: $spacer/2 $spacer;
+ border-left: 5px solid;
+ border-color: gray('300');
+ font-style: italic;
+}
+
+// Bg/text color classes generation
+.o_cc {
+ #{$o-color-extras-nesting-selector} {
+ // Re-force dropdown-item colors inside presets otherwise the presets
+ // 'link' colors take over.
+ .dropdown-menu .dropdown-item { // Need to add +1 priority thanks to
+ // .dropdown-menu to counter a:not(.btn)
+ &, h6 { // Quick fix: sometimes we use h6 in dropdowns
+ color: $dropdown-link-color !important;
+
+ @include hover-focus {
+ color: $dropdown-link-hover-color !important;
+ }
+ }
+ &.disabled,
+ &:disabled {
+ &, h6 { // Quick fix: sometimes we use h6 in dropdowns
+ color: $dropdown-link-disabled-color !important;
+ }
+ }
+ }
+ }
+}
+@for $index from 1 through length($o-color-combinations) {
+ $-bg: color('o-cc#{$index}-bg');
+ $-text: color('o-cc#{$index}-text');
+ $-headings: color('o-cc#{$index}-headings');
+ $-h2: color('o-cc#{$index}-h2');
+ $-h3: color('o-cc#{$index}-h3');
+ $-h4: color('o-cc#{$index}-h4');
+ $-h5: color('o-cc#{$index}-h5');
+ $-h6: color('o-cc#{$index}-h6');
+ $-link: color('o-cc#{$index}-link');
+ $-btn-primary: color('o-cc#{$index}-btn-primary');
+ $-btn-primary-border: color('o-cc#{$index}-btn-primary-border');
+ $-btn-secondary: color('o-cc#{$index}-btn-secondary');
+ $-btn-secondary-border: color('o-cc#{$index}-btn-secondary-border');
+
+ // Those color classes color multiple elements when applied on a snippet.
+ // Those rules are not important so that they can be overridden through
+ // bg and text utility classes. **
+ .o_cc#{$index} {
+ // Background & Text
+ $-bg-color: o-color($-bg);
+ @include o-bg-color($-bg-color, o-color($-text), $important: false, $yiq-min-opacity-threshold: 0);
+
+ #{$o-color-extras-nesting-selector} {
+ // Headings
+ h1, h2, h3, h4, h5, h6 {
+ // 'inherit' comes from the o-bg-color mixin
+ color: o-color($-headings);
+ }
+ h2, h3, h4, h5, h6 {
+ color: o-color($-h2);
+ }
+ h3, h4, h5, h6 {
+ color: o-color($-h3);
+ }
+ h4, h5, h6 {
+ color: o-color($-h4);
+ }
+ h5, h6 {
+ color: o-color($-h5);
+ }
+ h6 {
+ color: o-color($-h6);
+ }
+
+ // Links
+ $-link-color: if($-link, o-color($-link), theme-color('primary'));
+ $-link-hover-color: darken($-link-color, 15%);
+ a:not(.btn), .btn-link {
+ color: auto-contrast($-link-color, $-bg-color, 'o-cc#{$index}-link');
+
+ @include hover {
+ color: auto-contrast($-link-hover-color, $-bg-color, 'o-cc#{$index}-link');
+ }
+ }
+
+ // Buttons
+
+ // Primary
+ $-btn-primary-color: if($-btn-primary, o-color($-btn-primary), theme-color('primary'));
+ $-btn-primary-border-color: if($-btn-primary-border, o-color($-btn-primary-border), $-btn-primary-color);
+ .btn-fill-primary {
+ @include button-variant($-btn-primary-color, $-btn-primary-border-color);
+ }
+ .btn-outline-primary {
+ @include button-outline-variant($-btn-primary-border-color);
+ }
+
+ // Secondary
+ $-btn-secondary-color: if($-btn-secondary, o-color($-btn-secondary), theme-color('secondary'));
+ $-btn-secondary-border-color: if($-btn-secondary-border, o-color($-btn-secondary-border), $-btn-secondary-color);
+ .btn-fill-secondary {
+ @include button-variant($-btn-secondary-color, $-btn-secondary-border-color);
+ }
+ .btn-outline-secondary {
+ @include button-outline-variant($-btn-secondary-border-color);
+ }
+
+ // 'Active' states. Note: this only emulates very common components
+ // used in snippets. This might need to be more complex the day we
+ // can apply color combinations anywhere (page-item, ...).
+ .nav-pills {
+ .nav-link.active,
+ .show > .nav-link {
+ background-color: $-btn-primary-color;
+ color: color-yiq($-btn-primary-color);
+ }
+ }
+ .dropdown-menu .dropdown-item { // Need to add +1 priority thanks to
+ // .dropdown-menu (see .o_cc).
+ &.active,
+ &:active {
+ &, h6 { // Quick fix: sometimes we use h6 in dropdowns
+ @include gradient-bg($-btn-primary-color);
+ color: color-yiq($-btn-primary-color) !important;
+
+ @include hover-focus {
+ color: color-yiq($-btn-primary-color) !important;
+ }
+ }
+ }
+ }
+ a.list-group-item {
+ color: $-btn-primary-color;
+
+ &.active {
+ background-color: $-btn-primary-color;
+ color: color-yiq($-btn-primary-color);
+ border-color: $-btn-primary-color;
+ }
+ }
+ }
+ }
+}
+
+// Extend bootstrap to create background and text utilities for some colors
+// outside of the $theme-colors too (but not btn-, alert-, etc).
+@for $index from 1 through 5 {
+ $-color-name: 'o-color-#{$index}';
+ $-color: color($-color-name);
+ @include bg-variant(".bg-#{$-color-name}", $-color);
+ @include text-emphasis-variant(".text-#{$-color-name}", $-color);
+}
+
+// Base snippet rules
+%o-we-background-layer-parent {
+ &, & > * {
+ // Allow background layers to be placed accordingly and snippet content
+ // to be displayed on top. Note: we cannot just position the layers
+ // with z-index: -1, otherwise it would go under the snippet own
+ // background. Adding a z-index: 0 on the snippet to create its own
+ // stacking context won't solve that either as, in that case, any BS
+ // component inside would be using that stacking context (e.g. a
+ // dropdown inside snippet 1 of the page would go under snippet 2
+ // when opened since the dropdown z-index would be confined into
+ // snippet 1's stacking context.
+ position: relative;
+ }
+}
+%o-we-background-layer {
+ @include o-position-absolute(0, 0, 0, 0);
+ position: absolute !important;
+ display: block;
+ overflow: hidden;
+ background-repeat: no-repeat;
+ pointer-events: none;
+}
+
+section, .oe_img_bg, [data-oe-shape-data] {
+ @extend %o-we-background-layer-parent;
+}
+.o_we_bg_filter {
+ @extend %o-we-background-layer;
+}
diff --git a/addons/web_editor/static/src/scss/web_editor.frontend.scss b/addons/web_editor/static/src/scss/web_editor.frontend.scss
new file mode 100644
index 00000000..232b7b90
--- /dev/null
+++ b/addons/web_editor/static/src/scss/web_editor.frontend.scss
@@ -0,0 +1,74 @@
+@include media-breakpoint-down(sm) {
+ img, .media_iframe_video, span.fa, i.fa {
+ transform: none !important;
+ }
+}
+
+.o_wysiwyg_loader {
+ @extend :disabled;
+ pointer-events: none;
+ min-height: 100px;
+ color: transparent;
+}
+.o_wysiwyg_loading {
+ @include o-position-absolute($top: 50%, $left: 50%);
+ transform: translate(-50%, -50%)
+}
+
+.ui-autocomplete {
+ max-height: 50vh;
+ overflow-y: auto;
+ overflow-x: hidden;
+
+ .ui-menu-item {
+ padding: 0;
+ > .ui-state-active {
+ border: none;
+ font-weight: normal;
+ margin: 0;
+ }
+ }
+}
+
+// Background shapes
+.o_we_shape {
+ @extend %o-we-background-layer;
+
+ @each $module, $shapes in $o-bg-shapes {
+ @each $shape, $style in $shapes {
+ $url-params: '';
+ $colors: map-get($style, 'colors');
+ @each $i in $colors {
+ // %23 is the url-encoded form of '#' which doesn't work as is in urls.
+ $color: str-replace("#{map-get($o-color-palette, "o-color-#{$i}")}", '#', '%23');
+ $url-params: '#{$url-params}&c#{$i}=#{$color}';
+ }
+
+ // eg: o_website_shape_bg_1
+ &.o_#{$module}_#{str-replace($shape, '/', '_')} {
+ // When not customized, this URL, built in SCSS, allows for the
+ // shape to be dynamic and adapted if future color changes.
+ background-image: url("/web_editor/shape/#{$module}/#{$shape}.svg?#{str-slice($url-params, 2)}");
+ background-position: map-get($style, 'position');
+ background-size: map-get($style, 'size');
+ @if map-get($style, 'repeat-y') {
+ background-repeat: repeat-y;
+ }
+ }
+ }
+ }
+}
+@include media-breakpoint-down(sm) {
+ .o_we_shape {
+ display: none;
+ }
+}
+.o_we_flip_x {
+ transform: scaleX(-1);
+}
+.o_we_flip_y {
+ transform: scaleY(-1);
+}
+.o_we_flip_x.o_we_flip_y {
+ transform: scale(-1);
+}
diff --git a/addons/web_editor/static/src/scss/web_editor.variables.scss b/addons/web_editor/static/src/scss/web_editor.variables.scss
new file mode 100644
index 00000000..11cb2be1
--- /dev/null
+++ b/addons/web_editor/static/src/scss/web_editor.variables.scss
@@ -0,0 +1,728 @@
+///
+/// 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: Roboto, 'Montserrat', 'Segoe UI', 'Helvetica Neue', Helvetica, Arial, sans-serif !default;
+$o-we-accent: #01bad2 !default;
+$o-we-border-width: 1px !default;
+$o-we-border-color: $o-we-bg-light !default;
+
+$o-we-ace-color: #2F3129 !default;
+
+$o-we-handles-offset-to-hide: 10000px !default;
+$o-we-handles-btn-size: 20px !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: 8px !default;
+$o-we-handle-border-width: 2px !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;
+
+// Translations
+$o-we-content-to-translate-color: rgb(255, 255, 90) !default;
+$o-we-translated-content-color: rgb(120, 215, 110) !default;
+
+$o-we-toolbar-height: 32px !default;
+
+$o-we-item-spacing: 8px !default;
+$o-we-item-border-width: 1px !default;
+$o-we-item-border-color: $o-we-bg-darkest !default;
+$o-we-item-border-radius: 2px !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;
+$o-we-sidebar-width: $o-we-sidebar-border-width + 290px !default;
+
+$o-we-sidebar-top-height: 46px !default;
+
+$o-we-sidebar-tabs-size-ratio: 1 !default;
+$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-light !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-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-lightest !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: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !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-sidebar-content-field-toggle-control-shadow: 0 2px 3px 0 $o-we-bg-darkest !default;
+
+//------------------------------------------------------------------------------
+// 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;
+}
+
+@mixin o-we-preview-content {
+ display: inline-block;
+ max-width: 100%;
+ overflow: hidden;
+ box-shadow: 0 0 15px 2px #000;
+}
+
+//------------------------------------------------------------------------------
+// Mixins to shield UI from themed bootstrap
+//------------------------------------------------------------------------------
+
+@mixin o-w-preserve-base {
+ font-size: $o-we-font-size;
+ font-family: $o-we-font-family;
+ line-height: 1.5;
+ color: #33363e;
+
+ .text-muted {
+ color: #999999 !important;
+ }
+}
+
+@mixin o-w-preserve-headings {
+ h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
+ font-family: $o-we-font-family;
+ line-height: 1.5;
+ color: $o-we-bg-light;
+ font-weight: bold;
+ }
+ h1, .h1 {
+ font-size: 2.4 * $o-we-font-size;
+ }
+ h2, .h2 {
+ font-size: 1.5 * $o-we-font-size;
+ }
+ h3, .h3 {
+ font-size: 1.3 * $o-we-font-size;
+ }
+ h4, .h4 {
+ font-size: 1.2 * $o-we-font-size;
+ }
+ h5, .h5 {
+ font-size: 1.1 * $o-we-font-size;
+ }
+ h6, .h6 {
+ font-size: $o-we-font-size;
+ }
+}
+
+@mixin o-w-preserve-links {
+ a:not(.o_btn_preview) {
+ color: $o-brand-primary;
+
+ &:focus, &:active, &:focus:active {
+ outline: none!important;
+ }
+ }
+
+ .badge {
+ &:hover a, a {
+ color: #fff;
+ }
+ }
+}
+
+@mixin o-w-preserve-forms {
+ :not(.input-group):not(.form-group):not(.input-group-append):not(.input-group-prepend) > .form-control {
+ height: 34px;
+ }
+ .form-control {
+ padding: 6px 12px;
+ font-size: 14px;
+ line-height: 1.5;
+ border: 1px solid #d4d5d7;
+ color: #555;
+ background-color: #fff;
+ border-radius: 0;
+
+ &.is-invalid {
+ border-color: $danger;
+ }
+ }
+ .input-group .form-control {
+ height: auto;
+ }
+ .input-group-text {
+ background-color: #e9ecef;
+ }
+
+ .was-validated {
+ .form-control:invalid {
+ border-color: $danger;
+ }
+ }
+
+ select.form-control {
+ appearance: none;
+ background: url('data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPScxLjEnIHhtbG5zPSdodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZycgeG1 sbnM6eGxpbms9J2h0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsnIHdpZHRoPScyNCcgaGVpZ2 h0PScyNCcgdmlld0JveD0nMCAwIDI0IDI0Jz48cGF0aCBpZD0nc3ZnXzEnIGQ9J203LjQwNiw3L jgyOGw0LjU5NCw0LjU5NGw0LjU5NCwtNC41OTRsMC40MDYsMS40MDZsLTUsNC43NjZsLTUsLTQu NzY2bDAuNDA2LC0xLjQwNnonIGZpbGw9JyM4ODgnLz48L3N2Zz4=');
+ background-position: 100% 65%;
+ background-repeat: no-repeat;
+ }
+}
+
+@mixin o-w-preserve-modals {
+ .modal-content {
+ border-radius: 0;
+ background-color: white;
+
+ .modal-header {
+ border-bottom-color: #e9ecef;
+ }
+ .modal-body {
+ background-color: white;
+ }
+ .modal-footer {
+ border-top-color: #e9ecef;
+ text-align: left;
+ }
+ }
+
+ .close {
+ font-size: 1.5 * $o-we-font-size;
+ }
+}
+
+@mixin o-w-preserve-tabs {
+ .nav-tabs {
+ border-bottom: 1px solid #e9ecef;
+
+ > li {
+ > a {
+ line-height: 1.5;
+ color: #4e525b;
+
+ &:hover {
+ border-color: #dee2e6;
+ }
+
+ &.active {
+ &, &:hover, &:focus {
+ color: #3D4047;
+ background-color: white;
+ border-color: #dee2e6 #dee2e6 #FFFFFF;
+ }
+ }
+ }
+ }
+ }
+}
+
+@mixin o-w-preserve-btn {
+ .btn:not(.o_btn_preview) {
+ border-radius: 0;
+ font-weight: normal;
+ text-transform: none;
+ @include button-size(.375rem, .75rem, 0.875rem, 1.5, 0);
+
+ &.btn-primary {
+ @include button-variant($o-brand-primary, $o-brand-primary);
+ color: white;
+ }
+ &.btn-secondary {
+ @include button-variant(white, white);
+ color: $o-brand-primary;
+ }
+ &.btn-link {
+ @include button-variant(white, white);
+ color: $o-brand-primary;
+ }
+ &.btn-success {
+ @include button-variant($o-we-color-success, $o-we-color-success);
+ color: white;
+ }
+ &.btn-info {
+ @include button-variant($o-we-color-info, $o-we-color-info);
+ color: white;
+ }
+ &.btn-warning {
+ @include button-variant($o-we-color-warning, $o-we-color-warning);
+ color: #33363e;
+ }
+ &.btn-danger {
+ @include button-variant($o-we-color-danger, $o-we-color-danger);
+ color: #33363e;
+ }
+ }
+}
+
+@mixin o-w-preserve-cards {
+ .card {
+ padding: 19px;
+ margin-bottom: 20px;
+ background-color: white;
+ border: 1px solid darken(white, 5%);
+ border-radius: 0;
+ box-shadow: none;
+ }
+}
+
+@mixin o-w-preserve-dropdown-menus {
+ .dropdown-menu {
+ background-color: white;
+ }
+ .dropdown-item {
+ color: #212529;
+
+ @include hover-focus {
+ color: darken(#212529, 5%);
+ }
+ &.active,
+ &:active {
+ color: white;
+ @include gradient-bg($o-brand-primary);
+ }
+ }
+}
+
+//------------------------------------------------------------------------------
+// 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: (
+ (
+ 'o-color-1': scale-color($o-enterprise-primary-color, $saturation: -50%, $lightness: 20%),
+ 'o-color-2': scale-color($o-enterprise-color, $saturation: -50%),
+ 'o-color-3': #F6F6F6,
+ 'o-color-4': #FFFFFF,
+ 'o-color-5': #383E45,
+ ),
+ (
+ 'o-color-1': #337ab7,
+ 'o-color-2': #e9ecef,
+ 'o-color-3': #F8F9FA,
+ 'o-color-4': #FFFFFF,
+ 'o-color-5': #343a40,
+
+ 'menu': 2,
+ 'footer': 2,
+ 'copyright': 5,
+ ),
+) !default;
+$o-color-palette-number: 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.
+ (
+ 'alpha': $o-enterprise-primary-color,
+ 'beta': $o-enterprise-color,
+ 'gamma': #5C5B80,
+ 'delta': #5B899E,
+ 'epsilon': #E46F78,
+ ),
+) !default;
+$o-theme-color-palette-number: 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-number: 1 !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-1',
+ ),
+ (
+ '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#{$-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) {
+ $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 >= 500);
+}
+
+// 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 15% lightness change even if not contrasted enough
+ @while ($lightness-c1 > 0.1% and $lightness-c1 < 99.9% and $i < 15 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;
+}
+
+// Print a document property the right way (depending on the type of the printed
+// variable).
+@mixin print-variable($key, $value) {
+ @if $value != null {
+ $-type: type-of($value);
+ @if $-type == 'string' {
+ --#{$key}: '#{$value}';
+ } @else if $-type == 'list' {
+ --#{$key}: #{inspect($value)};
+ } @else {
+ --#{$key}: #{$value};
+ }
+ }
+}
+
+// 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), 'repeat-y': false),
+ 'Airy/02': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Airy/03': ('position': top, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Airy/04': ('position': center, 'size': 100% 100%, 'colors': (1), 'repeat-y': false),
+ 'Airy/05': ('position': center, 'size': 100% 100%, 'colors': (1), 'repeat-y': false),
+ 'Airy/06': ('position': bottom, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Airy/07': ('position': top, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Airy/08': ('position': bottom, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Airy/09': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Airy/10': ('position': bottom, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Airy/11': ('position': top, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Airy/12': ('position': top, 'size': 100% auto, 'colors': (1, 3), 'repeat-y': false),
+ 'Airy/13': ('position': bottom, 'size': 100% auto, 'colors': (1, 4), 'repeat-y': false),
+ 'Airy/14': ('position': bottom, 'size': 100% auto, 'colors': (1, 4), 'repeat-y': false),
+ 'Blobs/01': ('position': top, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Blobs/02': ('position': bottom, 'size': 100% auto, 'colors': (1, 2), 'repeat-y': false),
+ 'Blobs/03': ('position': top, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Blobs/04': ('position': center, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Blobs/05': ('position': bottom, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Blobs/06': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Blobs/07': ('position': top, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Blobs/08': ('position': right, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Blobs/09': ('position': bottom, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Blobs/10': ('position': top, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Blobs/11': ('position': center, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Blobs/12': ('position': bottom, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Blocks/01': ('position': bottom, 'size': 100% auto, 'colors': (1, 3, 5), 'repeat-y': false),
+ 'Blocks/01_001': ('position': top, 'size': 100% auto, 'colors': (1, 3, 5), 'repeat-y': false),
+ 'Blocks/02': ('position': top, 'size': 100% auto, 'colors': (1, 3, 5), 'repeat-y': false),
+ 'Blocks/02_001': ('position': bottom, 'size': 100% auto, 'colors': (1, 3, 5), 'repeat-y': false),
+ 'Blocks/03': ('position': bottom, 'size': 100% auto, 'colors': (1, 4), 'repeat-y': false),
+ 'Blocks/04': ('position': bottom, 'size': 100% auto, 'colors': (1, 2, 3, 5), 'repeat-y': false),
+ 'Bold/01': ('position': top, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Bold/02': ('position': bottom, 'size': 100% auto, 'colors': (1, 2, 3), 'repeat-y': false),
+ 'Bold/03': ('position': bottom, 'size': 100% auto, 'colors': (1, 3, 5), 'repeat-y': false),
+ 'Bold/04': ('position': top, 'size': 100% auto, 'colors': (2, 3), 'repeat-y': false),
+ 'Bold/05': ('position': center, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Bold/05_001': ('position': center, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Bold/06': ('position': center, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Bold/06_001': ('position': center, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Bold/07': ('position': bottom, 'size': 100% auto, 'colors': (1, 2), 'repeat-y': false),
+ 'Bold/08': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Bold/09': ('position': bottom, 'size': 100% auto, 'colors': (2, 3), 'repeat-y': false),
+ 'Bold/10': ('position': top, 'size': 100% auto, 'colors': (1, 3, 4, 5), 'repeat-y': false),
+ 'Bold/10_001': ('position': top, 'size': 100% auto, 'colors': (1, 4, 5), 'repeat-y': false),
+ 'Bold/11': ('position': bottom, 'size': 100% auto, 'colors': (1, 2, 3), 'repeat-y': false),
+ 'Bold/11_001': ('position': bottom, 'size': 100% auto, 'colors': (1, 2), 'repeat-y': false),
+ 'Bold/12': ('position': center, 'size': 100% auto, 'colors': (1, 2, 5), 'repeat-y': false),
+ 'Origins/01': ('position': bottom, 'size': 100% auto, 'colors': (2, 5), 'repeat-y': false),
+ 'Origins/02': ('position': bottom, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Origins/03': ('position': top, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Origins/04': ('position': bottom, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Origins/05': ('position': top, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Origins/06': ('position': center, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Origins/07': ('position': center, 'size': 100% 100%, 'colors': (3), 'repeat-y': false),
+ 'Origins/08': ('position': bottom, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Origins/09': ('position': top, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Origins/10': ('position': bottom, 'size': 100% auto, 'colors': (2, 5), 'repeat-y': false),
+ 'Origins/11': ('position': top, 'size': 100% auto, 'colors': (3, 5), 'repeat-y': false),
+ 'Origins/12': ('position': top, 'size': 100% auto, 'colors': (3, 5), 'repeat-y': false),
+ 'Origins/13': ('position': center, 'size': 100% auto, 'colors': (3, 5), 'repeat-y': false),
+ 'Origins/14': ('position': bottom, 'size': 100% auto, 'colors': (4), 'repeat-y': false),
+ 'Origins/15': ('position': top, 'size': 100% auto, 'colors': (4), 'repeat-y': false),
+ 'Rainy/01': ('position': bottom, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Rainy/02': ('position': top, 'size': 100% auto, 'colors': (1, 4, 5), 'repeat-y': false),
+ 'Rainy/03': ('position': top, 'size': 100% auto, 'colors': (2, 4, 5), 'repeat-y': true),
+ 'Rainy/04': ('position': top, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Rainy/05': ('position': top, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Rainy/05_001': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Rainy/06': ('position': bottom, 'size': 100% auto, 'colors': (1, 2, 3), 'repeat-y': false),
+ 'Rainy/07': ('position': top, 'size': 100% auto, 'colors': (1, 2, 3), 'repeat-y': false),
+ 'Rainy/08': ('position': top, 'size': 100% auto, 'colors': (1, 4), 'repeat-y': false),
+ 'Rainy/09': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Wavy/01': ('position': bottom, 'size': 100% auto, 'colors': (4), 'repeat-y': false),
+ 'Wavy/02': ('position': top, 'size': 100% auto, 'colors': (4), 'repeat-y': false),
+ 'Wavy/03': ('position': top, 'size': 100% auto, 'colors': (1, 2), 'repeat-y': false),
+ 'Wavy/04': ('position': bottom, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Wavy/05': ('position': top, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Wavy/06': ('position': top, 'size': 100% auto, 'colors': (1, 3, 4, 5), 'repeat-y': false),
+ 'Wavy/06_001': ('position': top, 'size': 100% auto, 'colors': (1, 3, 5), 'repeat-y': false),
+ 'Wavy/07': ('position': top, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Wavy/08': ('position': top, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Wavy/09': ('position': bottom, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Wavy/10': ('position': center, 'size': 100% auto, 'colors': (1, 2), 'repeat-y': false),
+ 'Wavy/11': ('position': bottom, 'size': 100% auto, 'colors': (1, 4), 'repeat-y': false),
+ 'Wavy/12': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Wavy/13': ('position': bottom, 'size': 100% auto, 'colors': (4), 'repeat-y': false),
+ 'Wavy/14': ('position': bottom, 'size': 100% auto, 'colors': (1, 3), 'repeat-y': false),
+ 'Wavy/15': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Wavy/16': ('position': bottom, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Wavy/17': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Wavy/18': ('position': bottom, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Wavy/19': ('position': top, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Wavy/20': ('position': bottom, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Wavy/21': ('position': top, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Wavy/22': ('position': bottom, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Wavy/23': ('position': top, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Zigs/01': ('position': bottom, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Zigs/02': ('position': bottom, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Zigs/03': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': true),
+ 'Zigs/04': ('position': bottom, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Zigs/05': ('position': bottom, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+));
diff --git a/addons/web_editor/static/src/scss/wysiwyg.scss b/addons/web_editor/static/src/scss/wysiwyg.scss
new file mode 100644
index 00000000..429da03a
--- /dev/null
+++ b/addons/web_editor/static/src/scss/wysiwyg.scss
@@ -0,0 +1,522 @@
+$o-we-overlay-zindex: ($zindex-fixed + $zindex-modal-backdrop) / 2 !default;
+$o-we-zindex: $o-we-overlay-zindex + 1 !default;
+
+// Use css variables to control the default style of the editor so that an
+// external assets bundle can influence it without duplicating the css.
+:root {
+ @include print-variable('o-we-toolbar-height', $o-we-toolbar-height);
+}
+
+.o_we_command_protector {
+ font-weight: 400 !important;
+
+ b, strong {
+ font-weight: 700 !important;
+ }
+ * {
+ font-weight: inherit !important;
+ }
+ .btn {
+ text-align: unset !important;
+ }
+}
+
+// EDITOR TOP BAR AND POPOVER
+.note-popover .popover {
+ max-width: 350px;
+ left: 50% !important;
+ transform: translate(-50%, 0);
+
+ .popover-body {
+ white-space: normal;
+ }
+}
+
+#web_editor-top-edit {
+ @include o-position-absolute(0, 0, auto, 0);
+ position: fixed;
+ z-index: $o-we-zindex + 1;
+ height: var(--o-we-toolbar-height);
+ background-color: $o-we-bg;
+
+ .note-popover .popover {
+ top: 0 !important;
+ left: 0 !important;
+ right: 0 !important;
+ border: none !important;
+ max-width: none;
+ transform: none;
+ }
+ .note-popover .popover .popover-body {
+ height: var(--o-we-toolbar-height);
+ }
+}
+
+.wysiwyg_iframe,
+.note-editor {
+ border: $o-we-border-width solid $o-we-border-color;
+ margin: 0;
+ padding: 0;
+}
+// avoid popover bar over its opened modal
+.note-popover .popover {
+ z-index: $o-we-overlay-zindex;
+}
+.note-popover .popover .popover-body,
+.panel-heading.note-toolbar {
+ padding-bottom: 0;
+ border-bottom: $o-we-border-width solid $o-we-border-color;
+ background-color: $o-we-bg;
+ color: $o-we-color;
+ font-family: $o-we-font-family;
+
+ // Main layout of buttons
+ .btn-group, .btn {
+ width: auto !important;
+ height: 100% !important;
+ margin-top: 0;
+ margin-bottom: 0;
+ background: transparent;
+ border: none;
+ border-radius: 0;
+ }
+ .btn-secondary {
+ color: inherit;
+ }
+
+ // Active buttons and opened dropdowns
+ .btn {
+ padding: 0.5em 0.75em !important;
+ border-left: $o-we-item-border-width solid $o-we-item-border-color;
+ border-right: $o-we-item-border-width solid $o-we-item-border-color;
+ background: $o-we-sidebar-content-field-clickable-bg;
+ color: inherit;
+ font-size: $o-we-font-size !important;
+
+ &.active,
+ &:focus, &:active, &:focus:active {
+ @extend %we-active-button;
+ }
+
+ // This element should have been removed but still exists in 13.0 by
+ // mistake. This takes advantage of it to restore the color preview
+ // feature which disappeared and cannot be fixed as in 12.0.
+ // TODO fix the right way in 14.0.
+ > .caret {
+ display: block;
+ @include o-position-absolute(auto, 0, 0, 0);
+ border-bottom: 2px solid transparent;
+ }
+ }
+ .btn-group.show {
+ > .btn {
+ @extend %we-active-button;
+ }
+ &::after {
+ content: '';
+ @include o-position-absolute(100%, $o-we-border-width, auto, $o-we-border-width);
+ height: $o-we-border-width;
+ }
+ }
+ %we-active-button {
+ background: $o-we-sidebar-content-field-pressed-bg;
+ color: $o-we-sidebar-content-field-pressed-color;
+ box-shadow: none !important;
+ outline: none !important;
+ }
+ .dropdown-menu {
+ transform: none !important;
+ margin-top: $o-we-dropdown-spacing;
+ padding: 0;
+ margin-top: $o-we-toolbar-height;
+ border: $o-we-dropdown-border-width solid $o-we-dropdown-border-color;
+ background-color: $o-we-dropdown-bg;
+ box-shadow: $o-we-dropdown-shadow;
+ }
+ .dropdown-menu.show { // To overcome .note-XXX .dropdown-menu rules
+ min-width: 0;
+ }
+ .dropdown-item { // To overcome summernote rules breaking this in iframes
+ display: block;
+ max-width: none;
+ overflow: visible;
+ margin-top: 0;
+ padding: 0 1em;
+ border: none;
+ background: none;
+ background-clip: padding-box;
+ background-color: $o-we-dropdown-item-bg;
+ color: $o-we-dropdown-item-color;
+ line-height: $o-we-dropdown-item-height;
+
+ &:not(.d-none) ~ .dropdown-item {
+ // Use a border-top instead of a margin-top as when the
+ // mouse goes from one select button to another, the
+ // option preview should switch from the first button's
+ // option to the second one without reset to selected
+ // state in between.
+ border-top: $o-we-dropdown-item-spacing solid transparent;
+ }
+
+ &.active {
+ color: $o-we-dropdown-item-active-color;
+ }
+ }
+ li > .dropdown-item {
+ border-top: $o-we-dropdown-item-spacing solid transparent;
+ }
+
+ .note-style {
+ .dropdown-item {
+ > * {
+ display: inline;;
+ }
+ &, > * {
+ line-height: 2;
+ }
+ &[data-value="blockquote"] {
+ padding-top: 0.5em;
+ padding-bottom: 0.5em;
+
+ > * {
+ display: block;
+ }
+ }
+ }
+ }
+
+ // Specific elements
+ .o_image_alt {
+ @include o-text-overflow();
+ max-width: 150px;
+ }
+ .note-color-palette div .note-color-btn {
+ border-color: $o-we-dropdown-bg;
+ }
+ .note-custom-color-palette .note-color-row {
+ height: auto!important;
+ .note-color-btn {
+ float: left;
+ height: 20px;
+ width: 20px;
+ padding: 0;
+ margin: 0;
+ border: 1px solid $o-we-dropdown-bg;
+ }
+ }
+}
+.note-color ul.show {
+ min-width: 216px !important;
+}
+
+// ANIMATIONS
+@keyframes fadeInDownSmall {
+ 0% {
+ opacity: 0;
+ transform: translate(0, -5px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translate(0, 0);
+ }
+}
+
+@keyframes inputHighlighter {
+ from {
+ background: $o-brand-primary;
+ }
+ to {
+ width: 0;
+ background: transparent;
+ }
+}
+
+.o_we_horizontal_collapse {
+ width: 0 !important;
+ padding: 0 !important;
+ border: none !important;
+}
+
+.o_we_transition_ease {
+ transition: all ease 0.35s;
+}
+
+// MODALS
+body .modal {
+
+ // SELECT MEDIA
+ .o_select_media_dialog {
+ max-width: 80%;
+
+ .modal-body {
+ .tab-pane {
+ min-height: 300px;
+ }
+
+ .o_we_images > .o_existing_attachment_cell .o_we_media_dialog_img_wrapper {
+ @extend %o-preview-alpha-background;
+
+ &, > img {
+ width: 100%;
+ }
+ }
+
+ .o_existing_attachment_cell {
+ cursor: pointer;
+ margin: 1px;
+
+ .o_existing_attachment_optimize, .o_existing_attachment_remove {
+ background-color: rgba(white, 0.4);
+ opacity: 0;
+ cursor: pointer;
+ transition: color 0.2s ease;
+ }
+
+ .o_existing_attachment_optimize {
+ @include o-position-absolute($top: 0, $left: 0);
+ border-radius: 0 0 2px 0;
+ }
+
+ .o_existing_attachment_remove {
+ @include o-position-absolute($top: 0, $right: 0);
+ z-index: 1;
+ border-radius: 0 0 0 2px;
+ &:hover {
+ color: $o-we-color-danger;
+ }
+ }
+
+ .o_file_name {
+ @include o-text-overflow;
+ }
+
+ &:hover {
+ .o_existing_attachment_optimize, .o_existing_attachment_remove {
+ opacity: 1;
+ }
+ &.o_we_attachment_highlight, .o_we_attachment_highlight {
+ border-color: $card-border-color;
+ box-shadow: 0px 0px 2px 2px $card-border-color;
+ }
+ }
+ }
+
+ .o_we_attachment_selected {
+ border-color: $o-brand-primary;
+ box-shadow: 0px 0px 2px 2px $o-brand-primary;
+ }
+
+ .o_we_attachment_optimized .badge {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ margin: 2px;
+ }
+
+ .font-icons-icons {
+ > span {
+ text-align: center;
+ font-size: 22px;
+ margin: 5px;
+ width: 50px;
+ height: 50px;
+ padding: 15px;
+ cursor: pointer;
+ }
+ }
+
+ #editor-media-image,
+ #editor-media-document {
+ .o_we_url_input {
+ width: 300px;
+ }
+ }
+
+ // VIDEO TAB
+ #editor-media-video {
+ .o_video_dialog_form {
+ #o_video_form_group {
+ position: relative;
+ width: 100%;
+
+ > textarea {
+ width: 100%;
+ min-height: 95px;
+ padding-bottom: 25px;
+ overflow-y: scroll;
+ }
+ }
+ }
+
+ #video-preview {
+ position: relative;
+ @include o-we-preview-box();
+ border: none;
+
+ .media_iframe_video {
+ width: 100%;
+ }
+
+ .o_video_dialog_iframe {
+ @include o-we-preview-content;
+ max-width: 100%;
+ max-height: 100%;
+
+ &.alert {
+ animation: fadeInDownSmall 700ms forwards;
+ margin: 0 auto;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // LINK EDITOR DIALOG COLOR SELECTOR
+ .o_link_dialog {
+ input.link-style:checked + span::after {
+ content: "\f00c";
+ display: inline-block;
+ font-family: FontAwesome;
+ margin-left: 2px;
+ }
+
+ .o_link_dialog_preview {
+ border-left: 1px solid gray('200');
+ }
+ }
+
+ .o_we_image_optimize_dialog {
+ .o_we_title_label {
+ font-size: $o-we-font-size;
+ }
+ .o_we_preview_area {
+ max-height: 400px;
+ overflow: auto;
+ }
+ }
+}
+
+// Highlight selected image/icon
+%o-we-selected-image {
+ outline: 3px solid rgba(150, 150, 220, 0.3);
+}
+
+img.o_we_selected_image {
+ @extend %o-we-selected-image;
+}
+
+.fa.o_we_selected_image::before {
+ @extend %o-we-selected-image;
+}
+// Override default image selection color from portal. It prevents your from
+// seeing the images' quality clearly in the wysiwyg.
+img::selection {
+ background: transparent;
+}
+.o_we_media_author {
+ font-size: 11px;
+ @include o-position-absolute($bottom: 0, $left: 0, $right: 0);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-align: center;
+ background-color: rgba(255, 255, 255, .7);
+}
+
+@include media-breakpoint-down(md) {
+ #web_editor-top-edit {
+ position: initial !important;
+ height: initial !important;
+ top: initial !important;
+ left: initial !important;
+
+ #web_editor-toolbars .popover-body {
+ display: flex;
+ width: 100%;
+ overflow-x: auto;
+
+ .btn-group {
+ position: static;
+ }
+ }
+ }
+}
+
+// User modal in edit mode
+.editor_enable, .note-editable {
+ .modal:not(.o_technical_modal) {
+ top: 40px;
+ right: 0;
+ bottom: 0;
+ right: $o-we-sidebar-width;
+ width: auto;
+ height: auto;
+
+ .modal-dialog {
+ padding: 0.5rem 0; // To use more editor space if necessary
+ }
+ }
+}
+
+.o_we_no_pointer_events {
+ pointer-events: none;
+}
+
+.o_we_crop_widget {
+ background-color: rgba(128, 128, 128, 0.5);
+ @include o-position-absolute(0, 0, 0, 0);
+ z-index: 1024;
+
+ .o_we_cropper_wrapper {
+ position: absolute;
+ }
+
+ .o_we_crop_buttons {
+ margin-top: 0.5rem;
+ display: flex;
+ flex-wrap: wrap;
+
+ input[type=radio] {
+ display: none;
+ }
+
+ .btn-group {
+ border-radius: 0.25rem;
+ margin: 0.1rem;
+ }
+
+ button, label {
+ cursor: pointer !important;
+ padding: 0.2rem 0.3rem;
+ }
+
+ label {
+ display: flex;
+ align-items: center;
+
+ &.active {
+ background-color: $o-we-bg-darkest;
+ }
+ }
+
+ button:not(.btn), label {
+ margin: 0;
+ border: none;
+ border-right: 1px solid $o-we-bg;
+ background-color: $o-we-bg;
+ color: $o-we-color;
+
+ &:first-child {
+ border-top-left-radius: 0.25rem;
+ border-bottom-left-radius: 0.25rem;
+ }
+
+ &:last-child {
+ border-top-right-radius: 0.25rem;
+ border-bottom-right-radius: 0.25rem;
+ border-right: none;
+ }
+ }
+ }
+}
diff --git a/addons/web_editor/static/src/scss/wysiwyg_iframe.scss b/addons/web_editor/static/src/scss/wysiwyg_iframe.scss
new file mode 100644
index 00000000..4e00d0bb
--- /dev/null
+++ b/addons/web_editor/static/src/scss/wysiwyg_iframe.scss
@@ -0,0 +1,27 @@
+iframe.wysiwyg_iframe.o_fullscreen {
+ position: fixed !important;
+ left: 0 !important;
+ right: 0 !important;
+ top: 0 !important;
+ bottom: 0 !important;
+ width: 100% !important;
+ min-height: 100% !important;
+ z-index: 1001 !important;
+ border: 0;
+}
+
+.o_wysiwyg_no_transform {
+ transform: none !important;
+}
+
+body.o_in_iframe {
+ background-color: $o-view-background-color;
+
+ &.editor_enable {
+ padding-top: var(--o-we-toolbar-height) !important;
+ }
+
+ .note-statusbar {
+ display: none;
+ }
+}
diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss
new file mode 100644
index 00000000..20b32893
--- /dev/null
+++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss
@@ -0,0 +1,1951 @@
+///
+/// This file contains all variables and mixins that are specific to the editor.
+///
+
+// OVERRIDES FOR EDITOR WITH SNIPPETS
+body.editor_enable.editor_has_snippets {
+ padding-right: $o-we-sidebar-width !important;
+
+ #web_editor-top-edit .note-popover .popover {
+ right: $o-we-sidebar-width !important;
+ }
+
+ .modal:not(.o_technical_modal) {
+ top: 0 !important;
+ // set z-index so customize options visible on dialog.
+ z-index: $o-we-overlay-zindex - 1;
+ // just for fake backdrop effect
+ background-color: rgba(66, 66, 66, 0.4);
+ }
+ > .oe_overlay.ui-draggable {
+ .o_handles {
+ display: none;
+ }
+ }
+}
+
+// Mobile fix for mass mailing
+@include media-breakpoint-down(md) {
+ body.editor_enable.editor_has_snippets {
+ #web_editor-top-edit {
+ position: initial !important;
+ height: initial !important;
+ top: initial !important;
+ left: initial !important;
+
+ .note-popover .popover {
+ right: 0 !important;
+ }
+ }
+ }
+}
+
+// Ugly hack to force ugly rules... while waiting for new editor
+#oe_snippets#oe_snippets .o_we_snippet_text_tools {
+ $-text-tools-gap: 3px;
+ $-text-tools-header-height: 35px;
+
+ z-index: $o-we-zindex;
+ flex: 1 0 auto;
+ display: flex;
+ padding: $-text-tools-header-height $o-we-sidebar-content-padding-base ($o-we-sidebar-content-padding-base * 3) $o-we-sidebar-content-indent;
+ box-shadow: $o-we-item-standup-top rgba($o-we-item-standup-color-light, .2);
+ overflow-y: auto;
+
+ .popover {
+ position: static !important;
+ top: 0 !important;
+ left: 0 !important;
+ transform: none !important;
+ border: none !important;
+ }
+ .popover-body {
+ padding: 0 !important;
+ border: none !important;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: $-text-tools-gap;
+
+ > * {
+ margin: 0 0 $o-we-sidebar-content-field-spacing*.5 0;
+ }
+ }
+ .btn {
+ @extend %we-generic-button;
+ border-radius: 0;
+ padding: 0 $o-we-sidebar-content-field-button-group-button-spacing!important;
+ line-height: $o-we-sidebar-content-field-height + ($o-we-sidebar-content-field-border-width*2)!important;
+ }
+ // Achieve a "button-group effect" for siblings buttons.
+ .popover-body > .btn-group {
+ > .btn {
+ border-radius: 0;
+ }
+ > .btn:first-of-type, > div:first-of-type .btn {
+ @include border-left-radius($o-we-sidebar-content-field-border-radius);
+ }
+ > .btn:last-of-type, > div:last-of-type .btn {
+ @include border-right-radius($o-we-sidebar-content-field-border-radius);
+ }
+ }
+ .btn-group {
+
+ &.note-color {
+ order: -3;
+
+ .note-back-color-preview {
+ margin-left: $-text-tools-gap;
+ }
+ .btn::after {
+ display: none;
+ }
+ }
+ &.note-style {
+ order: -2;
+
+ &, > div {
+ flex-grow: 1;
+ }
+ }
+ &.note-fontsize {
+ order: -1;
+ }
+ &.note-font [data-name="clear"] {
+ @include o-position-absolute(($-text-tools-header-height*-1) + 5px, 0);
+ background: 0;
+ border: 0;
+ @extend %we-generic-link;
+ }
+ &.note-para {
+ flex-grow: 1;
+ gap: $-text-tools-gap;
+ justify-content: space-between;
+
+ > * {
+ flex: 1 1 33%;
+ }
+ }
+ > .d-none + * {
+ margin-left: 0 !important;
+ }
+ }
+ .note-color {
+ .btn-group {
+ position: static;
+ }
+ .dropdown-menu { // Drop up
+ margin-top: $o-we-dropdown-spacing;
+ }
+ }
+ .note-popover ~ .note-popover,
+ .note-handle ~ .note-handle,
+ .note-dialog ~ .note-dialog {
+ // Prevent flickering of summernote when switching text tools...
+ display: none;
+ }
+}
+
+.oe_snippet {
+ // No root because can be drag and drop (and the helper is in the body)
+ position: relative;
+ z-index: $o-we-zindex;
+ width: 77px;
+ background-color: $o-we-sidebar-blocks-content-snippet-bg;
+
+ &.ui-draggable-dragging {
+ transform: rotate(-3deg) scale(1.2);
+ box-shadow: 0 5px 25px -10px black;
+ transition: transform 0.3s, box-shadow 0.3s;
+ }
+
+ > .oe_snippet_body {
+ display: none !important;
+ }
+
+ .oe_snippet_thumbnail {
+ width: 100%;
+
+ .oe_snippet_thumbnail_img {
+ width: 100%;
+ padding-top: 75%;
+ background-repeat: no-repeat;
+ background-size: contain;
+ background-position: top center;
+ overflow: hidden;
+ }
+ }
+
+ .oe_snippet_thumbnail_title {
+ display: none;
+ }
+
+ &:not(:hover):not(.o_disabled):not(.o_snippet_install) {
+ background-color: rgba($o-we-sidebar-blocks-content-snippet-bg, .9);
+
+ .oe_snippet_thumbnail {
+ filter: saturate(.7);
+ opacity: .9;
+ }
+ }
+}
+
+@mixin we-svg-icon(
+ $graphic: $o-we-sidebar-content-field-color,
+ $subdle: $o-we-sidebar-content-field-color,
+ $subdle-opacity: 0.5) {
+ svg {
+ .o_graphic {
+ fill: $graphic;
+ }
+ .o_subdle {
+ fill: rgba($subdle, $subdle-opacity);
+ }
+ }
+}
+
+%we-generic-clickable {
+ outline: none;
+ text-decoration: none;
+ line-height: $o-we-sidebar-content-field-height - 2 * $o-we-sidebar-content-field-border-width;
+ cursor: pointer;
+
+ &[disabled] {
+ opacity: .5;
+ }
+
+ &:not([disabled]) {
+ &.active:not(.o_we_no_toggle):not(.o_we_checkbox_wrapper), &:hover {
+ color: $o-we-sidebar-content-field-pressed-color;
+ }
+
+ $-hover-colors: (
+ 'success': $o-we-color-success,
+ 'info': $o-we-color-info,
+ 'warning': $o-we-color-warning,
+ 'danger': $o-we-color-danger,
+ );
+
+ @each $name, $color in $-hover-colors {
+ &.o_we_text_#{$name} {
+ color: $color;
+ }
+
+ &.o_we_hover_#{$name}:hover {
+ color: $color;
+ }
+ }
+ }
+}
+
+%we-generic-link {
+ color: $o-we-sidebar-content-field-color;
+ @include we-svg-icon();
+ @extend %we-generic-clickable;
+
+ &:not([disabled]) {
+ &.active, &:hover {
+ @include we-svg-icon($o-we-sidebar-content-field-pressed-color, $subdle-opacity: .75);
+ }
+ }
+}
+
+%we-generic-button {
+ @extend %we-generic-clickable;
+ @include o-text-overflow(block);
+ @include we-svg-icon($o-we-sidebar-content-field-clickable-color, $o-we-sidebar-content-field-clickable-color);
+
+ padding: 0 $o-we-sidebar-content-field-button-group-button-spacing;
+ border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color;
+ border-radius: $o-we-sidebar-content-field-border-radius;
+ background-color: $o-we-sidebar-content-field-clickable-bg;
+ color: $o-we-sidebar-content-field-clickable-color;
+
+ &:not([disabled]):hover, &.active:not(.o_we_no_toggle) {
+ @include we-svg-icon($o-we-sidebar-content-field-pressed-color, $subdle-opacity: .75);
+ }
+
+ &.active:not(.o_we_no_toggle) {
+ background-color: $o-we-sidebar-content-field-pressed-bg;
+ }
+}
+
+%we-generic-tab-button {
+ @extend %we-generic-link;
+ @include o-text-overflow(inline-flex);
+ flex: 1 1 auto;
+ justify-content: center;
+ border: none;
+ background-color: transparent;
+ color: inherit;
+ font-weight: normal;
+
+ > span {
+ display: inline-block;
+ $-r: $o-we-sidebar-tabs-size-ratio;
+ padding: (0.6em * $-r) (0.4em * $-r) (0.5em * $-r);
+ }
+ &.active > span {
+ color: $o-we-sidebar-content-field-colorpicker-dropdown-active-color;
+ box-shadow: inset 0 ($o-we-sidebar-tabs-active-border-width * -1) 0 $o-we-sidebar-tabs-active-border-color;
+ }
+}
+
+// SNIPPET PANEL
+#oe_snippets {
+ @include o-w-preserve-btn;
+
+ @include o-position-absolute(var(--o-we-toolbar-height), 0, 0, auto);
+ position: fixed;
+ z-index: $o-we-zindex;
+ display: flex;
+ flex-flow: column nowrap;
+ width: $o-we-sidebar-width;
+
+ border-left: $o-we-sidebar-border-width solid $o-we-sidebar-border-color;
+ background-color: $o-we-sidebar-bg;
+ color: $o-we-sidebar-color;
+ font-family: $o-we-font-family;
+ font-size: $o-we-sidebar-font-size;
+ font-weight: 400;
+
+ transition: transform 400ms ease 0s;
+ transform: translateX(100%);
+
+ &.o_loaded {
+ transform: none;
+ }
+
+ *::selection {
+ background: lighten($o-we-accent, 9);
+ color: $o-we-bg-darkest;
+ }
+
+ #snippets_menu {
+ flex: 0 0 auto;
+ display: flex;
+ background-color: $o-we-sidebar-tabs-bg;
+ box-shadow: $o-we-item-standup-top rgba($o-we-item-standup-color-light, .2);
+ color: $o-we-sidebar-tabs-color;
+
+ > button {
+ @extend %we-generic-tab-button;
+ }
+ }
+
+ // Snippet filter input
+ .o_snippet_search_filter {
+ position: relative;
+ box-shadow: $o-we-item-standup-bottom $o-we-item-standup-color-dark, 0 10px 10px rgba($o-we-item-standup-color-dark, .2);
+ z-index: 2;
+
+ &, .o_snippet_search_filter_input {
+ width: 100%;
+ }
+
+ .o_snippet_search_filter_input {
+ background-color: $o-we-sidebar-content-field-input-bg;
+ padding: $o-we-sidebar-blocks-content-spacing 2em $o-we-sidebar-blocks-content-spacing $o-we-sidebar-blocks-content-spacing;
+ border: 0;
+ border-bottom: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color;
+ color: $o-we-fg-lighter;
+
+ &::placeholder {
+ font-style: italic;
+ color: $o-we-sidebar-content-field-control-item-color;
+ }
+
+ &:focus {
+ background-color: $o-we-bg-lighter;
+ outline: none;
+ }
+ }
+
+ .o_snippet_search_filter_reset {
+ @include o-position-absolute($o-we-sidebar-blocks-content-spacing, $o-we-sidebar-blocks-content-spacing, $o-we-sidebar-blocks-content-spacing);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 0 $o-we-sidebar-content-field-clickable-spacing;
+ @include o-hover-text-color($o-we-sidebar-content-field-control-item-color, $o-we-fg-lighter);
+ cursor: pointer;
+ }
+ }
+
+ > #o_scroll, > .o_we_customize_panel {
+ min-height: 0;
+ overflow: auto;
+ }
+
+ > #o_scroll {
+ background-color: $o-we-sidebar-blocks-content-bg;
+ padding: 0 $o-we-sidebar-blocks-content-spacing;
+ z-index: 1;
+
+ .o_panel, .o_panel_header {
+ padding: $o-we-sidebar-blocks-content-spacing 0;
+ }
+
+ .o_panel_body {
+ display: flex; // Needed for too long snippet names
+ flex-wrap: wrap;
+ margin-left: -$o-we-sidebar-blocks-content-snippet-spacing;
+
+ > .oe_snippet {
+ flex: 0 0 auto;
+ width: percentage(1 / 3);
+ background-clip: padding-box;
+ border-left: $o-we-sidebar-blocks-content-snippet-spacing solid transparent;
+ margin-bottom: $o-we-sidebar-blocks-content-snippet-spacing;
+ user-select: none;
+
+ cursor: pointer;
+ cursor: copy;
+ cursor: grab;
+
+ .oe_snippet_thumbnail_title {
+ display: block;
+ padding: $o-we-sidebar-blocks-content-spacing / 2;
+ text-align: center;
+ }
+
+ &.o_disabled .o_snippet_undroppable {
+ @include o-position-absolute(8px, 6px, auto, auto);
+ }
+
+ &.o_snippet_install {
+ .btn.o_install_btn {
+ @include o-position-absolute($top: 10px);
+ }
+
+ &:not(:hover) .btn.o_install_btn {
+ display: none;
+ }
+ }
+
+ &.o_disabled, &.o_snippet_install {
+ background-color: rgba($o-we-sidebar-blocks-content-snippet-bg, .2);
+
+ .oe_snippet_thumbnail_img {
+ opacity: .4;
+ filter: saturate(0) blur(1px);
+ }
+ }
+ }
+ }
+ #snippet_custom .oe_snippet {
+ width: 100%;
+
+ &, .oe_snippet_thumbnail, .o_delete_btn {
+ display: flex;
+ }
+ .oe_snippet_thumbnail, .o_delete_btn {
+ align-items: center;
+ }
+ .oe_snippet_thumbnail {
+ min-width: 0; // Ensure text-overflow on flex children
+ }
+ .oe_snippet_thumbnail_title {
+ @include o-text-overflow(block);
+ }
+ .oe_snippet_thumbnail_img {
+ flex-shrink: 0;
+ width: 40px;
+ height: 32px;
+ padding: 0;
+ }
+ .o_delete_btn {
+ @extend %we-generic-link;
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+ }
+ }
+
+ > .o_we_customize_panel {
+ position: relative;
+
+ @mixin we-icon-button($icon, $color: $o-we-sidebar-content-field-control-item-color, $align: right) {
+ @extend %we-icon-button;
+ padding-#{$align}: 2 * $o-we-sidebar-content-field-control-item-spacing + $o-we-sidebar-content-field-control-item-size;
+
+ &::after {
+ content: $icon;
+ color: $color;
+
+ @if $align == left {
+ right: auto;
+ left: $o-we-sidebar-content-field-control-item-spacing;
+ }
+ }
+ }
+
+ @mixin large-component() {
+ flex: 1 1 auto;
+ width: $o-we-sidebar-content-available-room * .6;
+ }
+
+ we-button, we-toggler {
+ @extend %we-generic-button;
+ }
+
+ we-button.o_we_link {
+ @extend %we-generic-link;
+ margin-top: 0;
+ border: 0;
+ padding: 0;
+ background: 0;
+ }
+
+ we-toggler {
+ @include we-icon-button('\f0d7');
+ text-align: left;
+
+ > img, > svg {
+ max-width: 100%;
+ }
+
+ + * {
+ display: none !important;
+ border: $o-we-sidebar-content-field-dropdown-border-width solid $o-we-sidebar-content-field-dropdown-border-color;
+ background-color: $o-we-sidebar-content-field-dropdown-bg;
+ box-shadow: $o-we-sidebar-content-field-dropdown-shadow;
+ }
+ &.active {
+ @include we-icon-button('\f0d8');
+ + * {
+ display: block !important;
+ }
+ }
+ }
+ %we-icon-button {
+ position: relative;
+
+ &::after {
+ @include o-position-absolute(50%, $o-we-sidebar-content-field-control-item-spacing);
+ transform: translateY(-50%);
+ width: $o-we-sidebar-content-field-control-item-size;
+ text-align: center;
+ font-family: FontAwesome;
+ }
+ }
+
+ we-title {
+ display: block;
+ text-transform: capitalize;
+ }
+
+ we-customizeblock-options {
+ position: relative;
+ display: block;
+ padding: 0 0 ($o-we-sidebar-content-block-spacing * 1.5) 0;
+ background-color: $o-we-bg-lighter;
+ box-shadow: $o-we-item-standup-bottom rgba($o-we-item-standup-color-dark, 0.8);
+
+ > we-title {
+ display: flex;
+ align-items: center;
+ padding: $o-we-sidebar-content-block-spacing * .3 $o-we-sidebar-content-padding-base 0 $o-we-sidebar-content-indent;
+ background-color: $o-we-bg-light;
+ box-shadow: $o-we-item-standup-top rgba($o-we-item-standup-color-light, .2), $o-we-item-standup-bottom rgba($o-we-item-standup-color-dark, .5);
+ font-size: $o-we-sidebar-content-main-title-font-size;
+
+ > span {
+ @include o-text-overflow();
+ flex: 1 1 auto; // Make it full-width so that it is easier to click on
+ cursor: pointer;
+
+ color: $o-we-sidebar-content-main-title-color !important;
+ line-height: $o-we-sidebar-content-main-title-height;
+ }
+
+ > we-top-button-group {
+ flex: 0 0 auto;
+ display: flex;
+ margin-left: auto;
+ font-size: .9em;
+
+ .oe_snippet_remove {
+ font-size: 1.2em;
+ }
+ we-customizeblock-option {
+ display: flex;
+ padding: 0;
+ }
+ we-button {
+ margin-top: 0 !important;
+ margin-left: $o-we-sidebar-content-field-multi-spacing;
+ padding: 0 $o-we-sidebar-content-field-multi-spacing;
+
+ &.fa {
+ margin-left: $o-we-sidebar-content-field-label-spacing;
+ }
+ }
+ }
+ }
+ }
+
+ we-customizeblock-option {
+ position: relative;
+ display: block;
+ padding: 0 $o-we-sidebar-content-padding-base 0 $o-we-sidebar-content-indent;
+
+ .dropdown-menu {
+ // FIXME temporary fix for m2o option for example
+ position: static !important;
+ }
+
+ > we-alert {
+ background-color: $o-we-color-info;
+ display: block;
+ padding: $o-we-sidebar-content-field-label-spacing;
+
+ we-title {
+ margin-bottom: $o-we-sidebar-content-field-label-spacing;
+ text-transform: uppercase;
+ font-weight: bold;
+ }
+ }
+
+ > we-title {
+ margin-bottom: $o-we-sidebar-content-field-spacing * -0.5;
+ font-size: $o-we-sidebar-font-size + 1;
+ color: $o-we-fg-lighter;
+ font-weight: 500;
+
+ &:not(:first-child) {
+ margin-top: $o-we-sidebar-content-field-spacing * 2;
+ }
+ }
+ }
+
+ .o_we_fold_icon {
+ @include o-position-absolute(0, 100%, 0, -$o-we-sidebar-content-indent);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: $o-we-sidebar-content-indent;
+
+ @extend %we-generic-link;
+ }
+
+ //----------------------------------------------------------------------
+ // User Value Widgets
+ //----------------------------------------------------------------------
+
+ .o_we_user_value_widget {
+ @extend %o-we-inline;
+ margin-top: $o-we-sidebar-content-field-spacing;
+
+ > div {
+ display: flex;
+ align-items: center;
+ min-height: $o-we-sidebar-content-field-height;
+ }
+ }
+
+ // Buttons
+ we-button.o_we_user_value_widget {
+ > div {
+ // Needed otherwise cannot work because of flex display
+ @include o-text-overflow(block);
+ min-height: $o-we-sidebar-content-field-height - 2 * $o-we-sidebar-content-field-border-width;
+ line-height: $o-we-sidebar-content-field-height - 2 * $o-we-sidebar-content-field-border-width;
+
+ > img {
+ margin-bottom: 1px; // Not sure why but not really centered otherwise
+ }
+ > svg {
+ margin-bottom: 2px; // Not sure why but not really centered otherwise
+ }
+ }
+ &.fa > div {
+ display: none;
+ }
+ }
+
+ // Checkboxes
+ we-button.o_we_checkbox_wrapper.o_we_user_value_widget {
+ min-width: $o-we-sidebar-content-field-toggle-width;
+ padding: 0;
+ border: none;
+ background: none;
+ cursor: default;
+
+ > we-title {
+ cursor: pointer;
+ }
+ > div {
+ display: flex;
+ min-height: $o-we-sidebar-content-field-height;
+ line-height: $o-we-sidebar-content-field-height;
+ }
+ we-checkbox {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ width: $o-we-sidebar-content-field-toggle-width;
+ height: $o-we-sidebar-content-field-toggle-height;
+ background-color: $o-we-sidebar-content-field-toggle-bg;
+ border-radius: 10rem;
+ cursor: pointer;
+
+ &::after {
+ content: "";
+ display: block;
+ width: $o-we-sidebar-content-field-toggle-control-width;
+ height: $o-we-sidebar-content-field-toggle-control-height;
+ border-radius: 10rem;
+ background-color: $o-we-sidebar-content-field-toggle-control-bg;
+ box-shadow: $o-we-sidebar-content-field-toggle-control-shadow;
+ }
+ }
+ &.active we-checkbox {
+ background-color: $o-we-sidebar-content-field-toggle-active-bg;
+ justify-content: flex-end;
+ }
+ &.active, &:hover {
+ color: $o-we-sidebar-content-field-clickable-color;
+ }
+ }
+
+ // Selection (select and button groups)
+ we-selection-items {
+ .o_we_user_value_widget {
+ margin-top: 0;
+ }
+ }
+
+ // Selects
+ we-select.o_we_user_value_widget {
+ position: relative;
+
+ &:not(.o_we_icon_select) we-toggler {
+ @include large-component();
+ }
+ &.o_we_widget_opened .o_we_dropdown_caret {
+ position: relative;
+ display: block;
+ align-self: flex-end;
+
+ &::before, &::after {
+ content: '';
+ $-toggler-caret-size: 2 * $o-we-sidebar-content-field-control-item-spacing + $o-we-sidebar-content-field-control-item-size;
+ @include o-position-absolute(100%, $-toggler-caret-size);
+ z-index: $zindex-dropdown + 1;
+ transform: translateX(50%);
+ margin-top: $o-we-dropdown-caret-spacing;
+ border-bottom: ($o-we-item-spacing + $o-we-sidebar-content-field-dropdown-border-width - $o-we-dropdown-caret-spacing) solid $o-we-dropdown-border-color;
+ border-left: $o-we-item-spacing solid transparent;
+ border-right: $o-we-item-spacing solid transparent;
+ }
+ &::after {
+ border-bottom-color: $o-we-sidebar-content-field-dropdown-item-bg;
+ border-left-width: ($o-we-item-spacing - 1px);
+ border-right-width: ($o-we-item-spacing - 1px);
+ }
+ }
+ &:not(.o_we_so_color_palette) + we-button:not(:hover) {
+ background: none;
+ }
+
+ we-selection-items {
+ @include o-position-absolute(100%, 0, auto, 0);
+ z-index: $zindex-dropdown;
+ margin-top: $o-we-sidebar-content-field-dropdown-spacing !important;
+
+ &:not(.o_we_has_pager) {
+ max-height: 600px;
+ overflow-y: auto;
+ }
+
+ > we-title {
+ line-height: $o-we-sidebar-content-field-dropdown-item-height;
+ }
+
+ we-button {
+ @include we-icon-button('', $align: left); // Always a padding on the left
+ border: none;
+ background: none;
+ background-clip: padding-box;
+ background-color: $o-we-sidebar-content-field-dropdown-item-bg;
+ color: $o-we-sidebar-content-field-dropdown-item-color;
+
+ > we-title {
+ flex-grow: 1;
+ }
+
+ > div, > we-title {
+ line-height: $o-we-sidebar-content-field-dropdown-item-height;
+
+ img, svg {
+ max-width: 100%;
+ }
+ }
+
+ &:not(.d-none) ~ we-button {
+ // Use a border-top instead of a margin-top as when the
+ // mouse goes from one select button to another, the
+ // option preview should switch from the first button's
+ // option to the second one without reset to selected
+ // state in between.
+ border-top: $o-we-sidebar-content-field-dropdown-item-spacing solid transparent;
+ }
+
+ &:hover {
+ background-color: $o-we-sidebar-content-field-dropdown-item-bg-hover;
+ color: $o-we-sidebar-content-field-dropdown-item-hover-color;
+ }
+ &.active {
+ @include we-icon-button('\f00c', $align: left);
+ background-color: $o-we-sidebar-content-field-dropdown-item-active-bg;
+ color: $o-we-sidebar-content-field-dropdown-item-active-color;
+
+ &:after {
+ color: $o-we-accent;
+ }
+ }
+ }
+ }
+ .o_we_pager_header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: $o-we-sidebar-content-field-dropdown-item-bg;
+ margin-bottom: 1px;
+
+ & > b {
+ padding: $o-we-sidebar-content-field-label-spacing;
+ color: $o-we-fg-lighter;
+ }
+ }
+ .o_we_pager_controls {
+ display: flex;
+ align-items: center;
+
+ > span {
+ margin: 0 $o-we-sidebar-content-field-label-spacing;
+ }
+ }
+ .o_we_pager_next, .o_we_pager_prev {
+ margin: 0.3em;
+ padding: $o-we-sidebar-content-field-label-spacing;
+ cursor: pointer;
+ border: $o-we-item-border-width solid currentColor;
+ border-radius: $o-we-item-border-radius;
+ }
+ we-select-page {
+ display: none;
+ width: 100%;
+ // Cut the last visible option in the list to understand that we can scroll.
+ max-height: 75px * 7.5;
+ overflow-y: auto;
+
+ &.active {
+ display: block;
+ }
+ }
+ }
+
+ // Button groups
+ we-button-group.o_we_user_value_widget {
+ we-selection-items {
+ display: flex;
+ max-width: 100%;
+
+ we-button {
+ padding: 0 $o-we-sidebar-content-field-button-group-button-spacing;
+ border-radius: 0;
+
+ + we-button {
+ border-left: none;
+ }
+ &:first-child {
+ @include border-left-radius($o-we-sidebar-content-field-border-radius);
+ }
+ &:last-child {
+ @include border-right-radius($o-we-sidebar-content-field-border-radius);
+ }
+ }
+ }
+ }
+ // Only when main option (not in a we-row or something like that...)
+ we-customizeblock-option > we-button-group.o_we_user_value_widget we-selection-items {
+ @include large-component();
+
+ we-button {
+ display: flex;
+ justify-content: center;
+ flex: 0 1 percentage(1/4);
+ padding: ($o-we-sidebar-content-field-button-group-button-spacing / 4) ($o-we-sidebar-content-field-button-group-button-spacing / 3);
+ text-align: center;
+ }
+ }
+
+ // Inputs
+ we-input.o_we_user_value_widget {
+
+ > div {
+ flex: 0 1 auto;
+ width: $o-we-sidebar-content-field-input-max-width;
+ border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color;
+ border-radius: $o-we-sidebar-content-field-border-radius;
+ background-color: $o-we-sidebar-content-field-input-bg;
+
+ &:focus-within {
+ border-color: $o-we-sidebar-content-field-input-border-color;
+ }
+
+ > we-button { // for input-group
+ border: none;
+ }
+ }
+
+ &.o_we_large_input > div {
+ flex: 1 1 auto;
+ }
+
+ input {
+ box-sizing: content-box;
+ flex: 1 1 auto;
+ width: 0;
+ min-width: 2ch;
+ height: $o-we-sidebar-content-field-height - 2 * $o-we-sidebar-content-field-border-width;
+ padding: 0 $o-we-sidebar-content-field-clickable-spacing;
+ border: none;
+ border-radius: 0;
+ background-color: transparent;
+ color: inherit;
+ font-family: $o-we-sidebar-content-field-input-font-family;
+
+ &::placeholder {
+ color: $o-we-sidebar-content-field-control-item-color;
+ }
+ &:focus {
+ outline: none;
+ }
+ }
+ span {
+ flex: 0 0 auto;
+ padding-right: $o-we-sidebar-content-field-label-spacing;
+ font-size: $o-we-sidebar-content-field-input-unit-font-size;
+ color: $o-we-sidebar-content-field-control-item-color;
+ }
+ }
+
+ // Color Pickers
+ .o_we_so_color_palette.o_we_user_value_widget {
+
+ .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-sidebar-content-field-border-color;
+ border-radius: 10rem;
+
+ &::after {
+ box-shadow: $o-we-sidebar-content-field-colorpicker-shadow;
+ }
+ }
+
+ &.o_we_widget_opened {
+
+ .o_we_color_preview {
+ border: 2px solid $o-we-accent;
+ }
+ .o_we_dropdown_caret {
+ &::before, &::after {
+ right: $o-we-sidebar-content-field-colorpicker-size / 2;
+ }
+ &::after {
+ border-bottom-width: ($o-we-item-spacing + $o-we-sidebar-content-field-dropdown-border-width - $o-we-dropdown-caret-spacing) + 1px; // 1px = colorpicker inset box-shadow...
+ }
+ }
+ }
+
+ we-toggler {
+ display: none;
+ }
+ }
+
+ // Matrix (e.g. Chart Snippet)
+ we-matrix {
+ overflow-y: auto;
+
+ table {
+ table-layout: fixed;
+ width: 100%;
+
+ td, th {
+ text-align: center;
+ we-button {
+ display: inline-block;
+ color: inherit;
+ height: 100%;
+
+ &.o_we_matrix_remove_col, &.o_we_matrix_remove_row {
+ display: none;
+ }
+ }
+ input {
+ border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color;
+ background-color: $o-we-sidebar-content-field-input-bg;
+ color: inherit;
+ font-size: 12px;
+ width: 100%;
+ }
+ &:last-child {
+ width: 28px;
+ }
+ }
+ &.o_we_matrix_five_col {
+ width: auto;
+ td, th {
+ input {
+ width: 43px;
+ }
+ }
+ }
+ }
+ }
+
+ // Progress bar widget
+ we-range.o_we_user_value_widget {
+
+ input[type="range"] {
+ @include large-component();
+ height: $o-we-sidebar-content-field-height;
+ padding: 0 $o-we-item-border-width 0 0;
+ background-color: transparent;
+ appearance: none;
+
+ &:focus {
+ outline: none;
+
+ &::-webkit-slider-thumb { box-shadow: none; }
+ &::-moz-range-thumb { box-shadow: none; }
+ &::-ms-thumb { box-shadow: none; }
+ }
+ &::-moz-focus-outer {
+ border: 0;
+ }
+ &::-webkit-slider-thumb {
+ width: $o-we-sidebar-content-field-progress-control-height;
+ height: $o-we-sidebar-content-field-progress-control-height;
+ margin-top: ($o-we-sidebar-content-field-progress-height - $o-we-sidebar-content-field-progress-control-height) / 2;
+ border: none;
+ border-radius: 10rem;
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ box-shadow: none;
+ appearance: none;
+
+ &:active {
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ }
+ }
+ &::-webkit-slider-runnable-track {
+ width: 100%;
+ height: $o-we-sidebar-content-field-progress-height;
+ cursor: pointer;
+ // Unfortunately, Chrome does not support customizing the lower part of the track
+ background-color: $o-we-sidebar-content-field-progress-color;
+ border-color: transparent;
+ border-radius: 10rem;
+ box-shadow: none;
+
+ position: relative;
+ // z-index: 1000;
+ }
+ &::-moz-range-thumb {
+ width: $o-we-sidebar-content-field-progress-control-height;
+ height: $o-we-sidebar-content-field-progress-control-height;
+ border: none;
+ border-radius: 10rem;
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ box-shadow: none;
+ appearance: none;
+
+ &:active {
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ }
+ }
+ &::-moz-range-track {
+ width: 100%;
+ height: $o-we-sidebar-content-field-progress-height;
+ cursor: pointer;
+ background-color: $o-we-sidebar-content-field-progress-color;
+ border-color: transparent;
+ border-radius: 10rem;
+ box-shadow: none;
+ }
+ &::-moz-range-progress {
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ height: $o-we-sidebar-content-field-progress-height;
+ border-color: transparent;
+ border-radius: 10rem;
+ }
+ &::-ms-thumb {
+ width: $o-we-sidebar-content-field-progress-control-height;
+ height: $o-we-sidebar-content-field-progress-control-height;
+ margin-top: 0;
+ margin-right: 0;
+ margin-left: 0;
+ border: none;
+ border-radius: 10rem;
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ box-shadow: none;
+ appearance: none;
+
+ &:active {
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ }
+ }
+ &::-ms-track {
+ width: 100%;
+ height: $o-we-sidebar-content-field-progress-height;
+ cursor: pointer;
+ background-color: transparent;
+ border-color: transparent;
+ border-width: $o-we-sidebar-content-field-progress-control-height / 2;
+ box-shadow: none;
+ }
+ &::-ms-fill-lower {
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ border-radius: 10rem;
+ @include border-radius($custom-range-track-border-radius);
+ }
+ &::-ms-fill-upper {
+ background-color: $o-we-sidebar-content-field-progress-color;
+ border-radius: 10rem;
+ }
+
+ &.o_we_inverted_range {
+ transform: rotate(180deg);
+
+ &::-moz-range-track {
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ }
+ &::-moz-range-progress {
+ background-color: $o-we-sidebar-content-field-progress-color;
+ }
+ &::-ms-fill-lower {
+ background-color: $o-we-sidebar-content-field-progress-color;
+ }
+ &::-ms-fill-upper {
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ }
+ }
+ }
+ }
+
+ // Multi widgets
+ we-multi.o_we_user_value_widget {
+ margin-top: 0;
+
+ > div {
+ flex-flow: row wrap;
+
+ > * {
+ flex: 1 1 auto; // Needed for colorpicker...
+ }
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Layout Utils
+ //----------------------------------------------------------------------
+
+ we-row {
+ position: relative;
+ @extend %o-we-inline;
+ margin-top: $o-we-sidebar-content-field-spacing;
+
+ .o_we_user_value_widget {
+ margin-top: 0;
+ min-width: 4em; // Ideally rely on actual natural min-width, but does not work...
+ }
+ we-button, .o_we_so_color_palette {
+ &.o_we_user_value_widget {
+ min-width: auto; // ... except for these ones
+ }
+ }
+
+ > div {
+ display: flex;
+ align-items: center;
+
+ > :not(.d-none) ~ * {
+ margin-left: $o-we-sidebar-content-field-multi-spacing;
+ }
+ }
+
+ we-select.o_we_user_value_widget {
+ position: static;
+ }
+
+ &.o_we_full_row > div {
+ flex: 1 1 auto;
+ }
+ }
+
+ %o-we-inline {
+ display: flex;
+ flex-flow: row wrap;
+
+ > we-title {
+ width: 100%;
+ }
+ > div {
+ flex: 0 1 auto;
+ min-width: 0;
+ margin-top: $o-we-sidebar-content-field-spacing;
+ }
+
+ &:not(.o_we_fw) {
+ flex-flow: row nowrap;
+ align-items: center;
+
+ > we-title {
+ flex: 0 0 auto;
+ @include o-text-overflow();
+ width: $o-we-sidebar-content-field-label-width;
+ padding-right: $o-we-sidebar-content-field-label-spacing;
+ }
+ > div {
+ margin-top: 0;
+ }
+ }
+ }
+
+ we-collapse {
+ position: relative;
+ display: block;
+ padding-left: $o-we-sidebar-content-indent;
+ padding-right: $o-we-sidebar-content-padding-base;
+ margin-right: -$o-we-sidebar-content-padding-base;
+ margin-left: -$o-we-sidebar-content-indent;
+ // Allow inner margin to be considered inside the block + Visual space after/before previous/next widget + No flickering on opening
+ $-inner-spacing: ceil($o-we-sidebar-content-field-spacing / 2);
+ border-top: ($o-we-sidebar-content-field-spacing - $-inner-spacing) solid transparent;
+ padding-bottom: ($o-we-sidebar-content-field-spacing - $-inner-spacing);
+ margin-bottom: -($o-we-sidebar-content-field-spacing - $-inner-spacing);
+ background-clip: padding-box;
+
+ > :first-child, .o_we_collapse_toggler {
+ margin-top: $-inner-spacing;
+ }
+ .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;
+
+ &::after {
+ content: '\f0da';
+ position: static;
+ transform: none;
+ }
+ &.active {
+
+ &::after {
+ content: '\f0d7';
+ }
+ + * {
+ background: none;
+ border: none;
+ box-shadow: none;
+ }
+ }
+ }
+ &.active {
+ background-color: $o-we-sidebar-content-fold-block-bg;
+ box-shadow: $o-we-item-standup-top rgba($o-we-item-standup-color-dark, .5), $o-we-item-standup-bottom rgba($o-we-item-standup-color-light, .2);
+
+ we-collapse.active, we-collapse.active .o_we_collapse_toggler {
+ background-color: $o-we-bg-lighter;
+ }
+ }
+ }
+
+ .o_we_image_weight {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ margin: $o-we-sidebar-content-field-spacing *.25 $o-we-item-border-width $o-we-sidebar-content-field-spacing*2;
+
+ b {
+ margin-left: $o-we-sidebar-content-field-label-spacing;
+ font: 1em/1 bold $o-we-sidebar-content-field-input-font-family;
+ color: $o-we-color-success;
+ }
+ }
+
+ .o_we_external_warning {
+ margin-top: $o-we-sidebar-content-field-spacing;
+ }
+
+ .o_we_tag {
+ padding: ($o-we-sidebar-content-field-label-spacing / 2) $o-we-sidebar-content-field-label-spacing;
+ border-radius: 5px;
+ background-color: $o-we-bg-darkest;
+
+ + .fa {
+ margin: 0 0 0 5px;
+ }
+ }
+ .o_we_tag_wrapper {
+ display: inline-flex;
+ margin: $o-we-sidebar-content-field-label-spacing ($o-we-sidebar-content-field-label-spacing / 2) 0 0;
+ }
+ .o_wblog_new_tag {
+ & div, & we-input {
+ width: 100% !important;
+ }
+ }
+ }
+
+ > .o_we_invisible_el_panel {
+ flex: 0 0 auto;
+ max-height: 220px;
+ overflow-y: auto;
+ margin-top: 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;
+ }
+ }
+ }
+
+ &.o_we_backdrop {
+ > .o_we_customize_panel {
+ // Ensure the panel takes full height so that an opened dropdown
+ // does not make a scrollbar appear for no reason
+ flex: 1 1 auto;
+
+ &::after {
+ content: "";
+ @include o-position-absolute(0, 0, 0, 0);
+ display: block;
+ pointer-events: none;
+ background: $o-we-sidebar-content-backdrop-bg;
+ }
+ }
+
+ .o_we_widget_opened {
+ z-index: $zindex-dropdown;
+ }
+ }
+}
+
+.o_we_cc_preview_wrapper {
+ @extend %o-preview-alpha-background;
+ font-family: sans-serif !important;
+ font-size: 15px !important;
+ padding: $o-we-sidebar-content-field-spacing $o-we-sidebar-content-field-spacing $o-we-sidebar-content-field-spacing*.8;
+}
+.o_we_cc_preview_wrapper > * {
+ margin-bottom: 0 !important;
+ line-height: 1 !important;
+}
+.o_we_color_combination_btn_text {
+ color: inherit !important;
+ font-family: inherit !important;
+ font-size: 0.8em !important;
+ margin-top: .5em!important;
+}
+.o_we_color_combination_btn_title {
+ margin-top: 0 !important;
+ font-size: 1.3em !important;
+}
+.o_we_color_combination_btn_btn {
+ padding: 0.2em 3px 0.3em !important;
+ border-radius: 2px !important;
+ font-size: 0.8em !important;
+}
+
+// SNIPPET OPTIONS
+.colorpicker {
+ background-color: $o-we-sidebar-content-field-colorpicker-dropdown-bg;
+ color: $o-we-sidebar-content-field-colorpicker-dropdown-color;
+
+ .o_we_colorpicker_switch_panel {
+ border-bottom: 1px solid $o-we-bg-dark;
+ box-shadow: inset 0 1px 0 rgba(white, .2);
+ }
+ .o_we_colorpicker_switch_pane_btn {
+ @extend %we-generic-tab-button;
+ flex: 0 0 auto;
+ }
+ .o_colorpicker_reset {
+ max-width: 40%;
+ @extend %we-generic-button;
+ }
+ .o_colorpicker_sections {
+ .o_colorpicker_widget {
+ .o_hex_div, .o_rgba_div {
+ &:focus-within {
+ border-color: $o-we-sidebar-content-field-input-border-color;
+ }
+ }
+ .o_color_picker_inputs {
+ input {
+ border: none;
+ &:focus {
+ outline: none;
+ }
+ }
+ }
+ }
+
+ .o_we_color_btn, .o_we_color_combination_btn {
+ float: left;
+ width: percentage(1 / 8);
+ padding-top: percentage(1 / 10);
+ margin: 0;
+ border: 1px solid $o-we-sidebar-content-field-colorpicker-dropdown-bg;
+ box-shadow: $o-we-sidebar-content-field-colorpicker-shadow;
+
+ &.o_colorpicker_reset {
+ background-color: transparent;
+
+ &::before {
+ @include o-position-absolute(0, 0, 0, 0);
+ font-family: FontAwesome !important;
+ content: "\f00d" !important;// fa-times
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: $o-we-color-danger;
+ }
+ }
+ }
+ .o_we_color_combination_btn {
+ float: none;
+ width: 100%;
+ padding: 0;
+ margin: 0;
+ border: 0;
+ background-color: transparent;
+ background-clip: padding-box;
+
+ // Borders instead of margins so that the user smoothly goes from
+ // one button to another without leaving them.
+ border-top: $o-we-sidebar-content-field-spacing solid transparent;
+ border-bottom: $o-we-sidebar-content-field-spacing solid transparent;
+
+ + .o_we_color_combination_btn {
+ margin-top: $o-we-sidebar-content-field-spacing * -.5;
+ }
+
+ &.selected {
+ > .o_we_cc_preview_wrapper {
+ box-shadow: 0 0 0 1px $o-we-color-success !important;
+ }
+ .o_we_color_combination_btn_title::before {
+ content: "\f00c";
+ margin-right: $o-we-sidebar-content-field-spacing;
+ font-size: 0.8em;
+ font-family: FontAwesome;
+ color: $o-we-color-success;
+ }
+ }
+
+ .o_we_cc_preview_wrapper:after {
+ // For some reasons, in this specific context we have to
+ // compensate a 1px gap between the flex container and the
+ // absolute positioned pseudo-element that generates the bg.
+ bottom: -1px;
+ }
+ }
+ .o_colorpicker_section {
+ padding-top: $o-we-sidebar-content-field-spacing;
+
+ &::after {
+ content: "";
+ display: table;
+ clear: both;
+ }
+
+ .o_we_color_btn {
+ position: relative;
+
+ &.selected {
+ box-shadow: inset 0 0 0 1px $o-we-sidebar-content-field-colorpicker-dropdown-bg,
+ inset 0 0 0 3px $o-we-accent,
+ inset 0 0 0 4px white;
+ }
+
+ &.o_btn_transparent::before {
+ background-color: transparent;
+ }
+ }
+
+ &[data-name="transparent_grayscale"], &[data-name="theme"], &[data-name="reset"] {
+ .o_we_color_btn {
+ @extend %o-preview-alpha-background;
+
+ &::before, &::after {
+ box-shadow: inherit;
+ }
+ }
+ }
+ }
+ }
+}
+
+// DROPZONES
+@keyframes dropZoneInsert {
+ to {
+ background-color: rgba($o-brand-odoo, 0.3);
+ }
+}
+
+.oe_drop_zone {
+ background-color: rgba($o-brand-odoo, 0.15);
+ animation: dropZoneInsert 1s linear 0s infinite alternate;
+
+ &.oe_insert {
+ position: relative;
+ z-index: $o-we-overlay-zindex;
+ width: 100%;
+ min-width: $o-we-dropzone-size;
+ height: $o-we-dropzone-size;
+ min-height: $o-we-dropzone-size;
+ margin: (-$o-we-dropzone-size/2) 0;
+ border: 2px dashed $o-we-border-color;
+
+ &.oe_vertical {
+ width: $o-we-dropzone-size;
+ float: left;
+ margin: 0 (-$o-we-dropzone-size/2);
+ }
+ }
+}
+
+// MANIPULATORS
+#oe_manipulators {
+ position: relative;
+ z-index: $o-we-overlay-zindex;
+
+ // SNIPPET MANIPULATORS
+ .oe_overlay {
+ @include o-position-absolute;
+ display: none;
+ height: 0;
+ border-color: $o-we-handles-accent-color;
+ background: transparent;
+ text-align: center;
+ transition: opacity 400ms linear 0s;
+
+ &.o_overlay_hidden {
+ opacity: 0;
+ transition: none;
+ }
+ &.oe_active {
+ display: block;
+ z-index: 1;
+ }
+
+ // HANDLES
+ > .o_handles {
+ @include o-position-absolute(-$o-we-handles-offset-to-hide, 0, auto, 0);
+ border-color: inherit;
+
+ &:hover > .o_handle {
+ background-color: rgba($o-we-handles-accent-color, 0.05);
+ }
+ > .o_handle {
+ position: relative;
+ border: 0 solid transparent;
+ border-color: inherit;
+ transition: background 300ms ease 0s;
+
+ &.w {
+ @include o-position-absolute($o-we-handles-offset-to-hide, auto, -$o-we-handles-offset-to-hide, 0);
+ width: $o-we-handle-edge-size;
+ border-width: $o-we-handle-border-width;
+ border-right-width: 0;
+ cursor: e-resize;
+
+ &:after {
+ @include o-position-absolute($top: 50%, $left: 40%);
+ margin-top: -$o-we-handles-btn-size/2;
+ }
+ }
+ &.e {
+ @include o-position-absolute($o-we-handles-offset-to-hide, 0, -$o-we-handles-offset-to-hide, auto);
+ width: $o-we-handle-edge-size;
+ border-right-width: $o-we-handle-border-width;
+ cursor: w-resize;
+
+ &:after {
+ @include o-position-absolute($top: 50%, $right: 40%);
+ margin-top: -$o-we-handles-btn-size/2;
+ }
+ }
+ &.n {
+ @include o-position-absolute($o-we-handles-offset-to-hide, 0, auto, 0);
+ height: $o-we-handle-edge-size;
+ border-top-width: $o-we-handle-border-width;
+ cursor: ns-resize;
+
+ &:after {
+ @include o-position-absolute($left: 50%, $top: 40%);
+ margin-left: -$o-we-handles-btn-size/2;
+ }
+ }
+ &.s {
+ @include o-position-absolute(auto, 0, -$o-we-handles-offset-to-hide, 0);
+ height: $o-we-handle-edge-size;
+ border-bottom-width: $o-we-handle-border-width;
+ cursor: ns-resize;
+
+ &:after {
+ @include o-position-absolute($left: 50%, $bottom: 40%);
+ margin-left: -$o-we-handles-btn-size/2;
+ }
+ }
+
+ &::after {
+ z-index: 1;
+ display: block;
+ width: $o-we-handles-btn-size;
+ height: $o-we-handles-btn-size;
+ border: solid 1px darken($o-we-handles-accent-color, 20%);
+ line-height: $o-we-handles-btn-size - 2;
+ font-size: 14px;
+ font-family: FontAwesome;
+ background-color: darken($o-we-handles-accent-color, 10%);
+ color: white;
+ }
+
+ &.o_handle_start:after {
+ background-color: rgba($o-we-sidebar-content-field-clickable-bg, .6);
+ border-color: rgba($o-we-sidebar-content-field-border-color, .2);
+ }
+
+ &:hover,
+ &.o_active {
+ background-color: rgba($o-we-handles-accent-color, 0.2);
+
+ &::after {
+ border-color: darken($o-we-handles-accent-color, 10%);
+ background-color: darken($o-we-handles-accent-color, 20%);
+ }
+ }
+
+ &.w:after,
+ &.e:after {
+ content: "\f07e";
+ }
+
+ &.s:after,
+ &.n:after {
+ content: "\f07d";
+ }
+
+ &.o_handle_start {
+
+ &.w:after,
+ &.e:after {
+ content: '\f061';
+ }
+
+ &.n:after,
+ &.s:after {
+ content: '\f063';
+ }
+ }
+
+ &.o_handle_end {
+
+ &.w:after,
+ &.e:after {
+ content: '\f060';
+ }
+
+ &.n:after,
+ &.s:after {
+ content: '\f062';
+ }
+ }
+
+ &.readonly {
+ cursor: auto !important;
+
+ &:after {
+ display: none !important;
+ }
+
+ &:hover {
+ opacity: 0.5;
+ }
+ }
+ }
+
+ > .o_overlay_options_wrap {
+ @include o-position-absolute($o-we-handles-offset-to-hide, $left: 50%);
+ transform: translate(-50%, -110%);
+
+ &, > .o_overlay_move_options {
+ display: flex;
+ }
+ > .o_overlay_move_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;
+ }
+ }
+ > .oe_snippet_remove {
+ margin-left: $o-we-sidebar-content-field-button-group-button-spacing;
+ background-color: mix($o-we-color-danger, $o-we-sidebar-content-field-clickable-bg);;
+ }
+ > .o_overlay_move_options > .o_move_handle {
+ cursor: move;
+ width: 30px;
+ height: 22px;
+ background-image: url('/web_editor/static/src/img/snippets_options/o_overlay_move_drag.svg');
+ background-position: center;
+ background-repeat: no-repeat;
+ }
+ &:hover {
+ > .o_overlay_move_options {
+ > *, + * {
+ @include o-hover-opacity(.6);
+
+ &:hover {
+ border-color: mix($o-we-handles-accent-color, $o-we-sidebar-content-field-pressed-bg, .4);
+ background-color: $o-we-sidebar-content-field-pressed-bg;
+ }
+ }
+ }
+ > .oe_snippet_remove:hover {
+ border-color: mix($o-we-color-danger, $o-we-sidebar-content-field-pressed-bg, .4);
+ background-color: $o-we-color-danger;
+ }
+ }
+ }
+ }
+
+ &.o_top_cover > .o_handles > .o_overlay_options_wrap {
+ top: auto;
+ bottom: -$o-we-handles-offset-to-hide;
+ transform: translate(-50%, 110%);
+ }
+
+ &.o_we_overlay_preview {
+ pointer-events: none;
+
+ > .o_handles {
+
+ > .o_handle::after, .o_overlay_options_wrap {
+ display: none;
+ }
+ }
+ }
+
+ // Background position overlay
+ &.o_we_background_position_overlay {
+ background-color: rgba(0,0,0,.7);
+ z-index: auto;
+
+ .o_we_overlay_content {
+ cursor: grab;
+
+ .o_we_grabbing {
+ cursor: grabbing;
+ }
+ }
+
+ .o_overlay_background > * {
+ display: block !important;
+ top: 0 !important;
+ right: 0 !important;
+ bottom: 0 !important;
+ left: 0 !important;
+ transform: none !important;
+ max-width: unset !important;
+ max-height: unset !important;
+ }
+ }
+ }
+}
+
+.s-resize-important * {
+ cursor: s-resize !important;
+}
+
+.n-resize-important * {
+ cursor: n-resize !important;
+}
+
+.e-resize-important * {
+ cursor: e-resize !important;
+}
+
+.w-resize-important * {
+ cursor: w-resize !important;
+}
+
+.move-important * {
+ cursor: move !important;
+}
+
+.dropdown-menu label .o_switch {
+ margin: 0;
+ padding: 2px 0;
+}
+
+.text-input-group {
+ position: relative;
+ margin-bottom: 45px;
+
+ input {
+ font-size: 18px;
+ padding: 10px 10px 10px 5px;
+ display: block;
+ width: 300px;
+ border: none;
+ border-bottom: 1px solid #757575;
+ }
+
+ input:focus {
+ outline: none;
+ }
+
+ /* LABEL ======================================= */
+ label {
+ color: #999;
+ font-size: 18px;
+ font-weight: normal;
+ @include o-position-absolute($top: 10px, $left: 5px);
+ pointer-events: none;
+ transition: 0.2s ease all;
+ }
+
+ /* active state */
+ input:focus~label,
+ input:valid~label {
+ top: -20px;
+ font-size: 14px;
+ color: #5264AE;
+ }
+
+ /* BOTTOM BARS ================================= */
+ .bar {
+ position: relative;
+ display: block;
+ width: 300px;
+ }
+
+ .bar:before,
+ .bar:after {
+ content: '';
+ height: 2px;
+ width: 0;
+ bottom: 1px;
+ @include o-position-absolute;
+ background: #5264AE;
+ transition: 0.2s ease all;
+ }
+
+ .bar:before {
+ left: 50%;
+ }
+
+ .bar:after {
+ right: 50%;
+ }
+
+ /* active state */
+ input:focus~.bar:before,
+ input:focus~.bar:after {
+ width: 50%;
+ }
+
+ /* HIGHLIGHTER ================================== */
+ .highlight {
+ @include o-position-absolute($top: 25%, $left: 0);
+ height: 60%;
+ width: 100px;
+ pointer-events: none;
+ opacity: 0.5;
+ }
+
+ /* active state */
+ input:focus~.highlight {
+ animation: inputHighlighter 0.3s ease;
+ }
+}
+
+// DRAG&DROP ANIMATIONS
+.oe_snippet_body {
+ opacity: 0;
+ animation: fadeInDownSmall 700ms forwards;
+}
+
+// CONTAINER PREVIEW
+.o_container_preview {
+ outline: 2px dashed $o-we-handles-accent-color;
+}
+
+we-select.o_we_shape_menu {
+ we-button[data-shape] {
+ padding: 0 !important;
+
+ &.active {
+ border: 1px solid $o-we-color-success !important;
+ }
+ div {
+ width: 100%;
+ }
+ .o_we_shape_btn_content {
+ @extend %o-preview-alpha-background;
+ width: 100%;
+ height: 75px;
+ }
+ }
+}
+
+.o_we_ui_loading {
+ @include o-position-absolute(0, 0, 0, 0);
+ z-index: $o-we-zindex;
+ background-color: $o-we-sidebar-content-backdrop-bg;
+ color: $o-we-fg-lighter;
+}
+#oe_manipulators > .o_we_ui_loading {
+ // hacky solution to be over the content, ideally that loader should only
+ // be over the content being reloaded (with a covering similar to the editor
+ // overlay covering).
+ position: fixed;
+ right: $o-we-sidebar-width;
+}
+
+.o_we_force_no_transition {
+ // Note: this is forced through a CSS class instead of inline style to avoid
+ // overridding existing inline styles or forgetting to restore them as the
+ // code evolves. We may need to increase the CSS priority of this. It will
+ // also not work to override important inline style... this is a limitation.
+ transition: none !important;
+}
diff --git a/addons/web_editor/static/src/xml/ace.xml b/addons/web_editor/static/src/xml/ace.xml
new file mode 100644
index 00000000..be9fe53d
--- /dev/null
+++ b/addons/web_editor/static/src/xml/ace.xml
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates>
+
+<t t-name="web_editor.ace_view_editor">
+ <div class="o_ace_view_editor">
+ <div class="form-inline o_ace_view_editor_title">
+ <div class="btn-group o_ace_type_switcher">
+ <button type="button" class="btn btn-info dropdown-toggle" data-toggle="dropdown">XML (HTML)</button>
+ <div class="dropdown-menu" role="menu">
+ <a role="menuitem" href="#" class="dropdown-item o_ace_type_switcher_choice" data-type="xml">XML (HTML)</a>
+ <a role="menuitem" href="#" class="dropdown-item o_ace_type_switcher_choice" data-type="scss">SCSS (CSS)</a>
+ <a role="menuitem" href="#" class="dropdown-item o_ace_type_switcher_choice" data-type="js">JS</a>
+ </div>
+ </div>
+ <select id="ace-view-list" class="o_res_list"/>
+ <select id="ace-scss-list" class="o_res_list d-none"/>
+ <select id="ace-js-list" class="o_res_list d-none"/>
+ <label class="o_include_option oe_include_bundles">
+ <div class="dropdown">
+ <button class="btn btn-primary dropdown-toggle" type="button" id="o_dropdown_filter_views" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+ Filter
+ </button>
+ <div class="dropdown-menu" aria-labelledby="o_dropdown_filter_views">
+ <a class="dropdown-item o_ace_filter active" data-type="xml" data-value="default" href="#">Only Views</a>
+ <a class="dropdown-item o_ace_filter" data-type="xml" data-value="all" href="#">Views and Assets bundles</a>
+ </div>
+ </div>
+ </label>
+ <label class="o_include_option o_include_all_scss d-none">
+ <div class="dropdown">
+ <button class="btn btn-primary dropdown-toggle" type="button" id="o_dropdown_filter_assets" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+ Filter
+ </button>
+ <div class="dropdown-menu" aria-labelledby="o_dropdown_filter_assets">
+ <a class="dropdown-item o_ace_filter active" data-type="scss" data-value="custom" href="#">Only Custom SCSS Files</a>
+ <a class="dropdown-item o_ace_filter" data-type="scss" data-value="restricted" href="#">Only Page SCSS Files</a>
+ <a class="dropdown-item o_ace_filter" data-type="scss" data-value="all" href="#">All SCSS Files</a>
+ </div>
+ </div>
+ </label>
+ <div class="o_button_section">
+ <button data-action="save" type="submit" class="btn btn-primary">Save</button>
+ <button data-action="close" type="button" class="btn btn-secondary">Close</button>
+ </div>
+ </div>
+ <div id="ace-view-id">
+ <div class="float-right mb-2">
+ <button data-action="reset" type="button" class="btn btn-sm btn-danger"><i class="fa fa-undo"/> Reset</button>
+ <button data-action="format" type="button" class="btn btn-sm btn-link">Format</button>
+ </div>
+ <span class="o_ace_editor_resource_info"/>
+ <div class="alert alert-warning alert-dismissible mt-2 mb-0" role="alert">
+ Editing a built-in file through this editor is not advised, as it will prevent it from being updated during future App upgrades.
+ <button type="button" class="close" aria-label="Close">
+ <span>×</span>
+ </button>
+ </div>
+ </div>
+ <div id="ace-view-editor"/>
+ </div>
+</t>
+
+</templates>
diff --git a/addons/web_editor/static/src/xml/backend.xml b/addons/web_editor/static/src/xml/backend.xml
new file mode 100644
index 00000000..c79ee939
--- /dev/null
+++ b/addons/web_editor/static/src/xml/backend.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="web_editor.FieldTextHtml.button.translate">
+ <div class="btn-group float-right">
+ <button t-if="widget.field.translate" class="o_field_translate btn btn-secondary" style="height: 24px; padding: 1px 17px 0px 5px" aria-label="Translate" title="Translate">
+ <span class="fa fa-language fa-lg oe_input_icon"/>
+ </button>
+ </div>
+ </t>
+
+ <t t-name="web_editor.FieldTextHtml.fullscreen">
+ <span style="margin: 5px; position: fixed; top: 0; right: 0; z-index: 2000;">
+ <button class="o_fullscreen btn btn-primary" style="width: 24px; height: 24px; background-color: #337ab7; border: 1px solid #2e6da4; border-radius: 4px; padding: 0; position: relative;">
+ <img src="/web_editor/font_to_img/61541/rgb(255,255,255)/16" style="position: absolute; top: 3px; left: 4px;" alt="Fullscreen"/>
+ </button>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/web_editor/static/src/xml/editor.xml b/addons/web_editor/static/src/xml/editor.xml
new file mode 100644
index 00000000..ee766a81
--- /dev/null
+++ b/addons/web_editor/static/src/xml/editor.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="utf-8"?>
+<templates id="template" xml:space="preserve">
+ <!--=================-->
+ <!-- Base components -->
+ <!--=================-->
+
+ <!-- Editor top bar which contains the summernote tools and save/discard buttons -->
+ <t t-name="web_editor.editorbar">
+ <div id="web_editor-toolbars"/>
+ </t>
+
+ <!--=================-->
+ <!-- Snippet options -->
+ <!--=================-->
+
+ <!-- Background position option overlay -->
+ <t t-name="web_editor.background_position_overlay">
+ <div class="o_we_background_position_overlay oe_overlay">
+ <div class="o_we_overlay_content position-absolute">
+ <div class="o_overlay_background"/>
+ <div class="o_we_overlay_buttons position-absolute d-flex m-1" style="top: 0">
+ <button class="btn btn-primary mr-1 o_btn_apply">Apply</button>
+ <button class="btn btn-danger o_btn_discard">Discard</button>
+ </div>
+ </div>
+ </div>
+ </t>
+ <!-- Save Snippet Name option dialog -->
+ <t t-name="web_editor.dialog.save_snippet">
+ <div>
+ <div class="form-group form-row">
+ <label class="col-form-label col-md-3" for="snippetName">Name</label>
+ <div class="col-md-9">
+ <input type="text"
+ class="form-control o_we_snippet_name_input"
+ id="snippetName"
+ t-att-value="currentSnippetName"/>
+ </div>
+ </div>
+ </div>
+ </t>
+</templates>
diff --git a/addons/web_editor/static/src/xml/snippets.xml b/addons/web_editor/static/src/xml/snippets.xml
new file mode 100644
index 00000000..0d833333
--- /dev/null
+++ b/addons/web_editor/static/src/xml/snippets.xml
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="utf-8"?>
+<templates id="template" xml:space="preserve">
+ <t t-name="web_editor.snippet_overlay">
+ <div class="oe_overlay">
+ <div class="o_handles">
+ <!-- Visible overlay borders + allow to resize when not readonly -->
+ <div class="o_handle n readonly"/>
+ <div class="o_handle e readonly"/>
+ <div class="o_handle w readonly"/>
+ <div class="o_handle s readonly"/>
+
+ <div class="o_overlay_options_wrap">
+ <!-- Overlay move specific section -->
+ <div class="o_overlay_move_options">
+ <!-- Button-like handle to drag and drop -->
+ <div class="o_move_handle"/>
+ </div>
+ <button type="button" class="oe_snippet_remove fa fa-trash"/>
+ </div>
+ </div>
+ </div>
+ </t>
+ <t t-name="web_editor.customize_block_options_section">
+ <we-customizeblock-options>
+ <we-title>
+ <span t-esc="name"/>
+ <we-top-button-group>
+ <we-button class="fa fa-fw fa-clone oe_snippet_clone o_we_link o_we_hover_success"
+ title="Duplicate Container"
+ aria-label="Duplicate Container"/>
+ <we-button class="fa fa-fw fa-trash oe_snippet_remove o_we_link o_we_hover_danger"
+ title="Remove Block"
+ aria-label="Remove Block"/>
+ </we-top-button-group>
+ </we-title>
+ </we-customizeblock-options>
+ </t>
+ <t t-name="web_editor.outdated_block_message">
+ <we-alert>
+ <we-title>This block is outdated</we-title>
+ <span>To make changes, drop this block and use the new options in the last version.</span>
+ </we-alert>
+ </t>
+
+ <!-- options -->
+ <div t-name="web_editor.snippet.option.colorpicker" class="colorpicker">
+ <div class="o_we_colorpicker_switch_panel d-flex justify-content-end px-2">
+ <t t-if="widget.withCombinations">
+ <button type="button" tabindex="1" class="o_we_colorpicker_switch_pane_btn active" data-target="color-combinations">
+ <span>Combinations</span>
+ </button>
+ <button type="button" tabindex="2" t-attf-class="o_we_colorpicker_switch_pane_btn #{widget.withCombinations ? '' : 'active'}" data-target="theme-colors">
+ <span>Custom</span>
+ </button>
+ </t>
+ <button t-if="widget.resetButton" type="button" class="my-1 ml-auto o_we_color_btn o_colorpicker_reset">
+ <t t-if="widget.withCombinations">None</t>
+ <t t-else="">Reset</t>
+ </button>
+ </div>
+ <div t-attf-class="o_colorpicker_sections #{widget.withCombinations ? '' : 'd-none'} pt-2 px-2 pb-3 bg-black-25" data-color-tab="color-combinations">
+ <!-- List all Presets -->
+ <t t-foreach="5" t-as="i">
+ <t t-call="web_editor.colorpalette.combination.btn">
+ <t t-set="number" t-value="i + 1"/>
+ </t>
+ </t>
+ </div>
+ <div t-attf-class="o_colorpicker_sections #{widget.withCombinations ? 'd-none' : ''} py-3 px-2 bg-black-25" data-color-tab="theme-colors"/>
+ </div>
+ <t t-name="web_editor.colorpalette.combination.btn">
+ <button type="button" class="o_we_color_btn o_we_color_combination_btn"
+ t-att-data-color="number" t-attf-title="Preset #{number}">
+ <t t-call="web_editor.color.combination.preview"/>
+ </button>
+ </t>
+ <t t-name="web_editor.color.combination.preview">
+ <div class="o_we_cc_preview_wrapper d-flex justify-content-between">
+ <h1 class="o_we_color_combination_btn_title">Title</h1>
+ <p class="o_we_color_combination_btn_text flex-grow-1">Text</p>
+ <span class="o_we_color_combination_btn_btn btn btn-sm btn-primary o_btn_preview mr-1"><small>Button</small></span>
+ <span class="o_we_color_combination_btn_btn btn btn-sm btn-secondary o_btn_preview"><small>Button</small></span>
+ </div>
+ </t>
+
+ <t t-name="web_editor.many2one.button">
+ <div class="btn-group">
+ <a role="button" href="#" class="btn btn-secondary dropdown-toggle d-none" data-toggle="dropdown" data-hover="dropdown" title="Search Contact" aria-label="Search Contact">
+ <i class="fa fa-search"></i>
+ </a>
+ <ul class="dropdown-menu contact_menu d-block list-group list-group-flush mx-1" role="menu">
+ <li class="px-1"><a role="menuitem" class="dropdown-item pl-1 pr-2"><i class="fa fa-search"></i><input href="#" type="email" placeholder="Search" class="ml-2" autocomplete="off"/></a></li>
+ </ul>
+ </div>
+ </t>
+
+ <t t-name="web_editor.many2one.search">
+ <t t-foreach="contacts" t-as="item">
+ <li class="list-group-item px-2"><a role="menuitem" href="#" t-att-data-id="item.id" t-att-data-name="item.display_name"><t t-esc="item.display_name"/> <t t-if="item.city or item.country_id"><small class="text-muted">(<t t-esc="item.city"/> <t t-esc="item.country_id and item.country_id[1]"/>)</small></t></a></li>
+ </t>
+ </t>
+</templates>
diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml
new file mode 100644
index 00000000..3ca5030a
--- /dev/null
+++ b/addons/web_editor/static/src/xml/wysiwyg.xml
@@ -0,0 +1,579 @@
+<?xml version="1.0" encoding="utf-8"?>
+<templates id="template" xml:space="preserve">
+
+ <!--=================-->
+ <!-- Edition Iframe -->
+ <!--=================-->
+
+ <t t-name="wysiwyg.iframeContent"><head>
+ <meta charset="utf-8"/>
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
+ <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>
+ <t t-foreach="assets || []" t-as="asset">
+ <t t-foreach="asset.cssLibs || []" t-as="cssLib">
+ <link type="text/css" rel="stylesheet" t-att-href="cssLib"/>
+ </t>
+ <t t-foreach="asset.cssContents || []" t-as="cssContent">
+ <style type="text/css" t-raw="cssContent"/>
+ </t>
+ <t t-foreach="asset.jsLibs || []" t-as="jsLib">
+ <script type="text/javascript" t-att-src="jsLib"/>
+ </t>
+ <t t-foreach="asset.jsContents || []" t-as="jsContent" t-if="jsContent.indexOf('inline asset') !== -1">
+ <script type="text/javascript" t-raw="jsContent"/>
+ </t>
+ </t>
+ <script type="text/javascript">
+ odoo.define('web.session', function () {
+ return window.top.odoo.__DEBUG__.services['web.session'];
+ });
+
+ odoo.define('root.widget', function (require) {
+ 'use strict';
+ var Widget = require('web.Widget');
+ var widget = new Widget();
+ widget.appendTo(document.body);
+ return widget;
+ });
+
+ odoo.define('web.core.top', function (require) {
+ var core = require('web.core');
+ core.qweb.templates = window.top.odoo.__DEBUG__.services['web.core'].qweb.templates;
+ });
+ </script>
+ </head>
+ <body id="iframe_target" class="o_in_iframe">
+ <div id="web_editor-top-edit"></div>
+ <div id="wrapwrap">
+ <main>
+ <div data-oe-model="model" data-oe-type="html" class="o_editable oe_structure"></div>
+ </main>
+ </div>
+ <script type="text/javascript">
+ odoo.define('web_editor.wysiwyg.iniframe', function (require) {
+ 'use strict';
+ var editor = require('web_editor.editor');
+
+ window._summernoteSlave = $.summernote;
+ window._summernoteSlave.iframe = true;
+ window._summernoteSlave.lang = window.top.$.summernote.lang;
+ if (window.top.<t t-esc="updateIframeId"/>) {
+ window.top.<t t-esc="updateIframeId"/>(editor.Class, <t t-esc="avoidDoubleLoad"/>);
+ }
+ });
+ </script>
+ </body>
+ </t>
+
+ <!--=================-->
+ <!-- Edition Dialogs -->
+ <!--=================-->
+
+ <!-- Alt Dialog (allows to change alt and title of page images) -->
+ <form t-name="wysiwyg.widgets.alt" action="#">
+ <div class="form-group row">
+ <label class="col-md-3 col-form-label" for="alt"
+ title="'Alt tag' specifies an alternate text for an image, if the image cannot be displayed (slow connection, missing image, screen reader ...).">
+ Description <small>(ALT Tag)</small>
+ </label>
+ <div class="col-md-8">
+ <input class="form-control" id="alt" required="required" t-att-value="widget.alt" type="text"/>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="col-md-3 col-form-label" for="title"
+ title="'Title tag' is shown as a tooltip when you hover the picture.">
+ Tooltip <small>(TITLE Tag)</small>
+ </label>
+ <div class="col-md-8">
+ <input class="form-control" id="title" required="required" t-att-value="widget.tag_title" type="text"/>
+ </div>
+ </div>
+ </form>
+
+ <!-- Media Dialog (allows to choose an img/pictogram/video) -->
+ <div t-name="wysiwyg.widgets.media">
+ <ul class="nav nav-tabs" role="tablist">
+ <li t-if="!widget.options.noImages" class="nav-item"><a t-attf-class="nav-link #{widget.isImageActive() ? 'active' : ''}" id="editor-media-image-tab" data-toggle="tab" href="#editor-media-image" role="tab" aria-controls="editor-media-image" t-att-aria-selected="widget.isImageActive().toString()">Image</a></li>
+ <li t-if="!widget.options.noDocuments" class="nav-item"><a t-attf-class="nav-link #{widget.isDocumentActive() ? 'active' : ''}" id="editor-media-document-tab" data-toggle="tab" href="#editor-media-document" role="tab" aria-controls="editor-media-document" t-att-aria-selected="widget.isDocumentActive().toString()">Document</a></li>
+ <li t-if="!widget.options.noIcons" class="nav-item"><a t-attf-class="nav-link #{widget.isIconActive() ? 'active' : ''}" id="editor-media-icon-tab" data-toggle="tab" href="#editor-media-icon" role="tab" aria-controls="editor-media-icon" t-att-aria-selected="widget.isIconActive().toString()">Pictogram</a></li>
+ <li t-if="!widget.options.noVideos" class="nav-item"><a t-attf-class="nav-link #{widget.isVideoActive() ? 'active' : ''}" id="editor-media-video-tab" data-toggle="tab" href="#editor-media-video" role="tab" aria-controls="editor-media-video" t-att-aria-selected="widget.isVideoActive().toString()">Video</a></li>
+ </ul>
+ <!-- Tab panes -->
+ <div class="tab-content">
+ <div t-if="!widget.options.noImages" t-attf-class="tab-pane fade #{widget.isImageActive() ? 'show active': ''}" id="editor-media-image" role="tabpanel" aria-labelledby="editor-media-image-tab"/>
+ <div t-if="!widget.options.noDocuments" t-attf-class="tab-pane fade #{widget.isDocumentActive() ? 'show active': ''}" id="editor-media-document" role="tabpanel" aria-labelledby="editor-media-document-tab"/>
+ <div t-if="!widget.options.noIcons" t-attf-class="tab-pane fade #{widget.isIconActive() ? 'show active': ''}" id="editor-media-icon" role="tabpanel" aria-labelledby="editor-media-icon-tab"/>
+ <div t-if="!widget.options.noVideos" t-attf-class="tab-pane fade #{widget.isVideoActive() ? 'show active': ''}" id="editor-media-video" role="tabpanel" aria-labelledby="editor-media-video-tab"/>
+ </div>
+ </div>
+
+ <t t-name="wysiwyg.widgets.media.search">
+ <div class="input-group ml-auto">
+ <input type="text" class="form-control o_we_search" t-att-placeholder="searchPlaceholder.trim()"/>
+ <div class="input-group-append">
+ <div class="input-group-text o_we_search_icon">
+ <i class="fa fa-search" title="Search" role="img" aria-label="Search"/>
+ </div>
+ </div>
+ </div>
+ </t>
+
+ <!-- File choosing part of the Media Dialog -->
+ <t t-name="wysiwyg.widgets.file">
+ <form>
+ <t t-call="wysiwyg.widgets.files.submenu"/>
+ <div class="form-text o_we_error_text"/>
+ <div class="o_we_existing_attachments"/>
+ <div class="mt-4 text-center mx-auto o_we_load_more">
+ <button class="btn btn-primary o_load_more d-none" type="button">Load more...</button>
+ <div class="mt-4 o_load_done_msg d-none">
+ <span><i>All images have been loaded</i></span>
+ </div>
+ </div>
+ </form>
+ </t>
+
+ <t t-name="wysiwyg.widgets.files.submenu">
+ <div class="form-inline align-items-center py-4">
+ <input type="file" class="d-none o_file_input" name="upload" t-att-accept="widget.options.accept" t-att-multiple="widget.options.multiImages &amp;&amp; 'multiple'"/>
+
+ <div class="btn-group">
+ <button type="button" class="btn btn-primary o_upload_media_button">
+ <t t-esc="uploadText"/>
+ </button>
+ </div>
+
+ <div class="input-group align-items-center ml-2">
+ <input type="text" class="form-control o_we_url_input o_we_horizontal_collapse o_we_transition_ease" name="url" t-att-placeholder="urlPlaceholder"/>
+ <div class="input-group-append align-items-center">
+ <button type="button" class="btn btn-secondary o_upload_media_url_button">
+ <t t-esc="addText"/>
+ </button>
+ <div class="ml-2">
+ <span class="o_we_url_success text-success d-none fa fa-lg fa-check" title="The URL seems valid."/>
+ <span class="o_we_url_warning text-warning d-none fa fa-lg fa-warning" t-att-title="urlWarningTitle"/>
+ <span class="o_we_url_error text-danger d-none fa fa-lg fa-times" title="The URL does not seem to work."/>
+ </div>
+ </div>
+ </div>
+ <t t-raw="0"/>
+ <t t-call="wysiwyg.widgets.media.search"/>
+ </div>
+ </t>
+
+ <t t-name="wysiwyg.widgets.image">
+ <t t-call="wysiwyg.widgets.file">
+ <t t-set="uploadText">Upload an image</t>
+ <t t-set="urlPlaceholder">https://www.odoo.com/logo.png</t>
+ <t t-set="addText">Add URL</t>
+ <t t-set="searchPlaceholder">Search an image</t>
+ <t t-set="urlWarningTitle">The URL does not contain any image. The file will be added in the document section.</t>
+ <div class="d-flex justify-content-end flex-grow-1 pr-3">
+ <div t-attf-class="custom-control custom-switch #{__debug__ and 'd-flex' or 'd-none'} align-items-center mr-2">
+ <input class="o_we_show_optimized ml-2 custom-control-input" type="checkbox" id="o_we_show_optimized_switch"/>
+ <label class="custom-control-label" for="o_we_show_optimized_switch">
+ Show optimized images
+ </label>
+ </div>
+ <select class="custom-select o_we_search_select">
+ <option value="all">All</option>
+ <option value="database">My Images</option>
+ <option t-if="widget.options.useMediaLibrary" value="media-library">Illustrations</option>
+ </select>
+ </div>
+ </t>
+ </t>
+
+ <t t-name="wysiwyg.widgets.document">
+ <t t-call="wysiwyg.widgets.file">
+ <t t-set="uploadText">Upload a document</t>
+ <t t-set="urlPlaceholder">https://www.odoo.com/mydocument</t>
+ <t t-set="addText">Add document</t>
+ <t t-set="searchPlaceholder">Search a document</t>
+ <t t-set="urlWarningTitle">The URL contains an image. The file will be added in the image section.</t>
+ </t>
+ </t>
+
+ <t t-name="wysiwyg.widgets.image.optimize">
+ <form class="o_we_image_optimize_dialog">
+ <div class="row">
+ <div class="o_we_config_column col-lg-6">
+ <div class="form-group">
+ <label class="o_we_title_label" for="o_we_name_input">
+ Name
+ <i class="fa fa-question-circle-o" title="Give a relevant name to your file to optimize search engine results."/>
+ </label>
+ <input type="text" class="form-control" id="o_we_name_input" name="filename" aria-describedby="nameHelp" t-att-value="widget.attachment.name" required="required"/>
+ </div>
+
+ <small t-if="widget.disableResize" class="form-text text-muted o_we_no_resize">
+ <span class="fa fa-info-circle"/> Resizing is not supported for images of type <t t-esc="widget.attachment.mimetype"/>.
+ </small>
+ <div t-else="1" class="form-group">
+ <label class="o_we_title_label" for="o_we_name_input">
+ Size
+ <i class="fa fa-question-circle-o" title="Reduce the size as much as possible to increase performance."/>
+ </label>
+ <div class="form-row align-items-center">
+ <div class="col">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <div class="input-group-text">Width</div>
+ </div>
+ <input type="number" class="form-control" id="o_we_width" name="width" aria-describedby="sizeHelp" min="1" t-att-max="widget.image_width" t-att-value="widget.defaultWidth"/>
+ <div class="input-group-append">
+ <div class="input-group-text">px</div>
+ </div>
+ </div>
+ </div>
+ <div class="col-auto">
+ <i class="fa fa-times"/>
+ </div>
+ <div class="col">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <div class="input-group-text">Height</div>
+ </div>
+ <input type="number" class="form-control" id="o_we_height" name="height" aria-describedby="sizeHelp" min="1" t-att-max="widget.image_height" t-att-value="widget.defaultHeight"/>
+ <div class="input-group-append">
+ <div class="input-group-text">px</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="form-text small text-right">
+ <i class="fa fa-info-circle text-info"/>
+ <span>Or choose a preset:</span>
+ <t t-foreach="widget.suggestedWidths" t-as="suggestedWidth">
+ <span t-if="suggestedWidth_index > 0">-</span>
+ <a href="#" class="o_we_width_preset" t-att-data-width="suggestedWidth.width" t-esc="suggestedWidth.text"/>
+ </t>
+ </div>
+ </div>
+
+ <small t-if="widget.disableQuality" class="form-text text-muted o_we_no_quality">
+ <span class="fa fa-info-circle"/> Changing the quality is not supported for images of type <t t-esc="widget.attachment.mimetype"/>.
+ </small>
+ <div class="form-group" t-else="1">
+ <t t-if="widget.toggleQuality">
+ <div class="custom-control custom-switch">
+ <input type="checkbox" class="custom-control-input" id="o_we_optimize_quality" t-att-checked="widget.isExisting ? undefined : 'checked'" aria-describedby="toggleQualityHelp"/>
+ <label class="custom-control-label" for="o_we_optimize_quality">
+ Optimize
+ <i class="fa fa-question-circle-o" title="This reduces the quality to increase performance."/>
+ </label>
+ </div>
+ </t>
+ <t t-else="1">
+ <label class="o_we_title_label" for="o_we_quality_input">
+ Quality
+ <i class="fa fa-question-circle-o" title="Reduce the quality as much as possible to increase performance."/>
+ </label>
+ <div class="form-row align-items-center">
+ <div class="col-sm-10">
+ <input type="range" class="custom-range align-middle o_we_quality_range" id="quality_range" name="quality_range" min="1" max="100" step="1" aria-describedby="rangeQualityHelp" t-att-value="widget.defaultQuality"/>
+ </div>
+ <div class="col-sm-2">
+ <input type="number" class="form-control" id="o_we_quality_input" name="quality" min="1" max="100" step="1" aria-describedby="rangeQualityHelp" t-att-value="widget.defaultQuality"/>
+ </div>
+ </div>
+ </t>
+ </div>
+ </div>
+ <div class="o_we_preview_column col-lg-6">
+ <h4>Preview</h4>
+ <div class="mw-100 o_we_preview_area">
+ <img class="img o_we_preview_image" alt="Image Preview"/>
+ </div>
+ </div>
+ </div>
+ </form>
+ </t>
+
+ <t t-name="wysiwyg.widgets.image.existing.attachments">
+ <div class="o_we_existing_attachments o_we_images d-flex flex-wrap w-100 justify-content-between align-items-stretch my-0">
+ <t t-if="!widget.hasContent()">
+ <div t-if="widget.needle" class="o_nocontent_help">
+ <p class="o_empty_folder_image">No images found.</p>
+ <p class="o_empty_folder_subtitle">You can upload images with the button located in the top left of the screen.</p>
+ </div>
+ <div t-else="" class="o_we_search_prompt">
+ <h2>Get the perfect image by searching in our library of copyright free photos and illustrations.</h2>
+ </div>
+ </t>
+ <t t-else="">
+ <t t-if="['all', 'database'].includes(widget.searchService)" t-foreach="attachments" t-as="attachment">
+ <t t-call="wysiwyg.widgets.image.existing.attachment"/>
+ </t>
+ <t t-if="['all', 'media-library'].includes(widget.searchService)" t-foreach="libraryMedia" t-as="media">
+ <t t-call="wysiwyg.widgets.image.library_media"/>
+ </t>
+ <!-- 20 placeholders is just enough for a 5K screen, change this if ImageWidget.MIN_ROW_HEIGHT changes -->
+ <t t-foreach="20">
+ <div class="o_we_attachment_placeholder"/>
+ </t>
+ </t>
+ </div>
+ </t>
+
+ <t t-name="wysiwyg.widgets.image.existing.attachment">
+ <t t-set="isOptimized" t-value="!!attachment.original_id"/>
+ <div t-attf-class="o_existing_attachment_cell position-relative bg-light #{isOptimized and 'o_we_attachment_optimized d-none' or ''} align-items-center justify-content-center" t-att-data-id="attachment.id">
+ <t t-call="wysiwyg.widgets.file.existing.remove"/>
+ <div class="o_we_media_dialog_img_wrapper">
+ <img class="img img-fluid o_we_attachment_highlight" t-attf-src="#{attachment.thumbnail_src or attachment.image_src}" t-att-alt="attachment.name" t-att-title="attachment.name"/>
+ </div>
+ <span t-if="isOptimized" class="badge badge-success">Optimized</span>
+ </div>
+ </t>
+
+ <t t-name="wysiwyg.widgets.image.library_media">
+ <div t-attf-class="o_existing_attachment_cell position-relative bg-light align-items-center justify-content-center o_library_media_cell" t-att-data-media-id="media.id">
+ <div class="o_we_media_dialog_img_wrapper">
+ <img class="img img-fluid o_we_attachment_highlight" t-attf-src="#{media.thumbnail_url}" t-att-title="media.tooltip or ''" crossorigin="anonymous"/>
+ <a t-if="media.author" class="o_we_media_author" t-att-href="media.author_link" target="_blank" t-esc="media.author"/>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="wysiwyg.widgets.document.existing.attachments">
+ <div class="o_we_existing_attachments o_we_documents">
+ <div t-if="!attachments.length" class="o_nocontent_help">
+ <p class="o_empty_folder_image">No documents found.</p>
+ <p class="o_empty_folder_subtitle">You can upload documents with the button located in the top left of the screen.</p>
+ </div>
+ <div t-else="" class="row mx-auto">
+ <t t-foreach="attachments" t-as="attachment">
+ <div class="col-2 o_existing_attachment_cell o_we_attachment_highlight my-2" t-att-data-id="attachment.id">
+ <t t-call="wysiwyg.widgets.file.existing.remove"/>
+
+ <div t-att-data-url="attachment.url" role="img" t-att-aria-label="attachment.name" t-att-title="attachment.name" t-att-data-mimetype="attachment.mimetype" t-attf-class="o_image d-flex align-items-center justify-content-center"/>
+ <small class="o_file_name" t-esc="attachment.name"/>
+ </div>
+ </t>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="wysiwyg.widgets.file.existing.remove">
+ <t t-set="removeTitle" t-if="attachment.res_model === 'ir.ui.view'">This file is a public view attachment.</t>
+ <t t-set="removeTitle" t-else="">This file is attached to the current record.</t>
+ <i class="fa fa-trash o_existing_attachment_remove p-2" t-att-title="removeTitle" role="img" t-att-aria-label="removeTitle"/>
+ </t>
+
+ <t t-name="wysiwyg.widgets.image.existing.error">
+ <div class="form-text">
+ <p>The image could not be deleted because it is used in the
+ following pages or views:</p>
+ <ul t-as="view" t-foreach="views">
+ <li>
+ <a t-attf-href="/web#model=ir.ui.view&amp;id=#{view.id}">
+ <t t-esc="view.name"/>
+ </a>
+ </li>
+ </ul>
+ </div>
+ </t>
+
+ <!-- Icon choosing part of the Media Dialog -->
+ <t t-name="wysiwyg.widgets.font-icons">
+ <form action="#">
+ <div class="form-inline align-items-center py-4">
+ <t t-call="wysiwyg.widgets.media.search">
+ <t t-set="searchPlaceholder">Search a pictogram</t>
+ </t>
+ </div>
+ <div class="font-icons-icons">
+ <t t-call="wysiwyg.widgets.font-icons.icons">
+ <t t-set="iconsParser" t-value="widget.iconsParser"/>
+ </t>
+ </div>
+ </form>
+ </t>
+ <t t-name="wysiwyg.widgets.font-icons.icons">
+ <t t-as="data" t-foreach="iconsParser">
+ <span t-foreach="data.cssData" t-as="cssData"
+ t-att-data-id="cssData.names[0]"
+ t-att-title="cssData.names[0]"
+ t-att-aria-label="cssData.names[0]" role="img"
+ t-attf-class="font-icons-icon #{data.base} #{cssData.names[0]}"
+ t-att-data-alias="cssData.names.join(',')"/>
+ </t>
+ </t>
+
+ <!-- Video choosing part of the Media Dialog -->
+ <t t-name="wysiwyg.widgets.video">
+ <form action="#" class="row">
+ <div class="col mt-4 o_video_dialog_form">
+ <div class="form-group mb-2" id="o_video_form_group">
+ <label class="col-form-label" for="o_video_text">
+ Video code <small>(URL or Embed)</small>
+ </label>
+ <textarea class="form-control" id="o_video_text" placeholder="Copy-paste your URL or embed code here"/>
+ </div>
+ <div class="text-right">
+ <small class="text-muted">Accepts <b><i>Youtube</i></b>, <b><i>Vimeo</i></b>, <b><i>Dailymotion</i></b> and <b><i>Youku</i></b> videos</small>
+ </div>
+ <div class="o_video_dialog_options d-none mt-4">
+ <div class="o_youtube_option o_vimeo_option o_dailymotion_option">
+ <label class="o_switch mb0">
+ <input id="o_video_autoplay" type="checkbox"/><span/>
+ <span style="margin-right: 8px;">Autoplay</span>
+ <span class="small text-muted" style="margin-left: auto;">Videos are muted when autoplay is enabled</span>
+ </label>
+ </div>
+ <div class="o_youtube_option o_vimeo_option">
+ <label class="o_switch mb0"><input id="o_video_loop" type="checkbox"/><span/>Loop</label>
+ </div>
+ <div class="o_youtube_option o_dailymotion_option">
+ <label class="o_switch mb0"><input id="o_video_hide_controls" type="checkbox"/><span/>Hide player controls</label>
+ </div>
+ <div class="o_youtube_option">
+ <label class="o_switch mb0"><input id="o_video_hide_fullscreen" type="checkbox"/><span/>Hide fullscreen button</label>
+ </div>
+ <div class="o_youtube_option">
+ <label class="o_switch mb0"><input id="o_video_hide_yt_logo" type="checkbox"/><span/>Hide Youtube logo</label>
+ </div>
+ <div class="o_dailymotion_option">
+ <label class="o_switch mb0"><input id="o_video_hide_dm_logo" type="checkbox"/><span/>Hide Dailymotion logo</label>
+ </div>
+ <div class="o_dailymotion_option">
+ <label class="o_switch mb0"><input id="o_video_hide_dm_share" type="checkbox"/><span/>Hide sharing button</label>
+ </div>
+ </div>
+ </div>
+ <div class="col-md-6">
+ <div id="video-preview" class="p-3">
+ <div class="o_video_dialog_preview_text small mb-2 d-none">Preview</div>
+ <div class="media_iframe_video">
+ <div class="media_iframe_video_size"/>
+ <iframe class="o_video_dialog_iframe" allowfullscreen="allowfullscreen" frameborder="0" src=""/>
+ </div>
+ </div>
+ </div>
+ </form>
+ </t>
+
+
+ <!-- Link Dialog (allows to choose a style and content for a link on the page) -->
+ <div t-name="wysiwyg.widgets.link" class="o_link_dialog">
+ <div class="row">
+ <form class="col-lg-8">
+ <div t-attf-class="form-group row#{widget.needLabel ? '' : ' d-none'}">
+ <label class="col-form-label col-md-3" for="o_link_dialog_label_input">Link Label</label>
+ <div class="col-md-9">
+ <input type="text" name="label" class="form-control" id="o_link_dialog_label_input" required="required" t-att-value="widget.data.text"/>
+ </div>
+ </div>
+ <div id="o_url_input" t-attf-class="form-group row o_url_input#{widget.isButton ? ' d-none' : ''}">
+ <label class="col-form-label col-md-3" for="o_link_dialog_url_input">URL or Email</label>
+ <div class="col-md-9">
+ <input type="text" name="url" class="form-control" id="o_link_dialog_url_input" required="required"/>
+ <div class="form-check o_strip_domain d-none">
+ <input type="checkbox" id="o_link_dialog_url_strip_domain" checked="checked" class="form-check-input"/>
+ <label for="o_link_dialog_url_strip_domain" class="form-check-label font-weight-normal">
+ Autoconvert to relative link
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="col-form-label col-md-3">Type</label>
+ <div class="col-md-9 d-flex align-items-center">
+ <div t-attf-class="#{widget.colorCombinationClass ? ('p-2 ' + widget.colorCombinationClass) : ''}">
+ <t t-foreach="widget.colorsData" t-as="colorData">
+ <label role="button" class="m-0 mr-2">
+ <input type="radio" name="link_style_color" class="d-none link-style" t-att-value="colorData.type"/>
+ <span t-esc="colorData.label"
+ t-attf-class="o_btn_preview btn btn-sm btn-#{colorData.btnPreview} #{colorData.type ? '' : 'px-0'}"/>
+ </label>
+ </t>
+ </div>
+ </div>
+ </div>
+ <div id="o_link_dialog_button_opts_collapse" class="collapse">
+ <div class="form-group row">
+ <label class="col-form-label col-md-3">Size</label>
+ <div class="col-md-9">
+ <select name="link_style_size" class="form-control link-style">
+ <option value="sm">Small</option>
+ <option value="" selected="selected">Medium</option>
+ <option value="lg">Large</option>
+ </select>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="col-form-label col-md-3">Style</label>
+ <div class="col-md-9">
+ <select name="link_style_shape" class="form-control link-style">
+ <option value="" selected="selected">Default</option>
+ <option value="rounded-circle">Default + Rounded</option>
+ <option value="outline">Outline</option>
+ <option value="outline,rounded-circle">Outline + Rounded</option>
+ <option value="fill">Fill</option>
+ <option value="fill,rounded-circle">Fill + Rounded</option>
+ <option value="flat">Flat</option>
+ </select>
+ </div>
+ </div>
+ </div>
+ <div t-if="!widget.isButton" class="form-group row">
+ <div class="offset-md-3 col-md-9">
+ <label class="o_switch">
+ <input type="checkbox" name="is_new_window" t-att-checked="widget.data.isNewWindow ? 'checked' : undefined"/>
+ <span/>
+ Open in new window
+ </label>
+ </div>
+ </div>
+ </form>
+ <div class="col-lg-4 o_link_dialog_preview">
+ <div class="form-group text-center">
+ <label>Preview</label>
+ <div t-attf-class="#{widget.colorCombinationClass ? ('p-2 ' + widget.colorCombinationClass) : ''}"
+ style="overflow-x: auto; max-width: 100%; max-height: 200px;">
+ <a href="#" id="link-preview" aria-label="Preview" title="Preview"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- ImageCropWidget controls (allows to crop images on the page) -->
+ <div t-name="wysiwyg.widgets.crop" class="o_we_crop_widget" contenteditable="false">
+ <div class="o_we_cropper_wrapper">
+ <img class="o_we_cropper_img"/>
+ <div class="o_we_crop_buttons text-center mt16 position-absolute o_we_no_overlay" contenteditable="false">
+ <div class="btn-group btn-group-toggle" title="Aspect Ratio" data-toggle="buttons">
+ <t t-foreach="widget.aspectRatios" t-as="ratio">
+ <t t-set="is_active" t-value="ratio === widget.aspectRatio"/>
+ <label t-attf-class="btn #{is_active and 'active' or ''}" data-action="ratio" t-att-data-value="ratio">
+ <input type="radio" /><t t-esc="ratio_value.label"/>
+ </label>
+ </t>
+ </div>
+ <div class="btn-group" role="group">
+ <button type="button" title="Zoom In" data-action="zoom" data-value="0.1"><i class="fa fa-fw fa-search-plus"/></button>
+ <button type="button" title="Zoom Out" data-action="zoom" data-value="-0.1"><i class="fa fa-fw fa-search-minus"/></button>
+ </div>
+ <div class="btn-group" role="group">
+ <button type="button" title="Rotate Left" data-action="rotate" data-value="-90"><i class="fa fa-fw fa-rotate-left"/></button>
+ <button type="button" title="Rotate Right" data-action="rotate" data-value="90"><i class="fa fa-fw fa-rotate-right"/></button>
+ </div>
+ <div class="btn-group" role="group">
+ <button type="button" title="Flip Horizontal" data-action="flip" data-scale-direction="scaleX"><i class="fa fa-fw fa-arrows-h"/></button>
+ <button type="button" title="Flip Vertical" data-action="flip" data-scale-direction="scaleY"><i class="fa fa-fw fa-arrows-v"/></button>
+ </div>
+ <div class="btn-group" role="group">
+ <button type="button" title="Reset Image" data-action="reset"><i class="fa fa-refresh"/> Reset Image</button>
+ </div>
+ <div class="btn-group" role="group">
+ <button type="button" title="Apply" data-action="apply" class="btn btn-primary"><i class="fa fa-check"/> Apply</button>
+ <button type="button" title="Discard" data-action="discard" class="btn btn-danger"><i class="fa fa-times"/> Discard</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+</templates>
diff --git a/addons/web_editor/static/src/xml/wysiwyg_colorpicker.xml b/addons/web_editor/static/src/xml/wysiwyg_colorpicker.xml
new file mode 100644
index 00000000..a536f6f9
--- /dev/null
+++ b/addons/web_editor/static/src/xml/wysiwyg_colorpicker.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates>
+ <div t-name="wysiwyg.plugin.font.paletteButton" t-attf-class="note-btn-group btn-group {{className}}">
+ <button type="button" class="note-btn btn btn-light btn-sm dropdown-toggle" tabindex="-1" data-toggle="dropdown"><i
+ t-att-class="icon"></i></button>
+ <div class="dropdown-menu" />
+ </div>
+
+ <div t-name="wysiwyg.plugin.font.colorPalette" class="note-palette">
+ <button type="button" class="note-color-reset btn btn-light note-color-btn bg-undefined">
+ <t t-esc="lang.color.resetToDefault" /></button>
+ <div class="note-holder">
+ <div class="note-color-palette">
+ <h6 class="mt-2">Theme colors</h6>
+ <div class="o_theme_color_placeholder" />
+ <h6 class="mt-2">Transparent colors</h6>
+ <div class="o_transparent_color_placeholder" />
+ <h6 class="mt-2">Common colors</h6>
+ <div class="o_common_color_placeholder">
+ <div class="note-color-row" t-foreach="colors" t-as="rowColors">
+ <button t-foreach="rowColors" t-as="color" type="button" class="note-color-btn" data-toggle="button"
+ tabindex="-1" t-attf-style="background-color:{{color}}" t-att-data-value="color"
+ t-att-title="color" />
+ </div>
+ </div>
+ </div>
+ <h6 class="note-custom-color mt8">
+ <t t-esc="lang.color.customColor" />
+ </h6>
+ <button class="note-custom-color-btn note-color-btn" style="display: none;"></button>
+ </div>
+ </div>
+</templates>
diff --git a/addons/web_editor/static/tests/field_html_tests.js b/addons/web_editor/static/tests/field_html_tests.js
new file mode 100644
index 00000000..89dda4cc
--- /dev/null
+++ b/addons/web_editor/static/tests/field_html_tests.js
@@ -0,0 +1,528 @@
+odoo.define('web_editor.field_html_tests', function (require) {
+"use strict";
+
+var ajax = require('web.ajax');
+var FormView = require('web.FormView');
+var testUtils = require('web.test_utils');
+var weTestUtils = require('web_editor.test_utils');
+var core = require('web.core');
+var Wysiwyg = require('web_editor.wysiwyg');
+var MediaDialog = require('wysiwyg.widgets.MediaDialog');
+
+var _t = core._t;
+
+QUnit.module('web_editor', {}, function () {
+
+ QUnit.module('field html', {
+ beforeEach: function () {
+ this.data = weTestUtils.wysiwygData({
+ 'note.note': {
+ fields: {
+ display_name: {
+ string: "Displayed name",
+ type: "char"
+ },
+ header: {
+ string: "Header",
+ type: "html",
+ required: true,
+ },
+ body: {
+ string: "Message",
+ type: "html"
+ },
+ },
+ records: [{
+ id: 1,
+ display_name: "first record",
+ header: "<p> &nbsp;&nbsp; <br> </p>",
+ body: "<p>toto toto toto</p><p>tata</p>",
+ }, {
+ id: 2,
+ display_name: "second record",
+ header: "<p> &nbsp;&nbsp; <br> </p>",
+ body: `
+<div class="o_form_sheet_bg">
+ <div class="clearfix position-relative o_form_sheet" style="width: 1140px;">
+ <div class="o_notebook">
+ <div class="tab-content">
+ <div class="tab-pane active" id="notebook_page_820">
+ <div class="oe_form_field oe_form_field_html o_field_widget" name="description" style="margin-bottom: 5px;">
+ hacky code to test
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>`,
+ }],
+ },
+ 'mass.mailing': {
+ fields: {
+ display_name: {
+ string: "Displayed name",
+ type: "char"
+ },
+ body_html: {
+ string: "Message Body inline (to send)",
+ type: "html"
+ },
+ body_arch: {
+ string: "Message Body for edition",
+ type: "html"
+ },
+ },
+ records: [{
+ id: 1,
+ display_name: "first record",
+ body_html: "<div class='field_body' style='background-color: red;'>yep</div>",
+ body_arch: "<div class='field_body'>yep</div>",
+ }],
+ },
+ "ir.translation": {
+ fields: {
+ lang_code: {type: "char"},
+ value: {type: "char"},
+ res_id: {type: "integer"}
+ },
+ records: [{
+ id: 99,
+ res_id: 12,
+ value: '',
+ lang_code: 'en_US'
+ }]
+ },
+ });
+
+ testUtils.mock.patch(ajax, {
+ loadAsset: function (xmlId) {
+ if (xmlId === 'template.assets') {
+ return Promise.resolve({
+ cssLibs: [],
+ cssContents: ['body {background-color: red;}']
+ });
+ }
+ if (xmlId === 'template.assets_all_style') {
+ return Promise.resolve({
+ cssLibs: $('link[href]:not([type="image/x-icon"])').map(function () {
+ return $(this).attr('href');
+ }).get(),
+ cssContents: ['body {background-color: red;}']
+ });
+ }
+ throw 'Wrong template';
+ },
+ });
+ },
+ afterEach: function () {
+ testUtils.mock.unpatch(ajax);
+ },
+ }, function () {
+
+ QUnit.module('basic');
+
+ QUnit.test('simple rendering', async function (assert) {
+ assert.expect(3);
+
+ var form = await testUtils.createView({
+ View: FormView,
+ model: 'note.note',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="body" widget="html" style="height: 100px"/>' +
+ '</form>',
+ res_id: 1,
+ });
+ var $field = form.$('.oe_form_field[name="body"]');
+ assert.strictEqual($field.children('.o_readonly').html(),
+ '<p>toto toto toto</p><p>tata</p>',
+ "should have rendered a div with correct content in readonly");
+ assert.strictEqual($field.attr('style'), 'height: 100px',
+ "should have applied the style correctly");
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.nextTick();
+ $field = form.$('.oe_form_field[name="body"]');
+ assert.strictEqual($field.find('.note-editable').html(),
+ '<p>toto toto toto</p><p>tata</p>',
+ "should have rendered the field correctly in edit");
+
+ form.destroy();
+ });
+
+ QUnit.test('notebooks defined inside HTML field widgets are ignored when calling setLocalState', async function (assert) {
+ assert.expect(1);
+
+ var form = await testUtils.createView({
+ View: FormView,
+ model: 'note.note',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="body" widget="html" style="height: 100px"/>' +
+ '</form>',
+ res_id: 2,
+ });
+ // check that there is no error on clicking Edit
+ await testUtils.form.clickEdit(form);
+ await testUtils.nextTick();
+ assert.containsOnce(form, '.o_form_editable');
+
+ form.destroy();
+ });
+
+ QUnit.test('check if required field is set', async function (assert) {
+ assert.expect(1);
+
+ var form = await testUtils.createView({
+ View: FormView,
+ model: 'note.note',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="header" widget="html" style="height: 100px" />' +
+ '</form>',
+ res_id: 1,
+ });
+
+ testUtils.mock.intercept(form, 'call_service', function (ev) {
+ if (ev.data.service === 'notification') {
+ assert.deepEqual(ev.data.args[0], {
+ "className": undefined,
+ "message": "<ul><li>Header</li></ul>",
+ "sticky": undefined,
+ "title": "Invalid fields:",
+ "type": "danger"
+ });
+ }
+ }, true);
+
+ await testUtils.form.clickEdit(form);
+ await testUtils.nextTick();
+ await testUtils.dom.click(form.$('.o_form_button_save'));
+
+ form.destroy();
+ });
+
+ QUnit.test('colorpicker', async function (assert) {
+ assert.expect(6);
+
+ var form = await testUtils.createView({
+ View: FormView,
+ model: 'note.note',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="body" widget="html" style="height: 100px"/>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // Summernote needs a RootWidget to set as parent of the ColorPaletteWidget. In the
+ // tests, there is no RootWidget, so we set it here to the parent of the form view, which
+ // can act as RootWidget, as it will honor rpc requests correctly (to the MockServer).
+ const rootWidget = odoo.__DEBUG__.services['root.widget'];
+ odoo.__DEBUG__.services['root.widget'] = form.getParent();
+
+ await testUtils.form.clickEdit(form);
+ var $field = form.$('.oe_form_field[name="body"]');
+
+ // select the text
+ var pText = $field.find('.note-editable p').first().contents()[0];
+ Wysiwyg.setRange(pText, 1, pText, 10);
+ // text is selected
+
+ var range = Wysiwyg.getRange($field[0]);
+ assert.strictEqual(range.sc, pText,
+ "should select the text");
+
+ async function openColorpicker(selector) {
+ const $colorpicker = $field.find(selector);
+ const openingProm = new Promise(resolve => {
+ $colorpicker.one('shown.bs.dropdown', () => resolve());
+ });
+ await testUtils.dom.click($colorpicker.find('button:first'));
+ return openingProm;
+ }
+
+ await openColorpicker('.note-toolbar .note-back-color-preview');
+ assert.ok($field.find('.note-back-color-preview').hasClass('show'),
+ "should display the color picker");
+
+ await testUtils.dom.click($field.find('.note-toolbar .note-back-color-preview .o_we_color_btn[style="background-color:#00FFFF;"]'));
+
+ assert.ok(!$field.find('.note-back-color-preview').hasClass('show'),
+ "should close the color picker");
+
+ assert.strictEqual($field.find('.note-editable').html(),
+ '<p>t<font style="background-color: rgb(0, 255, 255);">oto toto&nbsp;</font>toto</p><p>tata</p>',
+ "should have rendered the field correctly in edit");
+
+ var fontContent = $field.find('.note-editable font').contents()[0];
+ var rangeControl = {
+ sc: fontContent,
+ so: 0,
+ ec: fontContent,
+ eo: fontContent.length,
+ };
+ range = Wysiwyg.getRange($field[0]);
+ assert.deepEqual(_.pick(range, 'sc', 'so', 'ec', 'eo'), rangeControl,
+ "should select the text after color change");
+
+ // select the text
+ pText = $field.find('.note-editable p').first().contents()[2];
+ Wysiwyg.setRange(fontContent, 5, pText, 2);
+ // text is selected
+
+ await openColorpicker('.note-toolbar .note-back-color-preview');
+ await testUtils.dom.click($field.find('.note-toolbar .note-back-color-preview .o_we_color_btn.bg-o-color-3'));
+
+ assert.strictEqual($field.find('.note-editable').html(),
+ '<p>t<font style="background-color: rgb(0, 255, 255);">oto t</font><font style="" class="bg-o-color-3">oto&nbsp;</font><font class="bg-o-color-3" style="">to</font>to</p><p>tata</p>',
+ "should have rendered the field correctly in edit");
+
+ odoo.__DEBUG__.services['root.widget'] = rootWidget;
+ form.destroy();
+ });
+
+ QUnit.test('media dialog: image', async function (assert) {
+ assert.expect(1);
+
+ var form = await testUtils.createView({
+ View: FormView,
+ model: 'note.note',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="body" widget="html" style="height: 100px"/>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.model === 'ir.attachment') {
+ if (args.method === "generate_access_token") {
+ return Promise.resolve();
+ }
+ }
+ if (route.indexOf('/web/image/123/transparent.png') === 0) {
+ return Promise.resolve();
+ }
+ if (route.indexOf('/web_unsplash/fetch_images') === 0) {
+ return Promise.resolve();
+ }
+ if (route.indexOf('/web_editor/media_library_search') === 0) {
+ return Promise.resolve();
+ }
+ return this._super(route, args);
+ },
+ });
+ await testUtils.form.clickEdit(form);
+ var $field = form.$('.oe_form_field[name="body"]');
+
+ // the dialog load some xml assets
+ var defMediaDialog = testUtils.makeTestPromise();
+ testUtils.mock.patch(MediaDialog, {
+ init: function () {
+ this._super.apply(this, arguments);
+ this.opened(defMediaDialog.resolve.bind(defMediaDialog));
+ }
+ });
+
+ var pText = $field.find('.note-editable p').first().contents()[0];
+ Wysiwyg.setRange(pText, 1);
+
+ await testUtils.dom.click($field.find('.note-toolbar .note-insert button:has(.fa-file-image-o)'));
+
+ // load static xml file (dialog, media dialog, unsplash image widget)
+ await defMediaDialog;
+
+ await testUtils.dom.click($('.modal #editor-media-image .o_existing_attachment_cell:first').removeClass('d-none'));
+
+ var $editable = form.$('.oe_form_field[name="body"] .note-editable');
+ assert.ok($editable.find('img')[0].dataset.src.includes('/web/image/123/transparent.png'),
+ "should have the image in the dom");
+
+ testUtils.mock.unpatch(MediaDialog);
+
+ form.destroy();
+ });
+
+ QUnit.test('media dialog: icon', async function (assert) {
+ assert.expect(1);
+
+ var form = await testUtils.createView({
+ View: FormView,
+ model: 'note.note',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="body" widget="html" style="height: 100px"/>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.model === 'ir.attachment') {
+ return Promise.resolve([]);
+ }
+ if (route.indexOf('/web_unsplash/fetch_images') === 0) {
+ return Promise.resolve();
+ }
+ return this._super(route, args);
+ },
+ });
+ await testUtils.form.clickEdit(form);
+ var $field = form.$('.oe_form_field[name="body"]');
+
+ // the dialog load some xml assets
+ var defMediaDialog = testUtils.makeTestPromise();
+ testUtils.mock.patch(MediaDialog, {
+ init: function () {
+ this._super.apply(this, arguments);
+ this.opened(defMediaDialog.resolve.bind(defMediaDialog));
+ }
+ });
+
+ var pText = $field.find('.note-editable p').first().contents()[0];
+ Wysiwyg.setRange(pText, 1);
+
+ await testUtils.dom.click($field.find('.note-toolbar .note-insert button:has(.fa-file-image-o)'));
+
+ // load static xml file (dialog, media dialog, unsplash image widget)
+ await defMediaDialog;
+ $('.modal .tab-content .tab-pane').removeClass('fade'); // to be sync in test
+ await testUtils.dom.click($('.modal a[aria-controls="editor-media-icon"]'));
+ await testUtils.dom.click($('.modal #editor-media-icon .font-icons-icon.fa-glass'));
+
+ var $editable = form.$('.oe_form_field[name="body"] .note-editable');
+
+ assert.strictEqual($editable.data('wysiwyg').getValue(),
+ '<p>t<span class="fa fa-glass"></span>oto toto toto</p><p>tata</p>',
+ "should have the image in the dom");
+
+ testUtils.mock.unpatch(MediaDialog);
+
+ form.destroy();
+ });
+
+ QUnit.test('save', async function (assert) {
+ assert.expect(1);
+
+ var form = await testUtils.createView({
+ View: FormView,
+ model: 'note.note',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="body" widget="html" style="height: 100px"/>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === "write") {
+ assert.strictEqual(args.args[1].body,
+ '<p>t<font class="bg-o-color-3">oto toto&nbsp;</font>toto</p><p>tata</p>',
+ "should save the content");
+
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ await testUtils.form.clickEdit(form);
+ var $field = form.$('.oe_form_field[name="body"]');
+
+ // select the text
+ var pText = $field.find('.note-editable p').first().contents()[0];
+ Wysiwyg.setRange(pText, 1, pText, 10);
+ // text is selected
+
+ async function openColorpicker(selector) {
+ const $colorpicker = $field.find(selector);
+ const openingProm = new Promise(resolve => {
+ $colorpicker.one('shown.bs.dropdown', () => resolve());
+ });
+ await testUtils.dom.click($colorpicker.find('button:first'));
+ return openingProm;
+ }
+
+ await openColorpicker('.note-toolbar .note-back-color-preview');
+ await testUtils.dom.click($field.find('.note-toolbar .note-back-color-preview .o_we_color_btn.bg-o-color-3'));
+
+ await testUtils.form.clickSave(form);
+
+ form.destroy();
+ });
+
+ QUnit.module('cssReadonly');
+
+ QUnit.test('rendering with iframe for readonly mode', async function (assert) {
+ assert.expect(3);
+
+ var form = await testUtils.createView({
+ View: FormView,
+ model: 'note.note',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="body" widget="html" style="height: 100px" options="{\'cssReadonly\': \'template.assets\'}"/>' +
+ '</form>',
+ res_id: 1,
+ });
+ var $field = form.$('.oe_form_field[name="body"]');
+ var $iframe = $field.find('iframe.o_readonly');
+ await $iframe.data('loadDef');
+ var doc = $iframe.contents()[0];
+ assert.strictEqual($(doc).find('#iframe_target').html(),
+ '<p>toto toto toto</p><p>tata</p>',
+ "should have rendered a div with correct content in readonly");
+
+ assert.strictEqual(doc.defaultView.getComputedStyle(doc.body).backgroundColor,
+ 'rgb(255, 0, 0)',
+ "should load the asset css");
+
+ await testUtils.form.clickEdit(form);
+
+ $field = form.$('.oe_form_field[name="body"]');
+ assert.strictEqual($field.find('.note-editable').html(),
+ '<p>toto toto toto</p><p>tata</p>',
+ "should have rendered the field correctly in edit");
+
+ form.destroy();
+ });
+
+ QUnit.module('translation');
+
+ QUnit.test('field html translatable', async function (assert) {
+ assert.expect(4);
+
+ var multiLang = _t.database.multi_lang;
+ _t.database.multi_lang = true;
+
+ this.data['note.note'].fields.body.translate = true;
+
+ var form = await testUtils.createView({
+ View: FormView,
+ model: 'note.note',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="body" widget="html"/>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_button' && args.method === 'translate_fields') {
+ assert.deepEqual(args.args, ['note.note', 1, 'body'], "should call 'call_button' route");
+ return Promise.resolve({
+ domain: [],
+ context: {search_default_name: 'partnes,foo'},
+ });
+ }
+ if (route === "/web/dataset/call_kw/res.lang/get_installed") {
+ return Promise.resolve([["en_US"], ["fr_BE"]]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ assert.strictEqual(form.$('.oe_form_field_html .o_field_translate').length, 0,
+ "should not have a translate button in readonly mode");
+
+ await testUtils.form.clickEdit(form);
+ var $button = form.$('.oe_form_field_html .o_field_translate');
+ assert.strictEqual($button.length, 1, "should have a translate button");
+ await testUtils.dom.click($button);
+ assert.containsOnce($(document), '.o_translation_dialog', 'should have a modal to translate');
+
+ form.destroy();
+ _t.database.multi_lang = multiLang;
+ });
+ });
+});
+});
diff --git a/addons/web_editor/static/tests/test_utils.js b/addons/web_editor/static/tests/test_utils.js
new file mode 100644
index 00000000..1aa4a79c
--- /dev/null
+++ b/addons/web_editor/static/tests/test_utils.js
@@ -0,0 +1,722 @@
+odoo.define('web_editor.test_utils', function (require) {
+"use strict";
+
+var ajax = require('web.ajax');
+var MockServer = require('web.MockServer');
+var testUtils = require('web.test_utils');
+var Widget = require('web.Widget');
+var Wysiwyg = require('web_editor.wysiwyg');
+var options = require('web_editor.snippets.options');
+
+const COLOR_PICKER_TEMPLATE = `
+ <t t-name="web_editor.colorpicker">
+ <colorpicker>
+ <div class="o_colorpicker_section" data-name="theme" data-display="Theme Colors" data-icon-class="fa fa-flask">
+ <button data-color="o-color-1"/>
+ <button data-color="o-color-2"/>
+ <button data-color="o-color-3"/>
+ <button data-color="o-color-4"/>
+ <button data-color="o-color-5"/>
+ </div>
+ <div class="o_colorpicker_section" data-name="transparent_grayscale" data-display="Transparent Colors" data-icon-class="fa fa-eye-slash">
+ <button class="o_btn_transparent"/>
+ <button data-color="black-25"/>
+ <button data-color="black-50"/>
+ <button data-color="black-75"/>
+ <button data-color="white-25"/>
+ <button data-color="white-50"/>
+ <button data-color="white-75"/>
+ </div>
+ <div class="o_colorpicker_section" data-name="common" data-display="Common Colors" data-icon-class="fa fa-paint-brush"/>
+ </colorpicker>
+ </t>`;
+const SNIPPETS_TEMPLATE = `
+ <h2 id="snippets_menu">Add blocks</h2>
+ <div id="o_scroll">
+ <div id="snippet_structure" class="o_panel">
+ <div class="o_panel_header">First Panel</div>
+ <div class="o_panel_body">
+ <div name="Separator" data-oe-type="snippet" data-oe-thumbnail="/website/static/src/img/snippets_thumbs/s_separator.png">
+ <div class="s_hr pt32 pb32">
+ <hr class="s_hr_1px s_hr_solid w-100 mx-auto"/>
+ </div>
+ </div>
+ <div name="Content" data-oe-type="snippet" data-oe-thumbnail="/website/static/src/img/snippets_thumbs/s_text_block.png">
+ <section name="Content+Options" class="test_option_all pt32 pb32" data-oe-type="snippet" data-oe-thumbnail="/website/static/src/img/snippets_thumbs/s_text_block.png">
+ <div class="container">
+ <div class="row">
+ <div class="col-lg-10 offset-lg-1 pt32 pb32">
+ <h2>Title</h2>
+ <p class="lead o_default_snippet_text">Content</p>
+ </div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="snippet_options" class="d-none">
+ <div data-js="many2one" data-selector="[data-oe-many2one-model]:not([data-oe-readonly])" data-no-check="true"/>
+ <div data-js="content"
+ data-selector=".s_hr, .test_option_all"
+ data-drop-in=".note-editable"
+ data-drop-near="p, h1, h2, h3, blockquote, .s_hr"/>
+ <div data-js="sizing_y" data-selector=".s_hr, .test_option_all"/>
+ <div data-selector=".test_option_all">
+ <we-colorpicker string="Background Color" data-select-style="true" data-css-property="background-color" data-color-prefix="bg-"/>
+ </div>
+ <div data-js="BackgroundImage" data-selector=".test_option_all">
+ <we-button data-choose-image="true" data-no-preview="true">
+ <i class="fa fa-picture-o"/> Background Image
+ </we-button>
+ </div>
+ <div data-js="option_test" data-selector=".s_hr">
+ <we-select string="Alignment">
+ <we-button data-select-class="align-items-start">Top</we-button>
+ <we-button data-select-class="align-items-center">Middle</we-button>
+ <we-button data-select-class="align-items-end">Bottom</we-button>
+ <we-button data-select-class="align-items-stretch">Equal height</we-button>
+ </we-select>
+ </div>
+ </div>`;
+
+MockServer.include({
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ * @returns {Promise}
+ */
+ async _performRpc(route, args) {
+ if (args.model === "ir.ui.view") {
+ if (args.method === 'read_template' && args.args[0] === "web_editor.colorpicker") {
+ return COLOR_PICKER_TEMPLATE;
+ }
+ if (args.method === 'render_public_asset' && args.args[0] === "web_editor.snippets") {
+ return SNIPPETS_TEMPLATE;
+ }
+ }
+ return this._super(...arguments);
+ },
+});
+
+/**
+ * Options with animation and edition for test.
+ */
+options.registry.option_test = options.Class.extend({
+ cleanForSave: function () {
+ this.$target.addClass('cleanForSave');
+ },
+ onBuilt: function () {
+ this.$target.addClass('built');
+ },
+ onBlur: function () {
+ this.$target.removeClass('focus');
+ },
+ onClone: function () {
+ this.$target.addClass('clone');
+ this.$target.removeClass('focus');
+ },
+ onFocus: function () {
+ this.$target.addClass('focus');
+ },
+ onMove: function () {
+ this.$target.addClass('move');
+ },
+ onRemove: function () {
+ this.$target.closest('.note-editable').addClass('snippet_has_removed');
+ },
+});
+
+
+/**
+ * Constructor WysiwygTest why editable and unbreakable node used in test.
+ */
+var WysiwygTest = Wysiwyg.extend({
+ _parentToDestroyForTest: null,
+ /**
+ * Override 'destroy' of discuss so that it calls 'destroy' on the parent.
+ *
+ * @override
+ */
+ destroy: function () {
+ unpatch();
+ this._super();
+ this.$target.remove();
+ this._parentToDestroyForTest.destroy();
+ },
+});
+
+
+function patch() {
+ testUtils.mock.patch(ajax, {
+ loadAsset: function (xmlId) {
+ if (xmlId === 'template.assets') {
+ return Promise.resolve({
+ cssLibs: [],
+ cssContents: ['body {background-color: red;}']
+ });
+ }
+ if (xmlId === 'template.assets_all_style') {
+ return Promise.resolve({
+ cssLibs: $('link[href]:not([type="image/x-icon"])').map(function () {
+ return $(this).attr('href');
+ }).get(),
+ cssContents: ['body {background-color: red;}']
+ });
+ }
+ throw 'Wrong template';
+ },
+ });
+}
+
+function unpatch() {
+ testUtils.mock.unpatch(ajax);
+}
+
+/**
+ * @param {object} data
+ * @returns {object}
+ */
+function wysiwygData(data) {
+ return _.defaults({}, data, {
+ 'ir.ui.view': {
+ fields: {
+ display_name: {
+ string: "Displayed name",
+ type: "char",
+ },
+ },
+ records: [],
+ read_template(args) {
+ if (args[0] === 'web_editor.colorpicker') {
+ return COLOR_PICKER_TEMPLATE;
+ }
+ },
+ render_template(args) {
+ if (args[0] === 'web_editor.snippets') {
+ return SNIPPETS_TEMPLATE;
+ }
+ },
+ },
+ 'ir.attachment': {
+ fields: {
+ display_name: {
+ string: "display_name",
+ type: 'char',
+ },
+ description: {
+ string: "description",
+ type: 'char',
+ },
+ mimetype: {
+ string: "mimetype",
+ type: 'char',
+ },
+ checksum: {
+ string: "checksum",
+ type: 'char',
+ },
+ url: {
+ string: "url",
+ type: 'char',
+ },
+ type: {
+ string: "type",
+ type: 'char',
+ },
+ res_id: {
+ string: "res_id",
+ type: 'integer',
+ },
+ res_model: {
+ string: "res_model",
+ type: 'char',
+ },
+ public: {
+ string: "public",
+ type: 'boolean',
+ },
+ access_token: {
+ string: "access_token",
+ type: 'char',
+ },
+ image_src: {
+ string: "image_src",
+ type: 'char',
+ },
+ image_width: {
+ string: "image_width",
+ type: 'integer',
+ },
+ image_height: {
+ string: "image_height",
+ type: 'integer',
+ },
+ original_id: {
+ string: "original_id",
+ type: 'many2one',
+ relation: 'ir.attachment',
+ },
+ },
+ records: [{
+ id: 1,
+ name: 'image',
+ description: '',
+ mimetype: 'image/png',
+ checksum: false,
+ url: '/web/image/123/transparent.png',
+ type: 'url',
+ res_id: 0,
+ res_model: false,
+ public: true,
+ access_token: false,
+ image_src: '/web/image/123/transparent.png',
+ image_width: 256,
+ image_height: 256,
+ }],
+ generate_access_token: function () {
+ return;
+ },
+ },
+ });
+}
+
+/**
+ * Create the wysiwyg instance for test (contains patch, usefull ir.ui.view, snippets).
+ *
+ * @param {object} params
+ */
+async function createWysiwyg(params) {
+ patch();
+ params.data = wysiwygData(params.data);
+
+ var parent = new Widget();
+ await testUtils.mock.addMockEnvironment(parent, params);
+
+ var wysiwygOptions = _.extend({}, params.wysiwygOptions, {
+ recordInfo: {
+ context: {},
+ res_model: 'module.test',
+ res_id: 1,
+ },
+ useOnlyTestUnbreakable: params.useOnlyTestUnbreakable,
+ });
+
+ var wysiwyg = new WysiwygTest(parent, wysiwygOptions);
+ wysiwyg._parentToDestroyForTest = parent;
+
+ var $textarea = $('<textarea/>');
+ if (wysiwygOptions.value) {
+ $textarea.val(wysiwygOptions.value);
+ }
+ var selector = params.debug ? 'body' : '#qunit-fixture';
+ $textarea.prependTo($(selector));
+ if (params.debug) {
+ $('body').addClass('debug');
+ }
+ return wysiwyg.attachTo($textarea).then(function () {
+ if (wysiwygOptions.snippets) {
+ var defSnippets = testUtils.makeTestPromise();
+ testUtils.mock.intercept(wysiwyg, "snippets_loaded", function () {
+ defSnippets.resolve(wysiwyg);
+ });
+ return defSnippets;
+ }
+ return wysiwyg;
+ });
+}
+
+
+/**
+ * Char codes.
+ */
+var dom = $.summernote.dom;
+var keyboardMap = {
+ "8": "BACKSPACE",
+ "9": "TAB",
+ "13": "ENTER",
+ "16": "SHIFT",
+ "17": "CONTROL",
+ "18": "ALT",
+ "19": "PAUSE",
+ "20": "CAPS_LOCK",
+ "27": "ESCAPE",
+ "32": "SPACE",
+ "33": "PAGE_UP",
+ "34": "PAGE_DOWN",
+ "35": "END",
+ "36": "HOME",
+ "37": "LEFT",
+ "38": "UP",
+ "39": "RIGHT",
+ "40": "DOWN",
+ "45": "INSERT",
+ "46": "DELETE",
+ "91": "OS_KEY", // 'left command': Windows Key (Windows) or Command Key (Mac)
+ "93": "CONTEXT_MENU", // 'right command'
+};
+_.each(_.range(40, 127), function (keyCode) {
+ if (!keyboardMap[keyCode]) {
+ keyboardMap[keyCode] = String.fromCharCode(keyCode);
+ }
+});
+
+/**
+ * Perform a series of tests (`keyboardTests`) for using keyboard inputs.
+ *
+ * @see wysiwyg_keyboard_tests.js
+ * @see wysiwyg_tests.js
+ *
+ * @param {jQuery} $editable
+ * @param {object} assert
+ * @param {object[]} keyboardTests
+ * @param {string} keyboardTests.name
+ * @param {string} keyboardTests.content
+ * @param {object[]} keyboardTests.steps
+ * @param {string} keyboardTests.steps.start
+ * @param {string} [keyboardTests.steps.end] default: steps.start
+ * @param {string} keyboardTests.steps.key
+ * @param {object} keyboardTests.test
+ * @param {string} [keyboardTests.test.content]
+ * @param {string} [keyboardTests.test.start]
+ * @param {string} [keyboardTests.test.end] default: steps.start
+ * @param {function($editable, assert)} [keyboardTests.test.check]
+ * @param {Number} addTests
+ */
+var testKeyboard = function ($editable, assert, keyboardTests, addTests) {
+ var tests = _.compact(_.pluck(keyboardTests, 'test'));
+ var testNumber = _.compact(_.pluck(tests, 'start')).length +
+ _.compact(_.pluck(tests, 'content')).length +
+ _.compact(_.pluck(tests, 'check')).length +
+ (addTests | 0);
+ assert.expect(testNumber);
+
+ function keydown(target, keypress) {
+ var $target = $(target.tagName ? target : target.parentNode);
+ if (!keypress.keyCode) {
+ keypress.keyCode = +_.findKey(keyboardMap, function (key) {
+ return key === keypress.key;
+ });
+ } else {
+ keypress.key = keyboardMap[keypress.keyCode] || String.fromCharCode(keypress.keyCode);
+ }
+ keypress.keyCode = keypress.keyCode;
+ var event = $.Event("keydown", keypress);
+ $target.trigger(event);
+
+ if (!event.isDefaultPrevented()) {
+ if (keypress.key.length === 1) {
+ textInput($target[0], keypress.key);
+ } else {
+ console.warn('Native "' + keypress.key + '" is not supported in test');
+ }
+ }
+ $target.trigger($.Event("keyup", keypress));
+ return $target;
+ }
+
+ function _select(selector) {
+ // eg: ".class:contents()[0]->1" selects the first contents of the 'class' class, with an offset of 1
+ var reDOMSelection = /^(.+?)(:contents(\(\)\[|\()([0-9]+)[\]|\)])?(->([0-9]+))?$/;
+ var sel = selector.match(reDOMSelection);
+ var $node = $editable.find(sel[1]);
+ var point = {
+ node: sel[3] ? $node.contents()[+sel[4]] : $node[0],
+ offset: sel[5] ? +sel[6] : 0,
+ };
+ if (!point.node || point.offset > (point.node.tagName ? point.node.childNodes : point.node.textContent).length) {
+ assert.notOk("Node not found: '" + selector + "' " + (point.node ? "(container: '" + (point.node.outerHTML || point.node.textContent) + "')" : ""));
+ }
+ return point;
+ }
+
+ function selectText(start, end) {
+ start = _select(start);
+ var target = start.node;
+ $(target.tagName ? target : target.parentNode).trigger("mousedown");
+ if (end) {
+ end = _select(end);
+ Wysiwyg.setRange(start.node, start.offset, end.node, end.offset);
+ } else {
+ Wysiwyg.setRange(start.node, start.offset);
+ }
+ target = end ? end.node : start.node;
+ $(target.tagName ? target : target.parentNode).trigger('mouseup');
+ }
+
+ function endOfAreaBetweenTwoNodes(point) {
+ // move the position because some browser make the caret on the end of the previous area after normalize
+ if (
+ !point.node.tagName &&
+ point.offset === point.node.textContent.length &&
+ !/\S|\u00A0/.test(point.node.textContent)
+ ) {
+ point = dom.nextPoint(dom.nextPoint(point));
+ while (point.node.tagName && point.node.textContent.length) {
+ point = dom.nextPoint(point);
+ }
+ }
+ return point;
+ }
+
+ var defPollTest = Promise.resolve();
+
+ function pollTest(test) {
+ var def = Promise.resolve();
+ $editable.data('wysiwyg').setValue(test.content);
+
+ function poll(step) {
+ var def = testUtils.makeTestPromise();
+ if (step.start) {
+ selectText(step.start, step.end);
+ if (!Wysiwyg.getRange($editable[0])) {
+ throw 'Wrong range! \n' +
+ 'Test: ' + test.name + '\n' +
+ 'Selection: ' + step.start + '" to "' + step.end + '"\n' +
+ 'DOM: ' + $editable.html();
+ }
+ }
+ setTimeout(function () {
+ if (step.keyCode || step.key) {
+ var target = Wysiwyg.getRange($editable[0]).ec;
+ if (window.location.search.indexOf('notrycatch') !== -1) {
+ keydown(target, {
+ key: step.key,
+ keyCode: step.keyCode,
+ ctrlKey: !!step.ctrlKey,
+ shiftKey: !!step.shiftKey,
+ altKey: !!step.altKey,
+ metaKey: !!step.metaKey,
+ });
+ } else {
+ try {
+ keydown(target, {
+ key: step.key,
+ keyCode: step.keyCode,
+ ctrlKey: !!step.ctrlKey,
+ shiftKey: !!step.shiftKey,
+ altKey: !!step.altKey,
+ metaKey: !!step.metaKey,
+ });
+ } catch (e) {
+ assert.notOk(e.name + '\n\n' + e.stack, test.name);
+ }
+ }
+ }
+ setTimeout(function () {
+ if (step.keyCode || step.key) {
+ var $target = $(target.tagName ? target : target.parentNode);
+ $target.trigger($.Event('keyup', {
+ key: step.key,
+ keyCode: step.keyCode,
+ ctrlKey: !!step.ctrlKey,
+ shiftKey: !!step.shiftKey,
+ altKey: !!step.altKey,
+ metaKey: !!step.metaKey,
+ }));
+ }
+ setTimeout(def.resolve.bind(def));
+ });
+ });
+ return def;
+ }
+ while (test.steps.length) {
+ def = def.then(poll.bind(null, test.steps.shift()));
+ }
+
+ return def.then(function () {
+ if (!test.test) {
+ return;
+ }
+
+ if (test.test.check) {
+ test.test.check($editable, assert);
+ }
+
+ // test content
+ if (test.test.content) {
+ var value = $editable.data('wysiwyg').getValue({
+ keepPopover: true,
+ });
+ var allInvisible = /\u200B/g;
+ value = value.replace(allInvisible, '&#8203;');
+ var result = test.test.content.replace(allInvisible, '&#8203;');
+ assert.strictEqual(value, result, test.name);
+
+ if (test.test.start && value !== result) {
+ assert.notOk("Wrong DOM (see previous assert)", test.name + " (carret position)");
+ return;
+ }
+ }
+
+ $editable[0].normalize();
+
+ // test carret position
+ if (test.test.start) {
+ var start = _select(test.test.start);
+ var range = Wysiwyg.getRange($editable[0]);
+ if ((range.sc !== range.ec || range.so !== range.eo) && !test.test.end) {
+ assert.ok(false, test.name + ": the carret is not colapsed and the 'end' selector in test is missing");
+ return;
+ }
+ var end = test.test.end ? _select(test.test.end) : start;
+ if (start.node && end.node) {
+ range = Wysiwyg.getRange($editable[0]);
+ var startPoint = endOfAreaBetweenTwoNodes({
+ node: range.sc,
+ offset: range.so,
+ });
+ var endPoint = endOfAreaBetweenTwoNodes({
+ node: range.ec,
+ offset: range.eo,
+ });
+ var sameDOM = (startPoint.node.outerHTML || startPoint.node.textContent) === (start.node.outerHTML || start.node.textContent);
+ var stringify = function (obj) {
+ if (!sameDOM) {
+ delete obj.sameDOMsameNode;
+ }
+ return JSON.stringify(obj, null, 2)
+ .replace(/"([^"\s-]+)":/g, "\$1:")
+ .replace(/([^\\])"/g, "\$1'")
+ .replace(/\\"/g, '"');
+ };
+ assert.deepEqual(stringify({
+ startNode: startPoint.node.outerHTML || startPoint.node.textContent,
+ startOffset: startPoint.offset,
+ endPoint: endPoint.node.outerHTML || endPoint.node.textContent,
+ endOffset: endPoint.offset,
+ sameDOMsameNode: sameDOM && startPoint.node === start.node,
+ }),
+ stringify({
+ startNode: start.node.outerHTML || start.node.textContent,
+ startOffset: start.offset,
+ endPoint: end.node.outerHTML || end.node.textContent,
+ endOffset: end.offset,
+ sameDOMsameNode: true,
+ }),
+ test.name + " (carret position)");
+ }
+ }
+ });
+ }
+ while (keyboardTests.length) {
+ defPollTest = defPollTest.then(pollTest.bind(null, keyboardTests.shift()));
+ }
+
+ return defPollTest;
+};
+
+
+/**
+ * Select a node in the dom with is offset.
+ *
+ * @param {String} startSelector
+ * @param {String} endSelector
+ * @param {jQuery} $editable
+ * @returns {Object} {sc, so, ec, eo}
+ */
+var select = (function () {
+ var __select = function (selector, $editable) {
+ var sel = selector.match(/^(.+?)(:contents\(\)\[([0-9]+)\]|:contents\(([0-9]+)\))?(->([0-9]+))?$/);
+ var $node = $editable.find(sel[1]);
+ return {
+ node: sel[2] ? $node.contents()[sel[3] ? +sel[3] : +sel[4]] : $node[0],
+ offset: sel[5] ? +sel[6] : 0,
+ };
+ };
+ return function (startSelector, endSelector, $editable) {
+ var start = __select(startSelector, $editable);
+ var end = endSelector ? __select(endSelector, $editable) : start;
+ return {
+ sc: start.node,
+ so: start.offset,
+ ec: end.node,
+ eo: end.offset,
+ };
+ };
+})();
+
+/**
+ * Trigger a keydown event.
+ *
+ * @param {String or Number} key (name or code)
+ * @param {jQuery} $editable
+ * @param {Object} [options]
+ * @param {Boolean} [options.firstDeselect] (default: false) true to deselect before pressing
+ */
+var keydown = function (key, $editable, options) {
+ var keyPress = {};
+ if (typeof key === 'string') {
+ keyPress.key = key;
+ keyPress.keyCode = +_.findKey(keyboardMap, function (k) {
+ return k === key;
+ });
+ } else {
+ keyPress.key = keyboardMap[key] || String.fromCharCode(key);
+ keyPress.keyCode = key;
+ }
+ var range = Wysiwyg.getRange($editable[0]);
+ if (!range) {
+ console.error("Editor have not any range");
+ return;
+ }
+ if (options && options.firstDeselect) {
+ range.sc = range.ec;
+ range.so = range.eo;
+ Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo);
+ }
+ var target = range.ec;
+ var $target = $(target.tagName ? target : target.parentNode);
+ var event = $.Event("keydown", keyPress);
+ $target.trigger(event);
+
+ if (!event.isDefaultPrevented()) {
+ if (keyPress.key.length === 1) {
+ textInput($target[0], keyPress.key);
+ } else {
+ console.warn('Native "' + keyPress.key + '" is not supported in test');
+ }
+ }
+};
+
+var textInput = function (target, char) {
+ var ev = new CustomEvent('textInput', {
+ bubbles: true,
+ cancelBubble: false,
+ cancelable: true,
+ composed: true,
+ data: char,
+ defaultPrevented: false,
+ detail: 0,
+ eventPhase: 3,
+ isTrusted: true,
+ returnValue: true,
+ sourceCapabilities: null,
+ type: "textInput",
+ which: 0,
+ });
+ ev.data = char;
+ target.dispatchEvent(ev);
+
+ if (!ev.defaultPrevented) {
+ document.execCommand("insertText", 0, ev.data);
+ }
+};
+
+return {
+ wysiwygData: wysiwygData,
+ createWysiwyg: createWysiwyg,
+ testKeyboard: testKeyboard,
+ select: select,
+ keydown: keydown,
+ patch: patch,
+ unpatch: unpatch,
+};
+
+
+});