summaryrefslogtreecommitdiff
path: root/addons/web_editor
diff options
context:
space:
mode:
authorChristophe Matthieu <chm@odoo.com>2015-07-07 16:02:53 +0200
committerChristophe Matthieu <chm@odoo.com>2015-07-10 17:00:11 +0200
commit8c559044a5e1be111dcb577ad7e5ab462c1002a0 (patch)
treeba1602e15b2bca38babd6cca63a41da1d05b4321 /addons/web_editor
parenta616c57c609da0e5e56835dcb45e1c05590d25b8 (diff)
[IMP] web_editor: split website into web_editor (to use editor in backend for html field) and website
Diffstat (limited to 'addons/web_editor')
-rw-r--r--addons/web_editor/__init__.py4
-rw-r--r--addons/web_editor/__openerp__.py21
-rw-r--r--addons/web_editor/controllers/__init__.py1
-rw-r--r--addons/web_editor/controllers/main.py276
-rw-r--r--addons/web_editor/models/__init__.py7
-rw-r--r--addons/web_editor/models/ir_attachment.py149
-rw-r--r--addons/web_editor/models/ir_http.py16
-rw-r--r--addons/web_editor/models/ir_qweb.py566
-rw-r--r--addons/web_editor/models/ir_ui_view.py123
-rw-r--r--addons/web_editor/models/test_models.py43
-rw-r--r--addons/web_editor/security/ir.model.access.csv3
-rw-r--r--addons/web_editor/static/src/css/backend.css73
-rw-r--r--addons/web_editor/static/src/css/common.sass4
-rw-r--r--addons/web_editor/static/src/css/inline.css64
-rw-r--r--addons/web_editor/static/src/css/snippets.css490
-rw-r--r--addons/web_editor/static/src/css/snippets.sass376
-rw-r--r--addons/web_editor/static/src/css/web_editor.css266
-rw-r--r--addons/web_editor/static/src/css/web_editor.sass195
-rw-r--r--addons/web_editor/static/src/img/drag_here.pngbin0 -> 619 bytes
-rw-r--r--addons/web_editor/static/src/img/edit_here.pngbin0 -> 491 bytes
-rw-r--r--addons/web_editor/static/src/js/backend.js322
-rw-r--r--addons/web_editor/static/src/js/base.js85
-rw-r--r--addons/web_editor/static/src/js/editor.js164
-rw-r--r--addons/web_editor/static/src/js/iframe.js78
-rw-r--r--addons/web_editor/static/src/js/inline.js77
-rw-r--r--addons/web_editor/static/src/js/rte.js597
-rw-r--r--addons/web_editor/static/src/js/rte.summernote.js781
-rw-r--r--addons/web_editor/static/src/js/snippets.animation.js82
-rw-r--r--addons/web_editor/static/src/js/snippets.editor.js1137
-rw-r--r--addons/web_editor/static/src/js/snippets.options.js534
-rw-r--r--addons/web_editor/static/src/js/summernote.js2192
-rw-r--r--addons/web_editor/static/src/js/tour.rte.js398
-rw-r--r--addons/web_editor/static/src/js/transcoder.js172
-rw-r--r--addons/web_editor/static/src/js/translator.js214
-rw-r--r--addons/web_editor/static/src/js/widgets.js1069
-rw-r--r--addons/web_editor/static/src/xml/backend.xml59
-rw-r--r--addons/web_editor/static/src/xml/editor.xml372
-rw-r--r--addons/web_editor/static/src/xml/snippets.xml74
-rw-r--r--addons/web_editor/static/src/xml/translator.xml14
-rw-r--r--addons/web_editor/tests/__init__.py3
-rw-r--r--addons/web_editor/tests/test_converter.py243
-rw-r--r--addons/web_editor/tests/test_ui.py9
-rw-r--r--addons/web_editor/views/editor.xml153
-rw-r--r--addons/web_editor/views/iframe.xml62
-rw-r--r--addons/web_editor/views/snippets.xml56
45 files changed, 11624 insertions, 0 deletions
diff --git a/addons/web_editor/__init__.py b/addons/web_editor/__init__.py
new file mode 100644
index 00000000000..03ab5da2fb6
--- /dev/null
+++ b/addons/web_editor/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+import controllers
+import models
+import tests
diff --git a/addons/web_editor/__openerp__.py b/addons/web_editor/__openerp__.py
new file mode 100644
index 00000000000..66fdff08ce1
--- /dev/null
+++ b/addons/web_editor/__openerp__.py
@@ -0,0 +1,21 @@
+{
+ 'name': 'Web Editor',
+ 'category': 'Hidden',
+ 'description': """
+Odoo Web Editor widget.
+==========================
+
+""",
+ 'author': 'Odoo S.A.',
+ 'depends': ['web'],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'views/editor.xml',
+ 'views/iframe.xml',
+ 'views/snippets.xml',
+ ],
+ 'qweb': [
+ 'static/src/xml/*.xml',
+ ],
+ 'auto_install': True
+}
diff --git a/addons/web_editor/controllers/__init__.py b/addons/web_editor/controllers/__init__.py
new file mode 100644
index 00000000000..8ee9bae18d9
--- /dev/null
+++ b/addons/web_editor/controllers/__init__.py
@@ -0,0 +1 @@
+import main
diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py
new file mode 100644
index 00000000000..7d9b6206ae7
--- /dev/null
+++ b/addons/web_editor/controllers/main.py
@@ -0,0 +1,276 @@
+# -*- coding: utf-8 -*-
+from openerp.http import request, STATIC_CACHE
+from openerp.addons.web import http
+import json
+import io
+from PIL import Image, ImageFont, ImageDraw
+from openerp import tools
+import cStringIO
+import werkzeug.wrappers
+import time
+import logging
+logger = logging.getLogger(__name__)
+
+
+class Web_Editor(http.Controller):
+ #------------------------------------------------------
+ # Backend snippet
+ #------------------------------------------------------
+ @http.route('/web_editor/snippets', type='json', auth="user")
+ def snippets(self, **kwargs):
+ return request.registry["ir.ui.view"].render(request.cr, request.uid, 'web_editor.snippets', None, context=request.context)
+
+ #------------------------------------------------------
+ # Backend html field
+ #------------------------------------------------------
+ @http.route('/web_editor/field/html', type='http', auth="user")
+ def FieldTextHtml(self, model=None, res_id=None, field=None, callback=None, **kwargs):
+ cr, uid, context = request.cr, request.uid, request.context
+
+ kwargs.update(
+ model=model,
+ res_id=res_id,
+ field=field,
+ datarecord=json.loads(kwargs['datarecord']),
+ debug='debug' in kwargs)
+
+ for k in kwargs:
+ if isinstance(kwargs[k], basestring) and kwargs[k].isdigit():
+ kwargs[k] = int(kwargs[k])
+
+ trans = dict(
+ lang=kwargs.get('lang', context.get('lang')),
+ translatable=kwargs.get('translatable'),
+ edit_translations=kwargs.get('edit_translations'),
+ editable=kwargs.get('enable_editor'))
+
+ context.update(trans)
+ kwargs.update(trans)
+
+ record = None
+ if model and kwargs.get('res_id'):
+ record = request.registry[model].browse(cr, uid, kwargs.get('res_id'), context)
+
+ kwargs.update(content=record and getattr(record, field) or "")
+
+ return request.render(kwargs.get("template") or "web_editor.FieldTextHtml", kwargs, uid=request.uid)
+
+ #------------------------------------------------------
+ # Backend html field in inline mode
+ #------------------------------------------------------
+ @http.route('/web_editor/field/html/inline', type='http', auth="user")
+ def FieldTextHtmlInline(self, model=None, res_id=None, field=None, callback=None, **kwargs):
+ kwargs['inline_mode'] = True
+ kwargs['dont_load_assets'] = not kwargs.get('enable_editor') and not kwargs.get('edit_translations')
+ return self.FieldTextHtml(model, res_id, field, callback, **kwargs)
+
+ #------------------------------------------------------
+ # convert font into picture
+ #------------------------------------------------------
+ @http.route([
+ '/web_editor/font_to_img/<icon>',
+ '/web_editor/font_to_img/<icon>/<color>',
+ '/web_editor/font_to_img/<icon>/<color>/<int:size>',
+ '/web_editor/font_to_img/<icon>/<color>/<int:size>/<int:alpha>',
+ ], type='http', auth="none")
+ def export_icon_to_png(self, icon, color='#000', size=100, alpha=255, font='/web/static/lib/fontawesome/fonts/fontawesome-webfont.ttf'):
+ """ This method converts FontAwesom pictograms to Images and is Used only
+ for mass mailing becuase custom fonts are not supported in mail.
+ :param icon : character from FontAwesom cheatsheet
+ :param color : RGB code of the color
+ :param size : Pixels in integer
+ :param alpha : transparency of the image from 0 to 255
+ :param font : font path
+
+ :returns PNG image converted from given font
+ """
+ # Initialize font
+ addons_path = http.addons_manifest['web']['addons_path']
+ font_obj = ImageFont.truetype(addons_path + font, size)
+
+ # Determine the dimensions of the icon
+ image = Image.new("RGBA", (size, size), color=(0, 0, 0, 0))
+ draw = ImageDraw.Draw(image)
+
+ boxw, boxh = draw.textsize(icon, font=font_obj)
+ draw.text((0, 0), icon, font=font_obj)
+ left, top, right, bottom = image.getbbox()
+
+ # Create an alpha mask
+ imagemask = Image.new("L", (boxw, boxh), 0)
+ drawmask = ImageDraw.Draw(imagemask)
+ drawmask.text((-left, -top), icon, font=font_obj, fill=alpha)
+
+ # Create a solid color image and apply the mask
+ if color.startswith('rgba'):
+ color = color.replace('rgba', 'rgb')
+ color = ','.join(color.split(',')[:-1])+')'
+ iconimage = Image.new("RGBA", (boxw, boxh), color)
+ iconimage.putalpha(imagemask)
+
+ # Create output image
+ outimage = Image.new("RGBA", (boxw, size), (0, 0, 0, 0))
+ outimage.paste(iconimage, (left, top))
+
+ # output image
+ output = io.BytesIO()
+ outimage.save(output, format="PNG")
+ response = werkzeug.wrappers.Response()
+ response.mimetype = 'image/png'
+ response.data = output.getvalue()
+ response.headers['Cache-Control'] = 'public, max-age=604800'
+ response.headers['Access-Control-Allow-Origin'] = '*'
+ response.headers['Access-Control-Allow-Methods'] = 'GET, POST'
+ response.headers['Connection'] = 'close'
+ response.headers['Date'] = time.strftime("%a, %d-%b-%Y %T GMT", time.gmtime())
+ response.headers['Expires'] = time.strftime("%a, %d-%b-%Y %T GMT", time.gmtime(time.time()+604800*60))
+
+ return response
+
+ #------------------------------------------------------
+ # image route for browse record
+ #------------------------------------------------------
+ def placeholder(self, response):
+ return request.registry['ir.attachment']._image_placeholder(response)
+
+ @http.route([
+ '/web_editor/image',
+ '/web_editor/image/<xmlid>',
+ '/web_editor/image/<xmlid>/<int:max_width>x<int:max_height>',
+ '/web_editor/image/<xmlid>/<field>',
+ '/web_editor/image/<xmlid>/<field>/<int:max_width>x<int:max_height>',
+ '/web_editor/image/<model>/<id>/<field>',
+ '/web_editor/image/<model>/<id>/<field>/<int:max_width>x<int:max_height>'
+ ], type='http', auth="public")
+ def image(self, model=None, id=None, field=None, xmlid=None, max_width=None, max_height=None):
+ """ Fetches the requested field and ensures it does not go above
+ (max_width, max_height), resizing it if necessary.
+
+ If the record is not found or does not have the requested field,
+ returns a placeholder image via :meth:`~.placeholder`.
+
+ Sets and checks conditional response parameters:
+ * :mailheader:`ETag` is always set (and checked)
+ * :mailheader:`Last-Modified is set iif the record has a concurrency
+ field (``__last_update``)
+
+ The requested field is assumed to be base64-encoded image data in
+ all cases.
+
+ xmlid can be used to load the image. But the field image must by base64-encoded
+ """
+ if xmlid and "." in xmlid:
+ try:
+ record = request.env.ref(xmlid)
+ model, id = record._name, record.id
+ except:
+ raise werkzeug.exceptions.NotFound()
+ if model == 'ir.attachment' and not field:
+ if record.sudo().type == "url":
+ field = "url"
+ else:
+ field = "datas"
+
+ if not model or not id or not field:
+ raise werkzeug.exceptions.NotFound()
+
+ try:
+ idsha = str(id).split('_')
+ id = idsha[0]
+ response = werkzeug.wrappers.Response()
+ return request.registry['ir.attachment']._image(
+ request.cr, request.uid, model, id, field, response, max_width, max_height,
+ cache=STATIC_CACHE if len(idsha) > 1 else None)
+ except Exception:
+ logger.exception("Cannot render image field %r of record %s[%s] at size(%s,%s)",
+ field, model, id, max_width, max_height)
+ response = werkzeug.wrappers.Response()
+ return self.placeholder(response)
+
+ #------------------------------------------------------
+ # add attachment (images or link)
+ #------------------------------------------------------
+ @http.route('/web_editor/attachment/add', type='http', auth='user', methods=['POST'])
+ def attach(self, func, upload=None, url=None, disable_optimization=None, **kwargs):
+ # the upload argument doesn't allow us to access the files if more than
+ # one file is uploaded, as upload references the first file
+ # therefore we have to recover the files from the request object
+ Attachments = request.registry['ir.attachment'] # registry for the attachment table
+
+ uploads = []
+ message = None
+ if not upload: # no image provided, storing the link and the image name
+ uploads.append({'website_url': url})
+ name = url.split("/").pop() # recover filename
+ attachment_id = Attachments.create(request.cr, request.uid, {
+ 'name': name,
+ 'type': 'url',
+ 'url': url,
+ 'res_model': 'ir.ui.view',
+ }, request.context)
+ else: # images provided
+ try:
+ attachment_ids = []
+ for c_file in request.httprequest.files.getlist('upload'):
+ image_data = c_file.read()
+ image = Image.open(cStringIO.StringIO(image_data))
+ w, h = image.size
+ if w*h > 42e6: # Nokia Lumia 1020 photo resolution
+ raise ValueError(
+ u"Image size excessive, uploaded images must be smaller "
+ u"than 42 million pixel")
+
+ if not disable_optimization and image.format in ('PNG', 'JPEG'):
+ image_data = tools.image_save_for_web(image)
+
+ attachment_id = Attachments.create(request.cr, request.uid, {
+ 'name': c_file.filename,
+ 'datas': image_data.encode('base64'),
+ 'datas_fname': c_file.filename,
+ 'res_model': 'ir.ui.view',
+ }, request.context)
+ attachment_ids.append(attachment_id)
+
+ uploads = Attachments.read(
+ request.cr, request.uid, attachment_ids, ['website_url'],
+ context=request.context)
+ except Exception, e:
+ logger.exception("Failed to upload image to attachment")
+ message = unicode(e)
+
+ return """<script type='text/javascript'>
+ window.parent['%s'](%s, %s);
+ </script>""" % (func, json.dumps(uploads), json.dumps(message))
+
+ #------------------------------------------------------
+ # remove attachment (images or link)
+ #------------------------------------------------------
+ @http.route('/web_editor/attachment/remove', type='json', auth='user')
+ def remove(self, ids, **kwargs):
+ """ Removes a web-based image attachment if it is used by no view (template)
+
+ Returns a dict mapping attachments which would not be removed (if any)
+ mapped to the views preventing their removal
+ """
+ cr, uid, context = request.cr, request.uid, request.context
+ Attachment = request.registry['ir.attachment']
+ Views = request.registry['ir.ui.view']
+
+ attachments_to_remove = []
+ # views blocking removal of the attachment
+ removal_blocked_by = {}
+
+ for attachment in Attachment.browse(cr, uid, ids, context=context):
+ # in-document URLs are html-escaped, a straight search will not
+ # find them
+ url = tools.html_escape(attachment.local_url)
+ ids = Views.search(cr, uid, ["|", ('arch_db', 'like', '"%s"' % url), ('arch_db', 'like', "'%s'" % url)], context=context)
+
+ if ids:
+ removal_blocked_by[attachment.id] = Views.read(
+ cr, uid, ids, ['name'], context=context)
+ else:
+ attachments_to_remove.append(attachment.id)
+ if attachments_to_remove:
+ Attachment.unlink(cr, uid, attachments_to_remove, context=context)
+ return removal_blocked_by
diff --git a/addons/web_editor/models/__init__.py b/addons/web_editor/models/__init__.py
new file mode 100644
index 00000000000..43522002bfc
--- /dev/null
+++ b/addons/web_editor/models/__init__.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+import ir_attachment
+import ir_qweb
+import ir_ui_view
+import ir_http
+
+import test_models
diff --git a/addons/web_editor/models/ir_attachment.py b/addons/web_editor/models/ir_attachment.py
new file mode 100644
index 00000000000..3068807b2ab
--- /dev/null
+++ b/addons/web_editor/models/ir_attachment.py
@@ -0,0 +1,149 @@
+# -*- coding: utf-8 -*-
+import openerp
+from openerp.osv import osv, fields
+
+import contextlib
+from sys import maxint
+from openerp.addons.web.http import request
+import datetime
+import hashlib
+import time
+import os
+
+from openerp.tools import html_escape as escape, ustr, image_resize_and_sharpen, image_save_for_web
+from PIL import Image
+import cStringIO
+
+import logging
+logger = logging.getLogger(__name__)
+
+
+class ir_attachment(osv.osv):
+
+ _inherit = "ir.attachment"
+
+ def _local_url_get(self, cr, uid, ids, name, arg, context=None):
+ result = {}
+ for attach in self.browse(cr, uid, ids, context=context):
+ if attach.url:
+ result[attach.id] = attach.url
+ else:
+ result[attach.id] = self.image_url(cr, uid, attach, 'datas')
+ return result
+
+ _columns = {
+ 'local_url': fields.function(_local_url_get, string="Attachment URL", type='char'),
+ }
+
+ def _image_placeholder(self, response):
+ # file_open may return a StringIO. StringIO can be closed but are
+ # not context managers in Python 2 though that is fixed in 3
+ with contextlib.closing(openerp.tools.misc.file_open(
+ os.path.join('web', 'static', 'src', 'img', 'placeholder.png'),
+ mode='rb')) as f:
+ response.data = f.read()
+ return response.make_conditional(request.httprequest)
+
+ def _image(self, cr, uid, model, id_or_ids, field, response, max_width=maxint, max_height=maxint, cache=None, context=None):
+ """ Fetches the requested field and ensures it does not go above
+ (max_width, max_height), resizing it if necessary.
+
+ Resizing is bypassed if the object provides a $field_big, which will
+ be interpreted as a pre-resized version of the base field.
+
+ If the record is not found or does not have the requested field,
+ returns a placeholder image via :meth:`~._image_placeholder`.
+
+ Sets and checks conditional response parameters:
+ * :mailheader:`ETag` is always set (and checked)
+ * :mailheader:`Last-Modified is set iif the record has a concurrency
+ field (``__last_update``)
+
+ The requested field is assumed to be base64-encoded image data in
+ all cases.
+ """
+ Model = self.pool[model]
+ ids = isinstance(id_or_ids, (list, tuple)) and id_or_ids or [int(id_or_ids)]
+ ids = Model.search(cr, uid, [('id', 'in', ids)], context=context)
+
+ if not ids:
+ return self._image_placeholder(response)
+
+ concurrency = '__last_update'
+ [record] = Model.read(cr, openerp.SUPERUSER_ID, ids, [concurrency, field], context=context)
+
+ if concurrency in record:
+ server_format = openerp.tools.misc.DEFAULT_SERVER_DATETIME_FORMAT
+ try:
+ response.last_modified = datetime.datetime.strptime(
+ record[concurrency], server_format + '.%f')
+ except ValueError:
+ # just in case we have a timestamp without microseconds
+ response.last_modified = datetime.datetime.strptime(
+ record[concurrency], server_format)
+
+ # Field does not exist on model or field set to False
+ if not record.get(field):
+ # FIXME: maybe a field which does not exist should be a 404?
+ return self._image_placeholder(response)
+
+ response.set_etag(hashlib.sha1(record[field]).hexdigest())
+ response.make_conditional(request.httprequest)
+
+ if cache:
+ response.cache_control.max_age = cache
+ response.expires = int(time.time() + cache)
+
+ # conditional request match
+ if response.status_code == 304:
+ return response
+
+ if model == 'ir.attachment' and field == 'url' and field in record:
+ path = record[field].strip('/')
+
+ # Check that we serve a file from within the module
+ if os.path.normpath(path).startswith('..'):
+ return self._image_placeholder(response)
+
+ # Check that the file actually exists
+ path = path.split('/')
+ resource = openerp.modules.get_module_resource(*path)
+ if not resource:
+ return self._image_placeholder(response)
+
+ data = open(resource, 'rb').read()
+ else:
+ data = record[field].decode('base64')
+ image = Image.open(cStringIO.StringIO(data))
+ response.mimetype = Image.MIME[image.format]
+
+ filename = '%s_%s.%s' % (model.replace('.', '_'), ids[0], str(image.format).lower())
+ response.headers['Content-Disposition'] = 'inline; filename="%s"' % filename
+
+ if (not max_width) and (not max_height):
+ response.data = data
+ return response
+
+ w, h = image.size
+ max_w = int(max_width) if max_width else maxint
+ max_h = int(max_height) if max_height else maxint
+
+ if w < max_w and h < max_h:
+ response.data = data
+ else:
+ size = (max_w, max_h)
+ img = image_resize_and_sharpen(image, size, preserve_aspect_ratio=True)
+ image_save_for_web(img, response.stream, format=image.format)
+ # invalidate content-length computed by make_conditional as
+ # writing to response.stream does not do it (as of werkzeug 0.9.3)
+ del response.headers['Content-Length']
+
+ return response
+
+ def image_url(self, cr, uid, record, field, size=None, context=None):
+ """Returns a local url that points to the image field of a given browse record."""
+ model = record._name
+ sudo_record = record.sudo()
+ id = '%s_%s' % (record.id, hashlib.sha1(sudo_record.write_date or sudo_record.create_date or '').hexdigest()[0:7])
+ size = '' if size is None else '/%s' % size
+ return '/web_editor/image/%s/%s/%s%s' % (model, id, field, size)
diff --git a/addons/web_editor/models/ir_http.py b/addons/web_editor/models/ir_http.py
new file mode 100644
index 00000000000..c72fee55e83
--- /dev/null
+++ b/addons/web_editor/models/ir_http.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+from openerp.osv import orm
+from openerp.http import request
+
+
+class ir_http(orm.AbstractModel):
+ _inherit = 'ir.http'
+
+ def _dispatch(self):
+ if 'editable' in request.httprequest.args and 'editable' not in request.context:
+ request.context['editable'] = True
+ if 'edit_translations' in request.httprequest.args and 'edit_translations' not in request.context:
+ request.context['edit_translations'] = True
+ if request.context.get('lang') != "en_US" and 'translatable' not in request.context:
+ request.context['translatable'] = True
+ return super(ir_http, self)._dispatch()
diff --git a/addons/web_editor/models/ir_qweb.py b/addons/web_editor/models/ir_qweb.py
new file mode 100644
index 00000000000..9642a3c2881
--- /dev/null
+++ b/addons/web_editor/models/ir_qweb.py
@@ -0,0 +1,566 @@
+# -*- coding: utf-8 -*-
+"""
+Web_editor-context rendering needs to add some metadata to rendered and allow to edit fields,
+as well as render a few fields differently.
+
+Also, adds methods to convert values back to openerp models.
+"""
+
+import cStringIO
+import datetime
+import itertools
+import logging
+import os
+import urllib2
+import urlparse
+import re
+import simplejson
+
+import pytz
+from dateutil import parser
+from lxml import etree, html
+from PIL import Image as I
+import openerp.modules
+
+import openerp
+from openerp.osv import orm, fields
+from openerp.tools import ustr, DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT
+from openerp.tools import html_escape as escape
+from openerp.addons.base.ir import ir_qweb
+
+REMOTE_CONNECTION_TIMEOUT = 2.5
+
+logger = logging.getLogger(__name__)
+
+
+class QWeb(orm.AbstractModel):
+ """ QWeb object for rendering editor stuff
+ """
+ _name = 'ir.qweb'
+ _inherit = 'ir.qweb'
+
+ re_remove_spaces = re.compile('\s+')
+
+ def render_tag_snippet(self, element, template_attributes, generated_attributes, qwebcontext):
+ cr = qwebcontext['request'].cr
+ uid = qwebcontext['request'].uid
+ view_reg = self.pool['ir.ui.view']
+ page_xmlid = self.eval_format(template_attributes["snippet"], qwebcontext)
+ view = view_reg.browse(cr, uid, view_reg.search(cr, uid, [('key', '=', page_xmlid)]))
+ generated_attributes += " " + "".join([
+ 'data-oe-name="%s"' % view.name,
+ 'data-oe-type="snippet"',
+ 'data-oe-thumbnail="%s"' % template_attributes.get('thumbnail', "oe-thumbnail")])
+
+ template_attributes['call'] = template_attributes['snippet']
+ return self.render_tag_call(element, template_attributes, generated_attributes, qwebcontext)
+
+ def get_converter_for(self, field_type):
+ return self.pool.get('ir.qweb.field.' + field_type, self.pool['ir.qweb.field'])
+
+
+#------------------------------------------------------
+# QWeb fields
+#------------------------------------------------------
+
+
+class Field(orm.AbstractModel):
+ _name = 'ir.qweb.field'
+ _inherit = 'ir.qweb.field'
+
+ def attributes(self, cr, uid, field_name, record, options,
+ source_element, g_att, t_att, qweb_context, context=None):
+ if options is None:
+ options = {}
+ field = record._model._fields[field_name]
+ attrs = []
+
+ placeholder = options.get('placeholder') \
+ or source_element.get('placeholder') \
+ or getattr(field, 'placeholder', None)
+ if placeholder:
+ attrs.append(('placeholder', placeholder))
+
+ return itertools.chain(
+ super(Field, self).attributes(cr, uid, field_name, record, options,
+ source_element, g_att, t_att,
+ qweb_context, context=context),
+ attrs
+ )
+
+ def value_from_string(self, value):
+ return value
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ return self.value_from_string(element.text_content().strip())
+
+ def qweb_object(self):
+ return self.pool['ir.qweb']
+
+
+class Integer(orm.AbstractModel):
+ _name = 'ir.qweb.field.integer'
+ _inherit = ['ir.qweb.field']
+
+ value_from_string = int
+
+
+class Float(orm.AbstractModel):
+ _name = 'ir.qweb.field.float'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.float']
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ lang = self.user_lang(cr, uid, context=context)
+
+ value = element.text_content().strip()
+
+ return float(value.replace(lang.thousands_sep, '')
+ .replace(lang.decimal_point, '.'))
+
+
+class ManyToOne(orm.AbstractModel):
+ _name = 'ir.qweb.field.many2one'
+ _inherit = ['ir.qweb.field.many2one']
+
+ def attributes(self, cr, uid, field_name, record, options,
+ source_element, g_att, t_att, qweb_context,
+ context=None):
+ attrs = super(ManyToOne, self).attributes(
+ cr, uid, field_name, record, options, source_element, g_att, t_att,
+ qweb_context, context=context)
+ many2one = getattr(record, field_name)
+ if many2one:
+ data = [('data-oe-many2one-id', many2one.id),
+ ('data-oe-many2one-model', many2one._name)]
+ return itertools.chain(attrs, data)
+ else:
+ return attrs
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ Model = self.pool[element.get('data-oe-model')]
+ M2O = self.pool[field.comodel_name]
+ field_name = element.get('data-oe-field')
+ id = int(element.get('data-oe-id'))
+ many2one_id = int(element.get('data-oe-many2one-id'))
+
+ if many2one_id and M2O.exists(cr, uid, [many2one_id]):
+ # save the new id of the many2one
+ Model.write(cr, uid, [id], {
+ field_name: many2one_id
+ }, context=context)
+
+ # not necessary, but might as well be explicit about it
+ return None
+
+
+class Contact(orm.AbstractModel):
+ _name = 'ir.qweb.field.contact'
+ _inherit = ['ir.qweb.field.contact', 'ir.qweb.field.many2one']
+
+ def attributes(self, cr, uid, field_name, record, options,
+ source_element, g_att, t_att, qweb_context,
+ context=None):
+ attrs = super(Contact, self).attributes(
+ cr, uid, field_name, record, options, source_element, g_att, t_att,
+ qweb_context, context=context)
+ if getattr(record, field_name):
+ return itertools.chain(attrs, [('data-oe-contact-options', simplejson.dumps(options))])
+ else:
+ return attrs
+
+ # helper to call the rendering of contact field
+ def get_record_to_html(self, cr, uid, ids, options=None, context=None):
+ node = self.record_to_html(cr, uid, 'record', {
+ 'record': self.pool['res.partner'].browse(cr, uid, ids[0], context=context)},
+ options=options, context=context)
+ return node and node.__html__()
+
+
+def parse_fuzzy(in_format, value):
+ day_first = in_format.find('%d') < in_format.find('%m')
+
+ if '%y' in in_format:
+ year_first = in_format.find('%y') < in_format.find('%d')
+ else:
+ year_first = in_format.find('%Y') < in_format.find('%d')
+
+ return parser.parse(value, dayfirst=day_first, yearfirst=year_first)
+
+
+class Date(orm.AbstractModel):
+ _name = 'ir.qweb.field.date'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.date']
+
+ def attributes(self, cr, uid, field_name, record, options,
+ source_element, g_att, t_att, qweb_context,
+ context=None):
+ attrs = super(Date, self).attributes(
+ cr, uid, field_name, record, options, source_element, g_att, t_att,
+ qweb_context, context=None)
+ return itertools.chain(attrs, [('data-oe-original', record[field_name])])
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ value = element.text_content().strip()
+ if not value:
+ return False
+
+ datetime.datetime.strptime(value, DEFAULT_SERVER_DATE_FORMAT)
+ return value
+
+
+class DateTime(orm.AbstractModel):
+ _name = 'ir.qweb.field.datetime'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.datetime']
+
+ def attributes(self, cr, uid, field_name, record, options,
+ source_element, g_att, t_att, qweb_context,
+ context=None):
+ value = record[field_name]
+ if isinstance(value, basestring):
+ value = datetime.datetime.strptime(
+ value, DEFAULT_SERVER_DATETIME_FORMAT)
+ if value:
+ # convert from UTC (server timezone) to user timezone
+ value = fields.datetime.context_timestamp(
+ cr, uid, timestamp=value, context=context)
+ value = value.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
+
+ attrs = super(DateTime, self).attributes(
+ cr, uid, field_name, record, options, source_element, g_att, t_att,
+ qweb_context, context=None)
+ return itertools.chain(attrs, [
+ ('data-oe-original', value)
+ ])
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ if context is None:
+ context = {}
+ value = element.text_content().strip()
+ if not value:
+ return False
+
+ # parse from string to datetime
+ dt = datetime.datetime.strptime(value, DEFAULT_SERVER_DATETIME_FORMAT)
+
+ # convert back from user's timezone to UTC
+ tz_name = context.get('tz') \
+ or self.pool['res.users'].read(cr, openerp.SUPERUSER_ID, uid, ['tz'], context=context)['tz']
+ if tz_name:
+ try:
+ user_tz = pytz.timezone(tz_name)
+ utc = pytz.utc
+
+ dt = user_tz.localize(dt).astimezone(utc)
+ except Exception:
+ logger.warn(
+ "Failed to convert the value for a field of the model"
+ " %s back from the user's timezone (%s) to UTC",
+ model, tz_name,
+ exc_info=True)
+
+ # format back to string
+ return dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
+
+
+class Text(orm.AbstractModel):
+ _name = 'ir.qweb.field.text'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.text']
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ return html_to_text(element)
+
+
+class Selection(orm.AbstractModel):
+ _name = 'ir.qweb.field.selection'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.selection']
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ record = self.browse(cr, uid, [], context=context)
+ value = element.text_content().strip()
+ selection = field.get_description(record.env)['selection']
+ for k, v in selection:
+ if isinstance(v, str):
+ v = ustr(v)
+ if value == v:
+ return k
+
+ raise ValueError(u"No value found for label %s in selection %s" % (
+ value, selection))
+
+
+class HTML(orm.AbstractModel):
+ _name = 'ir.qweb.field.html'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.html']
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ content = []
+ if element.text:
+ content.append(element.text)
+ content.extend(html.tostring(child)
+ for child in element.iterchildren(tag=etree.Element))
+ return '\n'.join(content)
+
+
+class Image(orm.AbstractModel):
+ """
+ Widget options:
+
+ ``class``
+ set as attribute on the generated <img> tag
+ """
+ _name = 'ir.qweb.field.image'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.image']
+
+ def to_html(self, cr, uid, field_name, record, options,
+ source_element, t_att, g_att, qweb_context, context=None):
+ assert source_element.tag != 'img',\
+ "Oddly enough, the root tag of an image field can not be img. " \
+ "That is because the image goes into the tag, or it gets the " \
+ "hose again."
+
+ return super(Image, self).to_html(
+ cr, uid, field_name, record, options,
+ source_element, t_att, g_att, qweb_context, context=context)
+
+ def record_to_html(self, cr, uid, field_name, record, options=None, context=None):
+ if options is None:
+ options = {}
+ aclasses = ['img', 'img-responsive'] + options.get('class', '').split()
+ classes = ' '.join(itertools.imap(escape, aclasses))
+
+ max_size = None
+ max_width, max_height = options.get('max_width', 0), options.get('max_height', 0)
+ if max_width or max_height:
+ max_size = '%sx%s' % (max_width, max_height)
+
+ src = self.pool['ir.attachment'].image_url(cr, uid, record, field_name, max_size)
+ if options.get('resize'):
+ src = "%s/%s" % (src, options.get('resize'))
+
+ img = '<img class="%s" src="%s" style="%s"/>' % (classes, src, options.get('style', ''))
+ return ir_qweb.HTMLSafe(img)
+
+ local_url_re = re.compile(r'^/(?P<module>[^]]+)/static/(?P<rest>.+)$')
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ url = element.find('img').get('src')
+
+ url_object = urlparse.urlsplit(url)
+ if url_object.path.startswith('/web_editor/image'):
+ # url might be /web_editor/image/<model>/<id>[_<checksum>]/<field>[/<width>x<height>]
+ fragments = url_object.path.split('/')
+ query = dict(urlparse.parse_qsl(url_object.query))
+ model = query.get('model', fragments[3])
+ oid = query.get('id', fragments[4].split('_')[0])
+ field = query.get('field', fragments[5])
+ item = self.pool[model].browse(cr, uid, int(oid), context=context)
+ return item[field]
+
+ if self.local_url_re.match(url_object.path):
+ return self.load_local_url(url)
+
+ return self.load_remote_url(url)
+
+ def load_local_url(self, url):
+ match = self.local_url_re.match(urlparse.urlsplit(url).path)
+
+ rest = match.group('rest')
+ for sep in os.sep, os.altsep:
+ if sep and sep != '/':
+ rest.replace(sep, '/')
+
+ path = openerp.modules.get_module_resource(
+ match.group('module'), 'static', *(rest.split('/')))
+
+ if not path:
+ return None
+
+ try:
+ with open(path, 'rb') as f:
+ # force complete image load to ensure it's valid image data
+ image = I.open(f)
+ image.load()
+ f.seek(0)
+ return f.read().encode('base64')
+ except Exception:
+ logger.exception("Failed to load local image %r", url)
+ return None
+
+ def load_remote_url(self, url):
+ try:
+ # should probably remove remote URLs entirely:
+ # * in fields, downloading them without blowing up the server is a
+ # challenge
+ # * in views, may trigger mixed content warnings if HTTPS CMS
+ # linking to HTTP images
+ # implement drag & drop image upload to mitigate?
+
+ req = urllib2.urlopen(url, timeout=REMOTE_CONNECTION_TIMEOUT)
+ # PIL needs a seekable file-like image, urllib result is not seekable
+ image = I.open(cStringIO.StringIO(req.read()))
+ # force a complete load of the image data to validate it
+ image.load()
+ except Exception:
+ logger.exception("Failed to load remote image %r", url)
+ return None
+
+ # don't use original data in case weird stuff was smuggled in, with
+ # luck PIL will remove some of it?
+ out = cStringIO.StringIO()
+ image.save(out, image.format)
+ return out.getvalue().encode('base64')
+
+
+class Monetary(orm.AbstractModel):
+ _name = 'ir.qweb.field.monetary'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.monetary']
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ lang = self.user_lang(cr, uid, context=context)
+
+ value = element.find('span').text.strip()
+
+ return float(value.replace(lang.thousands_sep, '')
+ .replace(lang.decimal_point, '.'))
+
+
+class Duration(orm.AbstractModel):
+ _name = 'ir.qweb.field.duration'
+ _inherit = [
+ 'ir.qweb.field.duration',
+ 'ir.qweb.field.float',
+ ]
+
+ def attributes(self, cr, uid, field_name, record, options,
+ source_element, g_att, t_att, qweb_context,
+ context=None):
+ attrs = super(Duration, self).attributes(
+ cr, uid, field_name, record, options, source_element, g_att, t_att,
+ qweb_context, context=None)
+ return itertools.chain(attrs, [('data-oe-original', record[field_name])])
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ value = element.text_content().strip()
+
+ # non-localized value
+ return float(value)
+
+
+class RelativeDatetime(orm.AbstractModel):
+ _name = 'ir.qweb.field.relative'
+ _inherit = [
+ 'ir.qweb.field.relative',
+ 'ir.qweb.field.datetime',
+ ]
+
+ # get formatting from ir.qweb.field.relative but edition/save from datetime
+
+
+class QwebView(orm.AbstractModel):
+ _name = 'ir.qweb.field.qweb'
+ _inherit = ['ir.qweb.field.qweb']
+
+
+def html_to_text(element):
+ """ Converts HTML content with HTML-specified line breaks (br, p, div, ...)
+ in roughly equivalent textual content.
+
+ Used to replace and fixup the roundtripping of text and m2o: when using
+ libxml 2.8.0 (but not 2.9.1) and parsing HTML with lxml.html.fromstring
+ whitespace text nodes (text nodes composed *solely* of whitespace) are
+ stripped out with no recourse, and fundamentally relying on newlines
+ being in the text (e.g. inserted during user edition) is probably poor form
+ anyway.
+
+ -> this utility function collapses whitespace sequences and replaces
+ nodes by roughly corresponding linebreaks
+ * p are pre-and post-fixed by 2 newlines
+ * br are replaced by a single newline
+ * block-level elements not already mentioned are pre- and post-fixed by
+ a single newline
+
+ ought be somewhat similar (but much less high-tech) to aaronsw's html2text.
+ the latter produces full-blown markdown, our text -> html converter only
+ replaces newlines by <br> elements at this point so we're reverting that,
+ and a few more newline-ish elements in case the user tried to add
+ newlines/paragraphs into the text field
+
+ :param element: lxml.html content
+ :returns: corresponding pure-text output
+ """
+
+ # output is a list of str | int. Integers are padding requests (in minimum
+ # number of newlines). When multiple padding requests, fold them into the
+ # biggest one
+ output = []
+ _wrap(element, output)
+
+ # remove any leading or tailing whitespace, replace sequences of
+ # (whitespace)\n(whitespace) by a single newline, where (whitespace) is a
+ # non-newline whitespace in this case
+ return re.sub(
+ r'[ \t\r\f]*\n[ \t\r\f]*',
+ '\n',
+ ''.join(_realize_padding(output)).strip())
+
+_PADDED_BLOCK = set('p h1 h2 h3 h4 h5 h6'.split())
+# https://developer.mozilla.org/en-US/docs/HTML/Block-level_elements minus p
+_MISC_BLOCK = set((
+ 'address article aside audio blockquote canvas dd dl div figcaption figure'
+ ' footer form header hgroup hr ol output pre section tfoot ul video'
+).split())
+
+
+def _collapse_whitespace(text):
+ """ Collapses sequences of whitespace characters in ``text`` to a single
+ space
+ """
+ return re.sub('\s+', ' ', text)
+
+
+def _realize_padding(it):
+ """ Fold and convert padding requests: integers in the output sequence are
+ requests for at least n newlines of padding. Runs thereof can be collapsed
+ into the largest requests and converted to newlines.
+ """
+ padding = None
+ for item in it:
+ if isinstance(item, int):
+ padding = max(padding, item)
+ continue
+
+ if padding:
+ yield '\n' * padding
+ padding = None
+
+ yield item
+ # leftover padding irrelevant as the output will be stripped
+
+
+def _wrap(element, output, wrapper=u''):
+ """ Recursively extracts text from ``element`` (via _element_to_text), and
+ wraps it all in ``wrapper``. Extracted text is added to ``output``
+
+ :type wrapper: basestring | int
+ """
+ output.append(wrapper)
+ if element.text:
+ output.append(_collapse_whitespace(element.text))
+ for child in element:
+ _element_to_text(child, output)
+ output.append(wrapper)
+
+
+def _element_to_text(e, output):
+ if e.tag == 'br':
+ output.append(u'\n')
+ elif e.tag in _PADDED_BLOCK:
+ _wrap(e, output, 2)
+ elif e.tag in _MISC_BLOCK:
+ _wrap(e, output, 1)
+ else:
+ # inline
+ _wrap(e, output)
+
+ if e.tail:
+ output.append(_collapse_whitespace(e.tail))
diff --git a/addons/web_editor/models/ir_ui_view.py b/addons/web_editor/models/ir_ui_view.py
new file mode 100644
index 00000000000..5946e7128bb
--- /dev/null
+++ b/addons/web_editor/models/ir_ui_view.py
@@ -0,0 +1,123 @@
+# -*- coding: utf-8 -*-
+import copy
+import openerp
+from openerp.exceptions import AccessError
+from openerp.osv import osv
+from lxml import etree, html
+
+
+class view(osv.osv):
+ _inherit = 'ir.ui.view'
+
+ def render(self, cr, uid, id_or_xml_id, values=None, engine='ir.qweb', context=None):
+ if not values:
+ values = {}
+ if values.get('editable'):
+ try:
+ if not isinstance(id_or_xml_id, (int, long)):
+ if '.' not in id_or_xml_id:
+ raise ValueError('Invalid template id: %r' % (id_or_xml_id,))
+ id_or_xml_id = self.get_view_id(cr, uid, id_or_xml_id, context=context)
+
+ self.check_access_rule(cr, uid, [id_or_xml_id], 'write', context=context)
+
+ except AccessError:
+ values['editable'] = False
+
+ return super(view, self).render(cr, uid, id_or_xml_id, values=values, engine=engine, context=context)
+
+ #------------------------------------------------------
+ # Save from html
+ #------------------------------------------------------
+
+ def extract_embedded_fields(self, cr, uid, arch, context=None):
+ return arch.xpath('//*[@data-oe-model != "ir.ui.view"]')
+
+ def save_embedded_field(self, cr, uid, el, context=None):
+ Model = self.pool[el.get('data-oe-model')]
+ field = el.get('data-oe-field')
+
+ converter = self.pool['ir.qweb'].get_converter_for(el.get('data-oe-type'))
+ value = converter.from_html(cr, uid, Model, Model._fields[field], el)
+
+ if value is not None:
+ # TODO: batch writes?
+ Model.write(cr, uid, [int(el.get('data-oe-id'))], {
+ field: value
+ }, context=context)
+
+ def _pretty_arch(self, arch):
+ # remove_blank_string does not seem to work on HTMLParser, and
+ # pretty-printing with lxml more or less requires stripping
+ # whitespace: http://lxml.de/FAQ.html#why-doesn-t-the-pretty-print-option-reformat-my-xml-output
+ # so serialize to XML, parse as XML (remove whitespace) then serialize
+ # as XML (pretty print)
+ arch_no_whitespace = etree.fromstring(
+ etree.tostring(arch, encoding='utf-8'),
+ parser=etree.XMLParser(encoding='utf-8', remove_blank_text=True))
+ return etree.tostring(
+ arch_no_whitespace, encoding='unicode', pretty_print=True)
+
+ def replace_arch_section(self, cr, uid, view_id, section_xpath, replacement, context=None):
+ # the root of the arch section shouldn't actually be replaced as it's
+ # not really editable itself, only the content truly is editable.
+
+ [view] = self.browse(cr, uid, [view_id], context=context)
+ arch = etree.fromstring(view.arch.encode('utf-8'))
+ # => get the replacement root
+ if not section_xpath:
+ root = arch
+ else:
+ # ensure there's only one match
+ [root] = arch.xpath(section_xpath)
+
+ root.text = replacement.text
+ root.tail = replacement.tail
+ # replace all children
+ del root[:]
+ for child in replacement:
+ root.append(copy.deepcopy(child))
+
+ return arch
+
+ def to_field_ref(self, cr, uid, el, context=None):
+ # filter out meta-information inserted in the document
+ attributes = dict((k, v) for k, v in el.items()
+ if not k.startswith('data-oe-'))
+ attributes['t-field'] = el.get('data-oe-expression')
+
+ out = html.html_parser.makeelement(el.tag, attrib=attributes)
+ out.tail = el.tail
+ return out
+
+ def save(self, cr, uid, res_id, value, xpath=None, context=None):
+ """ Update a view section. The view section may embed fields to write
+
+ :param str model:
+ :param int res_id:
+ :param str xpath: valid xpath to the tag to replace
+ """
+ res_id = int(res_id)
+
+ arch_section = html.fromstring(
+ value, parser=html.HTMLParser(encoding='utf-8'))
+
+ if xpath is None:
+ # value is an embedded field on its own, not a view section
+ self.save_embedded_field(cr, uid, arch_section, context=context)
+ return
+
+ for el in self.extract_embedded_fields(cr, uid, arch_section, context=context):
+ self.save_embedded_field(cr, uid, el, context=context)
+
+ # transform embedded field back to t-field
+ el.getparent().replace(el, self.to_field_ref(cr, uid, el, context=context))
+
+ arch = self.replace_arch_section(cr, uid, res_id, xpath, arch_section, context=context)
+ self.write(cr, uid, res_id, {
+ 'arch': self._pretty_arch(arch)
+ }, context=context)
+
+ view = self.browse(cr, openerp.SUPERUSER_ID, res_id, context=context)
+ if view.model_data_id:
+ view.model_data_id.write({'noupdate': True})
diff --git a/addons/web_editor/models/test_models.py b/addons/web_editor/models/test_models.py
new file mode 100644
index 00000000000..100b49b6448
--- /dev/null
+++ b/addons/web_editor/models/test_models.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+from openerp.osv import orm, fields
+
+
+class test_converter(orm.Model):
+ _name = 'web_editor.converter.test'
+
+ # disable translation export for those brilliant field labels and values
+ _translate = False
+
+ _columns = {
+ 'char': fields.char(),
+ 'integer': fields.integer(),
+ 'float': fields.float(),
+ 'numeric': fields.float(digits=(16, 2)),
+ 'many2one': fields.many2one('web_editor.converter.test.sub'),
+ 'binary': fields.binary(),
+ 'date': fields.date(),
+ 'datetime': fields.datetime(),
+ 'selection': fields.selection([
+ (1, "réponse A"),
+ (2, "réponse B"),
+ (3, "réponse C"),
+ (4, "réponse D"),
+ ]),
+ 'selection_str': fields.selection([
+ ('A', "Qu'il n'est pas arrivé à Toronto"),
+ ('B', "Qu'il était supposé arriver à Toronto"),
+ ('C', "Qu'est-ce qu'il fout ce maudit pancake, tabernacle ?"),
+ ('D', "La réponse D"),
+ ], string=u"Lorsqu'un pancake prend l'avion à destination de Toronto et "
+ u"qu'il fait une escale technique à St Claude, on dit:"),
+ 'html': fields.html(),
+ 'text': fields.text(),
+ }
+
+
+class test_converter_sub(orm.Model):
+ _name = 'web_editor.converter.test.sub'
+
+ _columns = {
+ 'name': fields.char(),
+ }
diff --git a/addons/web_editor/security/ir.model.access.csv b/addons/web_editor/security/ir.model.access.csv
new file mode 100644
index 00000000000..a02dd5f1957
--- /dev/null
+++ b/addons/web_editor/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_web_editor_converter_test,access_web_editor_converter_test,model_web_editor_converter_test,,1,1,1,1
+access_web_editor_converter_test_sub,access_web_editor_converter_test_sub,model_web_editor_converter_test_sub,,1,1,1,1
diff --git a/addons/web_editor/static/src/css/backend.css b/addons/web_editor/static/src/css/backend.css
new file mode 100644
index 00000000000..decaed2d593
--- /dev/null
+++ b/addons/web_editor/static/src/css/backend.css
@@ -0,0 +1,73 @@
+.oe_form_field_html_text .note-editable {
+ min-height: 180px;
+ font: initial !important;
+ line-height: initial !important;
+ color: initial !important;
+}
+.oe_form_field_html_text .note-editable p {
+ margin: initial;
+}
+.oe_form_field_html_text .note-editable a, .oe_form_field_html_text .note-editable a:hover {
+ color: initial;
+}
+
+.oe_form_field_html iframe {
+ display: block;
+ width: 100%;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ min-height: 300px;
+ min-height: -webkit-calc(100vh - 170px);
+ min-height: calc(100vh - 170px);
+}
+.o_form_FieldTextHtml_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;
+}
+
+.o_form_FieldTextHtml_fullscreen * {
+ display: none;
+}
+.o_form_FieldTextHtml_fullscreen .o_form_fullscreen_ancestor {
+ display: block !important;
+ position: static !important;
+ top: 0 !important;
+ left: 0 !important;
+ overflow: visible !important;
+ width: auto !important;
+ overflow: hidden !important;
+}
+
+
+.openerp .oe_form .oe_form_field_html_translate textarea {
+ min-width: 60px;
+ width: 100%;
+ color: #4c4c4c;
+ font: normal 13px "Lucida Grande", Helvetica, Verdana, Arial, sans-serif;
+}
+.openerp .oe_form_readonly .oe_form .oe_form_field_html_translate textarea {
+ border:0;
+ background: #fff;
+}
+.openerp .oe_form_editable .oe_form .oe_form_field_html_translate > button {
+ float: right;
+ margin: -6px -3px 0 0;
+}
+.openerp .oe_form_editable .oe_form .oe_form_field_html_translate > .note-editable {
+ border: 1px solid #ccc;
+ min-height: 22px;
+ border-radius: 3px;
+}
+.openerp .oe_form_editable .oe_form .oe_form_field_html_translate .note-editable [contentEditable='true']:hover {
+ -moz-box-shadow: #DDCC33 0 0 5px 2px inset;
+ -webkit-box-shadow: #DDCC33 0 0 5px 2px inset;
+ box-shadow: #DDCC33 0 0 5px 2px inset;
+} \ No newline at end of file
diff --git a/addons/web_editor/static/src/css/common.sass b/addons/web_editor/static/src/css/common.sass
new file mode 100644
index 00000000000..264f0166c49
--- /dev/null
+++ b/addons/web_editor/static/src/css/common.sass
@@ -0,0 +1,4 @@
+@import "compass/css3"
+@import "compass/css3/user-interface"
+@import "compass/css3/transition"
+@import "compass/support"
diff --git a/addons/web_editor/static/src/css/inline.css b/addons/web_editor/static/src/css/inline.css
new file mode 100644
index 00000000000..61e995fa3fa
--- /dev/null
+++ b/addons/web_editor/static/src/css/inline.css
@@ -0,0 +1,64 @@
+body {
+ background-color: #fff;
+}
+#editable_area p {
+ margin: initial;
+}
+#editable_area a {
+ color: inherit;
+}
+#editable_area a:hover {
+ color: inherit;
+}
+#editable_area {
+ margin-top: 0 !important;
+ font: inherit !important;
+ line-height: inherit !important;
+ color: inherit !important;
+}
+#oe_snippets ~ #wrapwrap #editable_area {
+ margin-bottom: 100px;
+}
+
+#wrapwrap > main > div:first-child {
+ max-width: 600px;
+ margin-left: auto;
+ margin-right: auto;
+ position: relative;
+}
+
+.editor_enable #wrapwrap > main > div:first-child {
+ border-top: 1px dashed #888;
+ margin-top: 50px;
+}
+
+.editor_enable #wrapwrap > main > div:first-child:before, #wrapwrap > main > div:first-child:after {
+ content: " ";
+ display: block;
+ height: 100%;
+ vertical-align: top;
+ position: fixed;
+ top: -4px;
+ overflow: hidden;
+ z-index: -1;
+ margin-top: 54px;
+}
+.editor_enable #wrapwrap > main > div:first-child[data-oe-placeholder]:empty:before {
+ content: " \00a0 " attr(data-oe-placeholder);
+}
+
+.editor_enable #wrapwrap > main > div:first-child:before {
+ border-left: 1px dashed #000;
+ opacity: 0.4;
+ margin-left: -5px;
+}
+.editor_enable #wrapwrap > main > div:first-child:after {
+ border-left: 1px dashed #000;
+ opacity: 0.4;
+ margin-left: 605px;
+ width: 0;
+}
+
+.note-color-palette .colorpicker {
+ display: none;
+}
diff --git a/addons/web_editor/static/src/css/snippets.css b/addons/web_editor/static/src/css/snippets.css
new file mode 100644
index 00000000000..ca2ba0c6deb
--- /dev/null
+++ b/addons/web_editor/static/src/css/snippets.css
@@ -0,0 +1,490 @@
+/* ---- SNIPPET EDITOR ---- {{{ */
+#oe_snippets #o_left_bar, #oe_snippets #o_arrow {
+ position: fixed;
+ top: 34px;
+ left: 0px;
+ bottom: 0;
+ background: #282828;
+ -moz-box-shadow: 0px 10px 10px -10px black inset;
+ -webkit-box-shadow: 0px 10px 10px -10px black inset;
+ box-shadow: 0px 10px 10px -10px black inset;
+}
+#oe_snippets #o_arrow {
+ z-index: 1009;
+ width: 24px;
+ opacity: 0.75;
+}
+#oe_snippets #o_arrow > * {
+ position: absolute;
+ color: white;
+}
+#oe_snippets #o_arrow div {
+ -moz-transform: translateX(-36%) rotate(-90deg);
+ -ms-transform: translateX(-36%) rotate(-90deg);
+ -webkit-transform: translateX(-36%) rotate(-90deg);
+ transform: translateX(-36%) rotate(-90deg);
+ top: 50%;
+ white-space: nowrap;
+}
+#oe_snippets #o_arrow .fa {
+ width: 100%;
+ left: 40%;
+}
+#oe_snippets #o_left_bar {
+ left: -228px;
+ z-index: 1010;
+ width: 208px;
+ -moz-transition: all 500ms ease;
+ -o-transition: all 500ms ease;
+ -webkit-transition: all 500ms ease;
+ transition: all 500ms ease;
+}
+#oe_snippets #o_left_bar ul {
+ text-align: center;
+ margin: 0;
+ padding: 6px 0;
+ list-style-type: none;
+}
+#oe_snippets #o_left_bar ul li {
+ display: inline-block;
+ padding: 0 3px;
+}
+#oe_snippets #o_left_bar ul li a {
+ color: #aaa;
+}
+#oe_snippets #o_left_bar ul li .fa-stack {
+ overflow: hidden;
+}
+#oe_snippets #o_left_bar ul li.active a.fa, #oe_snippets #o_left_bar ul li.active .fa-stack-1x {
+ color: #428bca;
+}
+#oe_snippets.o_open #o_left_bar {
+ left: 0;
+}
+#oe_snippets .o_panel {
+ margin: 0;
+ border: 0;
+ width: 208px;
+}
+#oe_snippets .o_panel_header {
+ background: #dff0d8;
+ padding: 1px 8px;
+ color: #414141;
+ text-align: center;
+}
+#oe_snippets .o_panel_header .fa-stack {
+ overflow: hidden;
+ font-size: 1em;
+}
+#oe_snippets .o_panel_body {
+ white-space: normal;
+ background: black;
+ padding: 7px 3px;
+}
+#oe_snippets .o_panel_body > div {
+ background: black;
+}
+#oe_snippets .o_panel_body > div label {
+ width: 44px;
+ color: #fff;
+ padding-left: 10px;
+}
+#oe_snippets .o_panel_body > div label div {
+ width: 100px;
+ text-align: center;
+ -moz-transform: translate(-39px, 44px);
+ -webkit-transform: translate(-39px, 44px);
+ transform: translate(-39px, 44px);
+ -moz-transform-origin: 50% 50%;
+ -ms-transform-origin: 50% 50%;
+ -webkit-transform-origin: 50% 50%;
+ transform-origin: 50% 50%;
+}
+
+.oe_snippet {
+ display: inline-block;
+ vertical-align: top;
+ width: 93px;
+ margin-left: 3px;
+ margin-right: 3px;
+ margin-top: 1px;
+ position: relative;
+ overflow: hidden;
+ -moz-user-select: -moz-none;
+ -ms-user-select: none;
+ -webkit-user-select: none;
+ user-select: none;
+ cursor: move;
+}
+.oe_snippet .oe_snippet_thumbnail {
+ text-align: center;
+ height: 100%;
+ background: transparent;
+ color: white;
+ position: relative;
+}
+.oe_snippet .oe_snippet_thumbnail:hover .oe_snippet_thumbnail_img {
+ -moz-transform: scale(0.95, 0.95);
+ -ms-transform: scale(0.95, 0.95);
+ -webkit-transform: scale(0.95, 0.95);
+ transform: scale(0.95, 0.95);
+}
+.oe_snippet .oe_snippet_thumbnail .oe_snippet_thumbnail_title {
+ font-size: 12px;
+ display: block;
+ text-shadow: 0 0 2px black;
+}
+.oe_snippet .oe_snippet_thumbnail .oe_snippet_thumbnail_img {
+ height: 74px;
+ width: 92px;
+ background-size: cover;
+ -moz-transform: scale(1, 1);
+ -ms-transform: scale(1, 1);
+ -webkit-transform: scale(1, 1);
+ transform: scale(1, 1);
+}
+.oe_snippet .oe_snippet_thumbnail span, .oe_snippet .oe_snippet_thumbnail div {
+ line-height: 18px;
+}
+.oe_snippet > :not(.oe_snippet_thumbnail) {
+ display: none !important;
+}
+.oe_snippet.disable .oe_snippet_thumbnail {
+ background-color: rgba(255, 0, 0, 0.75);
+}
+.oe_snippet.disable .oe_snippet_thumbnail_img {
+ opacity: 0.8;
+}
+
+#oe_snippets .oe_snippet_thumbnail {
+ background: #747474, url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PHJhZGlhbEdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgY3g9IjUwJSIgY3k9IjUwJSIgcj0iMTAwJSI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzAwMDAwMCIgc3RvcC1vcGFjaXR5PSIwLjI1Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMDAwMDAwIiBzdG9wLW9wYWNpdHk9IjAuNCIvPjwvcmFkaWFsR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA==');
+ background: #747474, -moz-radial-gradient(rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.4));
+ background: #747474, -webkit-radial-gradient(rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.4));
+ background: #747474, radial-gradient(rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.4));
+}
+
+/* ---- SNIPPETS DROP ZONES ---- {{{ */
+.oe_drop_zone.oe_insert {
+ display: block;
+ height: 48px;
+ margin: 0px;
+ margin-top: -4px;
+ margin-bottom: -44px;
+ -moz-transition: margin 250ms linear;
+ -o-transition: margin 250ms linear;
+ -webkit-transition: margin 250ms linear;
+ transition: margin 250ms linear;
+ width: 100%;
+ position: absolute;
+ z-index: 1000;
+}
+.oe_drop_zone.oe_insert:not(.oe_vertical):before {
+ content: "";
+ display: block;
+ border-top: dashed 2px rgba(209, 178, 255, 0.72);
+ position: relative;
+ top: 0px;
+}
+.oe_drop_zone.oe_insert.oe_hover:before {
+ border-top: dashed 2px rgba(116, 255, 161, 0.72);
+}
+.oe_drop_zone.oe_insert.oe_vertical {
+ width: 48px;
+ float: left;
+ position: relative;
+ margin: 0px -24px !important;
+}
+.oe_drop_zone.oe_insert.oe_vertical_text {
+ width: 48px;
+ float: none;
+ display: inline-block;
+ position: relative;
+ margin: 0px -24px !important;
+}
+.oe_drop_zone.oe_insert.oe_overlay {
+ -moz-border-radius: 3px;
+ -webkit-border-radius: 3px;
+ border-radius: 3px;
+ background: rgba(153, 0, 255, 0.5);
+}
+
+.oe_drop_zone, .oe_drop_zone_style {
+ border: none;
+ background: rgba(153, 0, 255, 0.3);
+ -moz-border-radius: 4px;
+ -webkit-border-radius: 4px;
+ border-radius: 4px;
+}
+.oe_drop_zone.oe_hover, .oe_drop_zone_style.oe_hover {
+ background: rgba(0, 255, 133, 0.3);
+ z-index: 1001;
+}
+
+.oe_drop_zone_style {
+ color: white;
+ height: 48px;
+ margin-bottom: 32px;
+ padding-top: 12px;
+}
+
+/* ---- SNIPPET MANIPULATOR ---- {{{ */
+.resize_editor_busy {
+ background-color: rgba(0, 0, 0, 0.3);
+}
+
+.oe_overlay {
+ display: none;
+ height: 0;
+ position: absolute;
+ background: transparent;
+ z-index: 1001;
+ -moz-border-radius: 3px;
+ -webkit-border-radius: 3px;
+ border-radius: 3px;
+ -moz-transition: opacity 100ms linear;
+ -o-transition: opacity 100ms linear;
+ -webkit-transition: opacity 100ms linear;
+ transition: opacity 100ms linear;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+}
+.oe_overlay.oe_active {
+ display: block;
+}
+.oe_overlay .oe_handle {
+ display: block !important;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+ width: 16px;
+ height: 16px;
+ margin: -2px;
+}
+.oe_overlay .oe_handle > div {
+ z-index: 1;
+ position: absolute;
+ border-style: dashed;
+ border-width: 1px;
+ border-color: #666666;
+ -moz-box-shadow: 0px 0px 0px 1px rgba(255, 255, 255, 0.5), 0px 0px 0px 1px rgba(255, 255, 255, 0.5) inset;
+ -webkit-box-shadow: 0px 0px 0px 1px rgba(255, 255, 255, 0.5), 0px 0px 0px 1px rgba(255, 255, 255, 0.5) inset;
+ box-shadow: 0px 0px 0px 1px rgba(255, 255, 255, 0.5), 0px 0px 0px 1px rgba(255, 255, 255, 0.5) inset;
+}
+.oe_overlay .oe_handle.e:before, .oe_overlay .oe_handle.w:before, .oe_overlay .oe_handle.s:before, .oe_overlay .oe_handle.n:before, .oe_overlay .oe_handle.size .oe_handle_button {
+ z-index: 2;
+ position: relative;
+ top: 50%;
+ left: 50%;
+ display: block;
+ background: white;
+ border: solid 1px rgba(0, 0, 0, 0.2);
+ -moz-border-radius: 5px;
+ -webkit-border-radius: 5px;
+ border-radius: 5px;
+ width: 18px;
+ height: 18px;
+ margin: -9px;
+ padding-left: 1px;
+ font-size: 14px;
+ line-height: 14px;
+ font-family: FontAwesome;
+ color: rgba(0, 0, 0, 0.5);
+ -moz-transition: background 100ms linear;
+ -o-transition: background 100ms linear;
+ -webkit-transition: background 100ms linear;
+ transition: background 100ms linear;
+}
+.oe_overlay .oe_handle.e:hover:before, .oe_overlay .oe_handle.w:hover:before, .oe_overlay .oe_handle.s:hover:before, .oe_overlay .oe_handle.n:hover:before {
+ background: rgba(0, 0, 0, 0.5);
+ color: #fff;
+ -moz-box-shadow: 0 0 5px 3px rgba(255, 255, 255, 0.7);
+ -webkit-box-shadow: 0 0 5px 3px rgba(255, 255, 255, 0.7);
+ box-shadow: 0 0 5px 3px rgba(255, 255, 255, 0.7);
+}
+.oe_overlay .oe_handle.e, .oe_overlay .oe_handle.w {
+ top: 4px;
+ height: 100%;
+}
+.oe_overlay .oe_handle.e:before, .oe_overlay .oe_handle.w:before {
+ content: "";
+ line-height: 16px;
+}
+.oe_overlay .oe_handle.e > div, .oe_overlay .oe_handle.w > div {
+ width: 0;
+ height: 100%;
+ top: 2px;
+ left: 8px;
+}
+.oe_overlay .oe_handle.e {
+ left: auto;
+ right: -6px;
+ cursor: w-resize;
+}
+.oe_overlay .oe_handle.w {
+ left: -6px;
+ cursor: e-resize;
+}
+.oe_overlay .oe_handle.s, .oe_overlay .oe_handle.n {
+ left: 2px;
+ width: 100%;
+}
+.oe_overlay .oe_handle.s:before, .oe_overlay .oe_handle.n:before {
+ content: "";
+ text-align: center;
+ padding: 1px;
+}
+.oe_overlay .oe_handle.s > div, .oe_overlay .oe_handle.n > div {
+ width: 100%;
+ height: 0;
+ top: 7px;
+ left: 1px;
+}
+.oe_overlay .oe_handle.s {
+ top: auto;
+ cursor: n-resize;
+}
+.oe_overlay .oe_handle.n {
+ cursor: s-resize;
+}
+.oe_overlay .oe_handle.n > div {
+ top: 5px;
+}
+.oe_overlay .oe_handle.size {
+ z-index: 3;
+ top: auto;
+ left: 50%;
+ bottom: -6px;
+ margin-left: -50px;
+}
+.oe_overlay .oe_handle.size .oe_handle_button {
+ position: relative;
+ z-index: 3;
+ text-align: center;
+ margin-left: -52px;
+ margin-top: -10px;
+ left: 0px;
+}
+.oe_overlay .oe_handle.size .oe_handle_button:hover {
+ background: rgba(30, 30, 30, 0.8);
+ color: #fff;
+ -moz-box-shadow: 0 0 5px 3px rgba(255, 255, 255, 0.7);
+ -webkit-box-shadow: 0 0 5px 3px rgba(255, 255, 255, 0.7);
+ box-shadow: 0 0 5px 3px rgba(255, 255, 255, 0.7);
+}
+.oe_overlay .oe_handle.size .size {
+ position: absolute;
+ width: 64px;
+ cursor: row-resize;
+ top: 9px;
+ margin-left: 52px;
+ padding: 0 5px;
+}
+.oe_overlay .oe_handle.size .auto_size {
+ position: absolute;
+ width: 100px;
+ top: 9px;
+ cursor: pointer;
+}
+.oe_overlay .oe_handle.readonly {
+ cursor: auto !important;
+}
+.oe_overlay .oe_handle.readonly:before, .oe_overlay .oe_handle.readonly.size {
+ display: none !important;
+}
+.oe_overlay .icon.btn {
+ display: inline-block;
+}
+.oe_overlay .oe_overlay_options {
+ position: absolute;
+ left: 50% !important;
+ text-align: center;
+ top: -20px;
+ z-index: 1002;
+}
+.oe_overlay .oe_overlay_options > .btn-group {
+ left: -50%;
+ white-space: nowrap;
+}
+.oe_overlay .oe_overlay_options > .btn-group > a {
+ cursor: pointer;
+ display: inline-block;
+ float: none;
+ margin: 0 -3px;
+}
+.oe_overlay .oe_overlay_options > .btn-group > div {
+ float: left;
+}
+.oe_overlay .oe_overlay_options .btn, .oe_overlay .oe_overlay_options a {
+ cursor: pointer;
+}
+.oe_overlay .oe_overlay_options .dropdown {
+ display: inline-block;
+}
+.oe_overlay .oe_overlay_options .dropdown-menu {
+ text-align: left;
+ min-width: 180px;
+}
+.oe_overlay .oe_overlay_options .dropdown-menu select, .oe_overlay .oe_overlay_options .dropdown-menu input {
+ display: block;
+}
+.oe_overlay.block-w-left .w:before {
+ content: "" !important;
+}
+.oe_overlay.block-w-right .w:before {
+ content: "" !important;
+}
+.oe_overlay.block-w-left.block-w-right .w:before {
+ display: none !important;
+}
+.oe_overlay.block-e-left .e:before {
+ content: "" !important;
+}
+.oe_overlay.block-e-right .e:before {
+ content: "" !important;
+}
+.oe_overlay.block-e-left.block-e-right .e:before {
+ display: none !important;
+}
+.oe_overlay.block-s-top .s:before {
+ content: "" !important;
+}
+.oe_overlay.block-s-bottom .s:before {
+ content: "" !important;
+}
+.oe_overlay.block-n-top .n:before {
+ content: "" !important;
+}
+.oe_overlay.block-n-bottom .n:before {
+ content: "" !important;
+}
+.oe_overlay.o_keypress {
+ opacity: 0;
+ -webkit-transition: 0.8s;
+ -moz-transition: 0.8s;
+ -o-transition: 0.8s;
+ -transition: 0.8s;
+}
+
+.s-resize-important, .s-resize-important * {
+ cursor: s-resize !important;
+}
+
+.n-resize-important, .n-resize-important * {
+ cursor: n-resize !important;
+}
+
+.e-resize-important, .e-resize-important * {
+ cursor: e-resize !important;
+}
+
+.w-resize-important, .w-resize-important * {
+ cursor: w-resize !important;
+}
+
+.move-important, .move-important * {
+ cursor: move !important;
+} \ No newline at end of file
diff --git a/addons/web_editor/static/src/css/snippets.sass b/addons/web_editor/static/src/css/snippets.sass
new file mode 100644
index 00000000000..752dabafc78
--- /dev/null
+++ b/addons/web_editor/static/src/css/snippets.sass
@@ -0,0 +1,376 @@
+@import "compass/css3"
+@import "compass/css3/user-interface"
+@import "compass/css3/transition"
+
+
+/* ---- SNIPPET EDITOR ---- {{{ */
+
+#oe_snippets
+ #o_left_bar, #o_arrow
+ position: fixed
+ top: 34px
+ left: 0px
+ bottom: 0
+ background: rgb(40,40,40)
+ +box-shadow(0px 10px 10px -10px black inset)
+ #o_arrow
+ z-index: 1009
+ width: 24px
+ opacity: 0.75
+ > *
+ position: absolute
+ color: white
+ div
+ @include transform( translateX(-36%) rotate(-90deg) )
+ top: 50%
+ white-space: nowrap
+ .fa
+ width: 100%
+ left: 40%
+ #o_left_bar
+ left: -228px
+ z-index: 1010
+ width: 208px
+ @include transition(all 500ms ease)
+ ul
+ text-align: center
+ margin: 0
+ padding: 6px 0
+ list-style-type: none
+ li
+ display: inline-block
+ padding: 0 3px
+ a
+ color: #aaa
+ .fa-stack
+ overflow: hidden
+ li.active a.fa, li.active .fa-stack-1x
+ color: #428bca
+
+ &.o_open #o_left_bar
+ left: 0
+ .o_panel
+ margin: 0
+ border: 0
+ width: 208px
+ .o_panel_header
+ background: #dff0d8
+ padding: 1px 8px
+ color: #414141
+ text-align: center
+ .fa-stack
+ overflow: hidden
+ font-size: 1em
+ .o_panel_body
+ white-space: normal
+ background: black
+ padding: 7px 3px
+ > div
+ background: rgb(0,0,0)
+ label
+ width: 44px
+ color: #fff
+ padding-left: 10px
+
+ div
+ width: 100px
+ text-align: center
+ @include transform( translate(-39px, 44px) , rotate(-90deg) )
+ @include transform-origin(50%)
+
+.oe_snippet
+ display: inline-block
+ vertical-align: top
+ width: 93px
+ margin-left: 3px
+ margin-right: 3px
+ margin-top: 1px
+ position: relative
+ overflow: hidden
+ +user-select(none)
+ cursor: move
+ .oe_snippet_thumbnail
+ text-align: center
+ height: 100%
+ background: transparent
+ color: white
+ position: relative
+ &:hover
+ .oe_snippet_thumbnail_img
+ @include transform( scale(.95,.95))
+ .oe_snippet_thumbnail_title
+ font-size: 12px
+ display: block
+ +text-shadow(0 0 2px rgb(0,0,0))
+ .oe_snippet_thumbnail_img
+ height: 74px
+ width: 92px
+ background-size: cover
+ @include transform( scale(1,1))
+ span, div
+ line-height: 18px
+ & > :not(.oe_snippet_thumbnail)
+ display: none !important
+ &.disable
+ .oe_snippet_thumbnail
+ background-color: rgba(255, 0, 0, 0.75)
+ .oe_snippet_thumbnail_img
+ opacity: 0.8
+#oe_snippets .oe_snippet_thumbnail
+ @include background(#747474, radial-gradient(rgba(0,0,0,0.25),rgba(0,0,0,0.4)))
+
+// }}}}
+
+/* ---- SNIPPETS DROP ZONES ---- {{{ */
+
+.oe_drop_zone.oe_insert
+ display: block
+ height: 48px
+ margin: 0px
+ margin-top: -4px
+ margin-bottom: -44px
+ @include transition(margin 250ms linear)
+ width: 100%
+ position: absolute
+ z-index: 1000
+ &:not(.oe_vertical):before
+ content: ""
+ display: block
+ border-top: dashed 2px rgba(209, 178, 255, 0.72)
+ position: relative
+ top: 0px
+ &.oe_hover:before
+ border-top: dashed 2px rgba(116, 255, 161, 0.72)
+ &.oe_vertical
+ width: 48px
+ float: left
+ position: relative
+ margin: 0px -24px !important
+ &.oe_vertical_text
+ width: 48px
+ float: none
+ display: inline-block
+ position: relative
+ margin: 0px -24px !important
+ &.oe_overlay
+ +border-radius(3px)
+ //@include background-image( repeating-linear-gradient(45deg, rgba(255,255,255,.1) ,rgba(255,255,255,.1) 35px, rgba(0,0,0,.1) 35px, rgba(0,0,0,.1) 75px))
+ //background-size: 100px 100px
+ background: rgba(153, 0, 255,.5)
+
+.oe_drop_zone, .oe_drop_zone_style
+ border: none
+ //@include background-image( repeating-linear-gradient(45deg, rgba(255,255,255,.1) ,rgba(255,255,255,.1) 35px, rgba(0,0,0,.1) 35px, rgba(0,0,0,.1) 75px))
+ //background-size: 100px 100px
+ background: rgba(153, 0, 255, .3)
+ +border-radius(4px)
+ &.oe_hover
+ background: rgba(0, 255, 133, .3)
+ z-index: 1001
+
+.oe_drop_zone_style
+ color: white
+ height: 48px
+ margin-bottom: 32px
+ padding-top: 12px
+
+// }}}
+
+/* ---- SNIPPET MANIPULATOR ---- {{{ */
+
+.resize_editor_busy
+ background-color: rgba(0,0,0,0.3)
+
+.oe_overlay
+ display: none
+ height: 0
+ position: absolute
+ background: transparent
+ z-index: 1001
+ //@include background-image( repeating-linear-gradient(45deg, rgba(255,255,255,.02) ,rgba(255,255,255,.02) 35px, rgba(0,0,0,.02) 35px, rgba(0,0,0,.02) 75px))
+ +border-radius(3px)
+ @include transition(opacity 100ms linear)
+ +box-sizing(border-box)
+ &.oe_active
+ display: block
+ .oe_handle
+ display: block !important
+ position: absolute
+ top: 50%
+ left: 50%
+ +box-sizing(border-box)
+ width: 16px
+ height: 16px
+ margin: -2px
+ > div
+ z-index: 1
+ position: absolute
+ border-style: dashed
+ border-width: 1px
+ border-color: #666666
+ +box-shadow(0px 0px 0px 1px rgba(255,255,255,0.5), 0px 0px 0px 1px rgba(255,255,255,0.5) inset)
+ &.e:before, &.w:before, &.s:before, &.n:before, &.size .oe_handle_button
+ z-index: 2
+ position: relative
+ top: 50%
+ left: 50%
+ display: block
+ background: rgba(255, 255, 255, 1)
+ border: solid 1px rgba(0, 0, 0, .2)
+ +border-radius(5px)
+ width: 18px
+ height: 18px
+ margin: -9px
+ padding-left: 1px
+ font-size: 14px
+ line-height: 14px
+ font-family: FontAwesome
+ color: rgba(0,0,0,.5)
+ @include transition(background 100ms linear)
+ &.e, &.w, &.s, &.n
+ &:hover:before
+ background: rgba(0, 0, 0, .5)
+ color: #fff
+ +box-shadow(0 0 5px 3px rgba(255,255,255,.7))
+ &.e, &.w
+ top: 4px
+ height: 100%
+ &:before
+ content: "\f07e"
+ line-height: 16px
+ > div
+ width: 0
+ height: 100%
+ top: 2px
+ left: 8px
+ &.e
+ left: auto
+ right: -6px
+ cursor: w-resize
+ &.w
+ left: -6px
+ cursor: e-resize
+ &.s, &.n
+ left: 2px
+ width: 100%
+ &:before
+ content: "\f07d"
+ text-align: center
+ padding: 1px
+ > div
+ width: 100%
+ height: 0
+ top: 7px
+ left: 1px
+ &.s
+ top: auto
+ cursor: n-resize
+ &.n
+ cursor: s-resize
+ > div
+ top: 5px
+ &.size
+ z-index: 3
+ top: auto
+ left: 50%
+ bottom: -6px
+ margin-left: -50px
+ .oe_handle_button
+ position: relative
+ z-index: 3
+ text-align: center
+ margin-left: -52px
+ margin-top: -10px
+ left: 0px
+ &:hover
+ background: rgba(30, 30, 30, .8)
+ color: #fff
+ +box-shadow(0 0 5px 3px rgba(255,255,255,.7))
+ .size
+ position: absolute
+ width: 64px
+ cursor: row-resize
+ top: 9px
+ margin-left: 52px
+ padding: 0 5px
+ .auto_size
+ position: absolute
+ width: 100px
+ top: 9px
+ cursor: pointer
+ &.readonly
+ cursor: auto !important
+ &:before, &.size
+ display: none !important
+
+ .icon.btn
+ display: inline-block
+
+ .oe_overlay_options
+ position: absolute
+ left: 50% !important
+ text-align: center
+ top: -20px
+ z-index: 1002
+ > .btn-group
+ left: -50%
+ white-space: nowrap
+ > a
+ cursor: pointer
+ display: inline-block
+ float: none
+ margin: 0 -3px
+ > div
+ float: left
+ .btn, a
+ cursor: pointer
+ .dropdown
+ display: inline-block
+ .dropdown-menu
+ text-align: left
+ min-width: 180px
+ .dropdown-menu select,.dropdown-menu input
+ display: block
+
+ &.block-w-left .w:before
+ content: "\F061" !important
+ &.block-w-right .w:before
+ content: "\F060" !important
+ &.block-w-left.block-w-right .w:before
+ display: none !important
+ &.block-e-left .e:before
+ content: "\F061" !important
+ &.block-e-right .e:before
+ content: "\F060" !important
+ &.block-e-left.block-e-right .e:before
+ display: none !important
+
+ &.block-s-top .s:before
+ content: "\F063" !important
+ &.block-s-bottom .s:before
+ content: "\f062" !important
+ &.block-n-top .n:before
+ content: "\F063" !important
+ &.block-n-bottom .n:before
+ content: "\f062" !important
+
+ &.o_keypress
+ opacity: 0
+ -webkit-transition: .8s
+ -moz-transition: .8s
+ -o-transition: .8s
+ -transition: .8s
+
+
+.s-resize-important, .s-resize-important *
+ cursor: s-resize !important
+.n-resize-important, .n-resize-important *
+ cursor: n-resize !important
+.e-resize-important, .e-resize-important *
+ cursor: e-resize !important
+.w-resize-important, .w-resize-important *
+ cursor: w-resize !important
+.move-important, .move-important *
+ cursor: move !important
+
+// }}}
diff --git a/addons/web_editor/static/src/css/web_editor.css b/addons/web_editor/static/src/css/web_editor.css
new file mode 100644
index 00000000000..e61e5244e40
--- /dev/null
+++ b/addons/web_editor/static/src/css/web_editor.css
@@ -0,0 +1,266 @@
+@charset "utf-8";
+/* ----- EDITOR ----- */
+.css_non_editable_mode_hidden {
+ display: none !important;
+}
+
+/* ----- Editor (for public user, like forum) ----- */
+.note-toolbar {
+ margin-left: 0 !important;
+}
+
+.note-popover .popover > .arrow {
+ display: none;
+}
+
+/* ----- GENERIC LAYOUTING HELPERS ---- */
+
+.hidden {
+ display: none !important;
+}
+
+/* table */
+table.table {
+ table-layout: fixed;
+}
+
+td {
+ min-width: 20px;
+}
+
+/* Vertical Spacing */
+.mt128 {
+ margin-top: 128px !important;
+}
+
+.mt92 {
+ margin-top: 92px !important;
+}
+
+.mt64 {
+ margin-top: 64px !important;
+}
+
+.mt48 {
+ margin-top: 48px !important;
+}
+
+.mt32 {
+ margin-top: 32px !important;
+}
+
+.mt16 {
+ margin-top: 16px !important;
+}
+
+.mt8 {
+ margin-top: 8px !important;
+}
+
+.mt4 {
+ margin-top: 4px !important;
+}
+
+.mt0 {
+ margin-top: 0px !important;
+}
+
+.mb128 {
+ margin-bottom: 128px !important;
+}
+
+.mb92 {
+ margin-bottom: 92px !important;
+}
+
+.mb64 {
+ margin-bottom: 64px !important;
+}
+
+.mb48 {
+ margin-bottom: 48px !important;
+}
+
+.mb32 {
+ margin-bottom: 32px !important;
+}
+
+.mb16 {
+ margin-bottom: 16px !important;
+}
+
+.mb8 {
+ margin-bottom: 8px !important;
+}
+
+.mb4 {
+ margin-bottom: 4px !important;
+}
+
+.mb0 {
+ margin-bottom: 0px !important;
+}
+
+.ml8 {
+ margin-left: 8px !important;
+}
+
+.ml4 {
+ margin-left: 4px !important;
+}
+
+.ml0 {
+ margin-left: 0px !important;
+}
+
+.mr8 {
+ margin-right: 8px !important;
+}
+
+.mr4 {
+ margin-right: 4px !important;
+}
+
+.mr0 {
+ margin-right: 0px !important;
+}
+
+/* Media */
+img.shadow {
+ -moz-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2);
+ -webkit-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2);
+ 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;
+}
+
+div.media_iframe_video.padding-small iframe {
+ padding: 4px;
+}
+div.media_iframe_video.padding-medium iframe {
+ padding: 8px;
+}
+div.media_iframe_video.padding-large iframe {
+ padding: 16px;
+}
+div.media_iframe_video.padding-xl iframe {
+ padding: 32px;
+}
+
+/* font awsome */
+.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.center-block {
+ text-align: center;
+}
+
+/* video */
+div.media_iframe_video {
+ margin: 0 auto;
+ text-align: center;
+ position: relative;
+ overflow: hidden;
+}
+div.media_iframe_video iframe {
+ width: 100%;
+ height: 100%;
+ top: 0;
+ position: absolute;
+ margin: 0 auto;
+ margin-left: -50%;
+}
+div.media_iframe_video .media_iframe_video_size {
+ padding-bottom: 66.5%;
+ position: relative;
+ width: 100%;
+ height: 0;
+}
+div.media_iframe_video .css_editable_mode_display {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ display: none;
+ z-index: 2;
+}
+
+html[data-browser^="msie"] div.media_iframe_video iframe {
+ margin-left: 0;
+}
+
+/* Mobile view */
+@media (max-width: 768px) {
+ img:not(.cke_iframe), .media_iframe_video, span.fa, i.fa {
+ -webkit-transform: none !important;
+ -moz-transform: none !important;
+ -ms-transform: none !important;
+ -o-transform: none !important;
+ transform: none !important;
+ }
+}
+/* ---- fields --- */
+address .fa.fa-mobile-phone {
+ margin: 0 3px 0 2px;
+}
+address .fa.fa-file-text-o {
+ margin-right: 1px;
+}
+
+span[data-oe-type="monetary"] {
+ white-space: nowrap;
+}
+
+/* ---- MENU ---- {{{ */
+div.oe_menu_buttons {
+ top: -8px;
+ right: -8px;
+}
+
+ul.oe_menu_editor .fa-home {
+ display: none;
+}
+ul.oe_menu_editor > li:first-child > div > span > .fa-home {
+ display: block;
+}
+ul.oe_menu_editor .oe_menu_placeholder {
+ outline: 1px dashed #4183C4;
+}
+ul.oe_menu_editor ul {
+ list-style: none;
+}
+ul.oe_menu_editor li div {
+ cursor: move;
+}
+ul.oe_menu_editor .disclose {
+ cursor: pointer;
+ width: 10px;
+ display: none;
+}
diff --git a/addons/web_editor/static/src/css/web_editor.sass b/addons/web_editor/static/src/css/web_editor.sass
new file mode 100644
index 00000000000..75b4ad6c781
--- /dev/null
+++ b/addons/web_editor/static/src/css/web_editor.sass
@@ -0,0 +1,195 @@
+@charset "utf-8"
+@import "common"
+
+/* ----- EDITOR ----- */
+
+.css_non_editable_mode_hidden
+ display: none !important
+
+/* ----- Editor (for public user, like forum) ----- */
+
+.note-toolbar
+ margin-left: 0 !important
+
+.note-popover .popover > .arrow
+ display: none
+
+/* ----- GENERIC LAYOUTING HELPERS ---- */
+
+.hidden
+ display: none !important
+
+/* table */
+
+table.table
+ table-layout: fixed
+
+td
+ min-width: 20px
+
+/* Vertical Spacing */
+
+.mt128
+ margin-top: 128px !important
+.mt92
+ margin-top: 92px !important
+.mt64
+ margin-top: 64px !important
+.mt48
+ margin-top: 48px !important
+.mt32
+ margin-top: 32px !important
+.mt16
+ margin-top: 16px !important
+.mt8
+ margin-top: 8px !important
+.mt4
+ margin-top: 4px !important
+.mt0
+ margin-top: 0px !important
+
+.mb128
+ margin-bottom: 128px !important
+.mb92
+ margin-bottom: 92px !important
+.mb64
+ margin-bottom: 64px !important
+.mb48
+ margin-bottom: 48px !important
+.mb32
+ margin-bottom: 32px !important
+.mb16
+ margin-bottom: 16px !important
+.mb8
+ margin-bottom: 8px !important
+.mb4
+ margin-bottom: 4px !important
+.mb0
+ margin-bottom: 0px !important
+
+.ml8
+ margin-left: 8px !important
+.ml4
+ margin-left: 4px !important
+.ml0
+ margin-left: 0px !important
+
+.mr8
+ margin-right: 8px !important
+.mr4
+ margin-right: 4px !important
+.mr0
+ margin-right: 0px !important
+
+/* Media */
+
+img.shadow
+ +box-shadow(0px 3px 8px rgba(0, 0, 0, 0.2))
+
+img, .img, span.fa, iframe
+ &.padding-small
+ padding: 4px
+ &.padding-medium
+ padding: 8px
+ &.padding-large
+ padding: 16px
+ &.padding-xl
+ padding: 32px
+div.media_iframe_video
+ &.padding-small iframe
+ padding: 4px
+ &.padding-medium iframe
+ padding: 8px
+ &.padding-large iframe
+ padding: 16px
+ &.padding-xl iframe
+ padding: 32px
+
+/* font awsome */
+
+.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.center-block
+ text-align: center
+
+/* video */
+
+div.media_iframe_video
+ margin: 0 auto
+ text-align: center
+ position: relative
+ overflow: hidden
+ iframe
+ width: 100%
+ height: 100%
+ top: 0
+ position: absolute
+ margin: 0 auto
+ margin-left: -50%
+ .media_iframe_video_size
+ padding-bottom: 66.5%
+ position: relative
+ width: 100%
+ height: 0
+ .css_editable_mode_display
+ position: absolute
+ width: 100%
+ height: 100%
+ display: none
+ z-index: 2
+
+html[data-browser^="msie"] div.media_iframe_video iframe
+ margin-left: 0
+
+/* Mobile view */
+
+@media (max-width: 768px)
+ img:not(.cke_iframe), .media_iframe_video, span.fa, i.fa
+ -webkit-transform: none !important
+ -moz-transform: none !important
+ -ms-transform: none !important
+ -o-transform: none !important
+ transform: none !important
+
+/* ---- 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
+
+/* ---- MENU ---- {{{ */
+
+div.oe_menu_buttons
+ top: -8px
+ right: -8px
+
+ul.oe_menu_editor
+ .fa-home
+ display: none
+ > li:first-child > div > span > .fa-home
+ display: block
+ .oe_menu_placeholder
+ outline: 1px dashed #4183C4
+ ul
+ list-style: none
+ li div
+ cursor: move
+ .disclose
+ cursor: pointer
+ width: 10px
+ display: none
+
+// }}}
diff --git a/addons/web_editor/static/src/img/drag_here.png b/addons/web_editor/static/src/img/drag_here.png
new file mode 100644
index 00000000000..335c3a571bb
--- /dev/null
+++ b/addons/web_editor/static/src/img/drag_here.png
Binary files differ
diff --git a/addons/web_editor/static/src/img/edit_here.png b/addons/web_editor/static/src/img/edit_here.png
new file mode 100644
index 00000000000..77beff11c7b
--- /dev/null
+++ b/addons/web_editor/static/src/img/edit_here.png
Binary files differ
diff --git a/addons/web_editor/static/src/js/backend.js b/addons/web_editor/static/src/js/backend.js
new file mode 100644
index 00000000000..5725cfc39f6
--- /dev/null
+++ b/addons/web_editor/static/src/js/backend.js
@@ -0,0 +1,322 @@
+odoo.define('web_editor.backend', function (require) {
+'use strict';
+
+var core = require('web.core');
+var session = require('web.session');
+var Model = require('web.DataModel');
+var common = require('web.form_common');
+var base = require('web_editor.base');
+var editor = require('web_editor.editor');
+var summernote = require('web_editor.summernote');
+var transcoder = require('web_editor.transcoder');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+/**
+ * FieldTextHtml Widget
+ * Intended for FieldText widgets meant to display HTML content. This
+ * widget will instantiate an iframe with the editor summernote improved by odoo
+ */
+
+var widget = common.AbstractField.extend(common.ReinitializeFieldMixin);
+
+var FieldTextHtmlSimple = widget.extend({
+ template: 'web_editor.FieldTextHtmlSimple',
+ _config: function () {
+ var self = this;
+ return {
+ 'focus': false,
+ 'height': 180,
+ '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",
+ 'onChange': function (value) {
+ self.internal_set_value(value);
+ self.trigger('changed_value');
+ }
+ };
+ },
+ initialize_content: function() {
+ var self = this;
+ this.$textarea = this.$("textarea").val(this.get('value') || "<p><br/></p>");
+
+ if (this.get("effective_readonly")) {
+ this.$textarea.hide().after('<div class="note-editable"/>');
+ } else {
+ this.$textarea.summernote(this._config());
+
+ if (this.field.translate && this.view) {
+ $(QWeb.render('web_editor.FieldTextHtml.button.translate', this))
+ .appendTo(this.$('.note-toolbar'))
+ .on('click', this.on_translate);
+ }
+
+ var reset = _.bind(this.reset_history, this);
+ this.view.on('load_record', this, reset);
+ setTimeout(reset, 0);
+ }
+ this.$content = this.$('.note-editable:first');
+
+ $(".oe-view-manager-content").on("scroll", function () {
+ $('.o_table_handler').remove();
+ });
+ this._super();
+ },
+ reset_history: function () {
+ var history = this.$content.data('NoteHistory');
+ if (history) {
+ history.reset();
+ self.$('.note-toolbar').find('button[data-event="undo"]').attr('disabled', true);
+ }
+ },
+ text_to_html: function (text) {
+ var value = text || "";
+ if (value.match(/^\s*$/)) {
+ value = '<p><br/></p>';
+ } else {
+ value = "<p>"+value.split(/<br\/?>/).join("</p><p>")+"</p>";
+ value = value.replace('<p><p>', '<p>').replace('</p></p>', '</p>');
+ }
+ return value;
+ },
+ render_value: function() {
+ var value = this.get('value');
+ this.$textarea.val(value || '');
+ this.$content.html(this.text_to_html(value));
+ this.$content.focusInEnd();
+ var history = this.$content.data('NoteHistory');
+ if (history && history.recordUndo()) {
+ this.$('.note-toolbar').find('button[data-event="undo"]').attr('disabled', false);
+ }
+ },
+ is_false: function() {
+ return !this.get('value') || this.get('value') === "<p><br/></p>" || !this.get('value').match(/\S/);
+ },
+ before_save: function() {
+ if (this.options['style-inline']) {
+ transcoder.class_to_style(this.$content);
+ transcoder.font_to_img(this.$content);
+ this.internal_set_value(this.$content.html());
+ }
+ },
+ destroy_content: function () {
+ $(".oe-view-manager-content").off("scroll");
+ this.$textarea.destroy();
+ this._super();
+ }
+});
+
+var FieldTextHtml = widget.extend({
+ template: 'web_editor.FieldTextHtml',
+ willStart: function () {
+ var self = this;
+ return new Model('res.lang').call("search_read", [[['code', '!=', 'en_US']], ["name", "code"]]).then(function (res) {
+ self.languages = res;
+ });
+ },
+ start: function () {
+ var self = this;
+
+ this.callback = _.uniqueId('FieldTextHtml_');
+ window.odoo[this.callback+"_editor"] = function (EditorBar) {
+ setTimeout(function () {
+ self.on_editor_loaded(EditorBar);
+ },0);
+ };
+ window.odoo[this.callback+"_content"] = function (EditorBar) {
+ self.on_content_loaded();
+ };
+ window.odoo[this.callback+"_updown"] = null;
+ window.odoo[this.callback+"_downup"] = function (value) {
+ self.internal_set_value(value);
+ self.trigger('changed_value');
+ self.resize();
+ };
+
+ // init jqery objects
+ this.$iframe = this.$el.find('iframe');
+ this.document = null;
+ this.$body = $();
+ this.$content = $();
+
+ this.$iframe.css('min-height', 'calc(100vh - 360px)');
+
+ // init resize
+ this.resize = function resize() {
+ if (self.get('effective_readonly')) { return; }
+ if ($("body").hasClass("o_form_FieldTextHtml_fullscreen")) {
+ self.$iframe.css('height', (document.body.clientHeight - self.$iframe.offset().top) + 'px');
+ } else {
+ self.$iframe.css("height", (self.$body.find("#oe_snippets").length ? 500 : 300) + "px");
+ }
+ };
+ $(window).on('resize', self.resize);
+
+ return this._super();
+ },
+ get_url: function (_attr) {
+ var src = this.options.editor_url ? this.options.editor_url+"?" : "/web_editor/field/html?";
+ var datarecord = this.view.get_fields_values();
+
+ var attr = {
+ 'model': this.view.model,
+ 'field': this.name,
+ 'res_id': datarecord.id || '',
+ 'callback': this.callback
+ };
+ _attr = _attr || {};
+
+ if (this.options['style-inline']) {
+ attr.inline_mode = 1;
+ }
+ if (this.options.snippets) {
+ attr.snippets = this.options.snippets;
+ }
+ if (!this.get("effective_readonly")) {
+ attr.enable_editor = 1;
+ }
+ if (this.field.translate) {
+ attr.translatable = 1;
+ }
+ if (session.debug) {
+ attr.debug = 1;
+ }
+
+ attr.lang = attr.enable_editor ? 'en_US' : this.session.user_context.lang;
+
+ for (var k in _attr) {
+ attr[k] = _attr[k];
+ }
+
+ for (var k in attr) {
+ if (attr[k] !== null) {
+ src += "&"+k+"="+(_.isBoolean(attr[k]) ? +attr[k] : attr[k]);
+ }
+ }
+
+ delete datarecord[this.name];
+ src += "&datarecord="+ encodeURIComponent(JSON.stringify(datarecord));
+
+ return src;
+ },
+ initialize_content: function() {
+ this.$el.closest('.modal-body').css('max-height', 'none');
+ this.$iframe = this.$el.find('iframe');
+ this.document = null;
+ this.$body = $();
+ this.$content = $();
+ this.editor = false;
+ window.odoo[this.callback+"_updown"] = null;
+ this.$iframe.attr("src", this.get_url());
+ },
+ on_content_loaded: function () {
+ var self = this;
+ this.document = this.$iframe.contents()[0];
+ this.$body = $("body", this.document);
+ this.$content = this.$body.find("#editable_area");
+ this.lang = this.$iframe.attr('src').match(/[?&]lang=([^&]+)/);
+ this.lang = this.lang ? this.lang[1] : this.view.dataset.context.lang;
+ this._dirty_flag = false;
+ this.render_value();
+ setTimeout(function () {
+ self.add_button();
+ setTimeout(self.resize,0);
+ }, 0);
+ },
+ on_editor_loaded: function (EditorBar) {
+ var self = this;
+ this.editor = EditorBar;
+ if (this.get('value') && window.odoo[self.callback+"_updown"] && !(this.$content.html()||"").length) {
+ this.render_value();
+ }
+ setTimeout(function () {
+ setTimeout(self.resize,0);
+ }, 0);
+ },
+ add_button: function () {
+ var self = this;
+ var $to = this.$body.find("#web_editor-top-edit, #wrapwrap").first();
+
+ $(QWeb.render('web_editor.FieldTextHtml.translate', {'widget': this}))
+ .appendTo($to)
+ .on('change', 'select', function () {
+ var lang = $(this).val();
+ var edit = !self.get("effective_readonly");
+ var trans = lang !== 'en_US';
+ self.$iframe.attr("src", self.get_url({
+ 'edit_translations': edit && trans,
+ 'enable_editor': edit && !trans,
+ 'lang': lang
+ }));
+ });
+
+ $(QWeb.render('web_editor.FieldTextHtml.fullscreen'))
+ .appendTo($to)
+ .on('click', '.o_fullscreen', function () {
+ $("body").toggleClass("o_form_FieldTextHtml_fullscreen");
+ var full = $("body").hasClass("o_form_FieldTextHtml_fullscreen");
+ self.$iframe.parents().toggleClass('o_form_fullscreen_ancestor', full);
+ self.resize();
+ });
+
+ this.$body.on('click', '[data-action="cancel"]', function (event) {
+ event.preventDefault();
+ self.initialize_content();
+ });
+ },
+ render_value: function() {
+ if (this.lang !== this.view.dataset.context.lang || this.$iframe.attr('src').match(/[?&]edit_translations=1/)) {
+ return;
+ }
+ var value = (this.get('value') || "").replace(/^<p[^>]*>(\s*|<br\/?>)<\/p>$/, '');
+ if (!this.$content) {
+ return;
+ }
+ if (!this.get("effective_readonly")) {
+ if(window.odoo[this.callback+"_updown"]) {
+ window.odoo[this.callback+"_updown"](value, this.view.get_fields_values(), this.name);
+ this.resize();
+ }
+ } else {
+ this.$content.html(value);
+ this.$iframe.css("height", (this.$body.height()+20) + "px");
+ }
+ },
+ is_false: function() {
+ return this.get('value') === false || !this.$content.html() || !this.$content.html().match(/\S/);
+ },
+ before_save: function () {
+ if (this.lang !== 'en_US' && this.$body.find('.o_dirty').length) {
+ this.internal_set_value( this.view.datarecord[this.name] );
+ this._dirty_flag = false;
+ return this.editor.save();
+ } else if (this._dirty_flag && this.editor && this.editor.buildingBlock) {
+ this.editor.buildingBlock.clean_for_save();
+ this.internal_set_value( this.$content.html() );
+ }
+ },
+ destroy: function () {
+ $(window).off('resize', self.resize);
+ delete window.odoo[this.callback+"_editor"];
+ delete window.odoo[this.callback+"_content"];
+ delete window.odoo[this.callback+"_updown"];
+ delete window.odoo[this.callback+"_downup"];
+ }
+});
+
+core.form_widget_registry
+ .add('html', FieldTextHtmlSimple)
+ .add('html_frame', FieldTextHtml);
+
+});
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 00000000000..28b35722edd
--- /dev/null
+++ b/addons/web_editor/static/src/js/base.js
@@ -0,0 +1,85 @@
+odoo.define('web_editor.base', function (require) {
+"use strict";
+
+var core = require('web.core');
+var ajax = require('web.ajax');
+var qweb = core.qweb;
+var _t = core._t;
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+var get_context = function (dict) {
+ var html = document.documentElement;
+ return _.extend({
+ 'lang': (html.getAttribute('lang') || '').replace('-', '_'),
+ }, dict);
+};
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+/* ----------------------------------------------------
+ Async Ready and Template loading
+ ---------------------------------------------------- */
+var dom_ready = $.Deferred();
+$(document).ready(function () {
+ dom_ready.resolve();
+ // fix for ie
+ if($.fn.placeholder) $('input, textarea').placeholder();
+});
+
+
+// todo: remove and load a bundle of translated templates
+var all_ready;
+var ready = function() {
+ if (!all_ready) {
+ all_ready = $.when(dom_ready, ajax.loadXML()).then(translations);
+ } else if(all_ready.state() === "resolved") { // can add async template
+ all_ready = $.when(dom_ready, ajax.loadXML());
+ }
+ return all_ready;
+};
+
+function translations() {
+ function translate_node(node){
+ if(node.nodeType === 3) { // TEXT_NODE
+ if(node.nodeValue.match(/\S/)){
+ var space = node.nodeValue.match(/^([\s]*)([\s\S]*?)([\s]*)$/);
+ node.nodeValue = space[1] + $.trim(_t(space[2])) + space[3];
+ }
+ }
+ else if(node.nodeType === 1 && node.hasChildNodes()) { // ELEMENT_NODE
+ _.each(node.childNodes, translate_node);
+ }
+ }
+ return ajax.jsonRpc(data.url_translations, 'call', {
+ 'mods': ['web_editor'],
+ 'lang': data.get_context().lang
+ })
+ .then(function(trans) {
+ _t.database.set_bundle(trans);
+ }).then(function () {
+ var keys = _.keys(qweb.templates);
+ for (var i = 0; i < keys.length; i++){
+ translate_node(qweb.templates[keys[i]]);
+ }
+ }).promise();
+}
+
+var reload = function () {
+ location.hash = "scrollTop=" + window.document.body.scrollTop;
+ if (location.search.indexOf("enable_editor") > -1) {
+ window.location.href = window.location.href.replace(/enable_editor(=[^&]*)?/g, '');
+ } else {
+ window.location.reload();
+ }
+};
+
+var data = {
+ 'get_context': get_context,
+ 'url_translations': '/web/webclient/translations',
+ 'dom_ready': dom_ready,
+ 'ready': ready,
+ 'reload': reload
+};
+return data;
+
+});
diff --git a/addons/web_editor/static/src/js/editor.js b/addons/web_editor/static/src/js/editor.js
new file mode 100644
index 00000000000..b09a69e3818
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor.js
@@ -0,0 +1,164 @@
+odoo.define('web_editor.editor', function (require) {
+"use strict";
+
+var ajax = require('web.ajax');
+var Widget = require('web.Widget');
+var core = require('web.core');
+var base = require('web_editor.base');
+var rte = require('web_editor.rte');
+
+var qweb = core.qweb;
+var _t = core._t;
+var editor = {};
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+editor.dummy = function () {return true;}; // used for snippets, options...
+
+editor.editable = !!$('html').data('editable');
+
+ajax.loadXML('/web_editor/static/src/xml/editor.xml', qweb);
+
+base.dom_ready.done(function () {
+ $(document).on('click', '.note-editable', function (ev) {
+ ev.preventDefault();
+ });
+
+ $(document).on('submit', '.note-editable form .btn', function (ev) {
+ // Disable form submition in editable mode
+ ev.preventDefault();
+ });
+
+ $(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();
+ }
+ });
+});
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+editor.reload = function () {
+ location.hash = "scrollTop=" + window.document.body.scrollTop;
+ if (location.search.indexOf("enable_editor") > -1) {
+ window.location.href = window.location.href.replace(/&?enable_editor(=[^&]*)?/g, '');
+ } else {
+ window.location.reload();
+ }
+};
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+/* ----- TOP EDITOR BAR FOR ADMIN ---- */
+
+base.ready().then(function () {
+ if (editor.editable && location.search.indexOf("enable_editor") >= 0 && !editor.no_editor) {
+ editor.editor_bar = new editor.Class();
+ editor.editor_bar.prependTo(document.body);
+ }
+});
+
+editor.Class = Widget.extend({
+ template: 'web_editor.editorbar',
+ events: {
+ 'click button[data-action=save]': 'save',
+ 'click a[data-action=cancel]': 'cancel',
+ },
+ init: function(parent) {
+ var self = this;
+ var res = this._super.apply(this, arguments);
+ this.parent = parent;
+ this.rte = new rte.Class(this);
+ this.rte.on('rte:start', this, function () {
+ self.trigger('rte:start');
+ });
+ return res;
+ },
+ start: function() {
+ var self = this;
+
+ this.$('button[data-action=save]').prop('disabled', true);
+ $('.dropdown-toggle').dropdown();
+
+ this.display_placeholder();
+
+ this.rte.on('change', this, this.proxy('rte_changed'));
+ this.rte.start();
+
+ var flag = false;
+ window.onbeforeunload = function(event) {
+ if ($('.o_editable.o_dirty').length && !flag) {
+ flag = true;
+ setTimeout(function () {flag=false;},0);
+ return _t('This document is not saved!');
+ }
+ };
+ return this._super();
+ },
+ display_placeholder: function () {
+ var $area = $("#wrapwrap").find("[data-oe-model] .oe_structure.oe_empty, [data-oe-model].oe_structure.oe_empty, [data-oe-type=html]")
+ .addClass("oe_empty")
+ .attr("data-oe-placeholder", _t("Press The Top-Left Edit Button"));
+
+ this.on('rte:start', this, function () {
+ $area.attr("data-oe-placeholder", _t("Write Your Text Here"));
+ });
+ this.on("snippets:ready", this, function () {
+ if ($("body").hasClass("editor_has_snippets")) {
+ $area.attr("data-oe-placeholder", _t("Write Your Text or Drag a Block Here"));
+ }
+ });
+
+ $(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();
+ }
+ }
+ });
+ },
+ rte_changed: function () {
+ // todo: use !rte.history.getEditableHasUndo().length (but phantomjs unknow error)
+ this.$('button[data-action=save]').prop('disabled', !$('.o_dirty').length);
+ },
+ save: function () {
+ return this.rte.save().then(function () {
+ editor.reload();
+ });
+ },
+ /**
+ * Saves an RTE content, which always corresponds to a view section (?).
+ */
+ save_without_reload: function () {
+ return this.rte.save();
+ },
+ cancel: function () {
+ new $.Deferred(function (d) {
+ if (!rte.history.getEditableHasUndo().length) {
+ return d.resolve();
+ }
+ var $dialog = $(qweb.render('web_editor.discard')).appendTo(document.body);
+ $dialog.on('click', '.btn-danger', function () {
+ d.resolve();
+ }).on('hidden.bs.modal', function () {
+ d.reject();
+ });
+ d.always(function () {
+ $dialog.remove();
+ });
+ $dialog.modal('show');
+ }).then(function () {
+ window.onbeforeunload = null;
+ editor.reload();
+ });
+ },
+});
+
+return editor;
+
+});
+
diff --git a/addons/web_editor/static/src/js/iframe.js b/addons/web_editor/static/src/js/iframe.js
new file mode 100644
index 00000000000..8b287064b7c
--- /dev/null
+++ b/addons/web_editor/static/src/js/iframe.js
@@ -0,0 +1,78 @@
+odoo.define('web_editor.iframe', function (require) {
+'use strict';
+
+var editor = require('web_editor.editor');
+var translator = require('web_editor.translate');
+var rte = require('web_editor.rte');
+var snippet_editor = require('web_editor.snippet.editor');
+
+window.top.odoo[callback+"_updown"] = function (value, fields_values, field_name) {
+ var $editable = $("#editable_area");
+ if(value !== $editable.prop("innerHTML")) {
+ if ($('body').hasClass('editor_enable')) {
+ if (value !== fields_values[field_name]) {
+ rte.history.recordUndo($editable);
+ }
+ snippet_editor.instance.make_active(false);
+ }
+
+ $editable.html(value);
+
+ if ($('body').hasClass('editor_enable') && value !== fields_values[field_name]) {
+ $editable.trigger("content_changed");
+ }
+ }
+};
+
+editor.Class.include({
+ start: function () {
+ this.on('rte:start', this, function () {
+ this.$('form').hide();
+
+ if (window.top.odoo[callback+"_editor"]) {
+ window.top.odoo[callback+"_editor"](this);
+ }
+
+ var $editable = $("#editable_area");
+ setTimeout(function () {
+ $($editable.find("*").filter(function () {return !this.children.length;}).first()[0] || $editable)
+ .focusIn().trigger("mousedown").trigger("keyup");
+ },0);
+
+ $editable.on('content_changed', this, function () {
+ if (window.top.odoo[callback+"_downup"]) {
+ window.top.odoo[callback+"_downup"]($editable.prop('innerHTML'));
+ }
+ });
+ });
+
+ this.on("snippets:ready", this, function () {
+ $(window.top).trigger("resize");
+ });
+ return this._super();
+ }
+});
+
+snippet_editor.Class.include({
+ _get_snippet_url: function () {
+ return snippets_url;
+ }
+});
+
+translator.Class.include({
+ start: function () {
+ var self = this;
+ return this._super().then(function () {
+ $('button[data-action=save]').hide();
+ if (window.top.odoo[callback+"_editor"]) {
+ window.top.odoo[callback+"_editor"](self);
+ }
+ });
+ }
+});
+
+});
+
+$(function () {
+ odoo.init();
+});
diff --git a/addons/web_editor/static/src/js/inline.js b/addons/web_editor/static/src/js/inline.js
new file mode 100644
index 00000000000..c5fd2cfd924
--- /dev/null
+++ b/addons/web_editor/static/src/js/inline.js
@@ -0,0 +1,77 @@
+odoo.define('web_editor.inline', function (require) {
+'use strict';
+
+var editor = require('web_editor.editor');
+var rte = require('web_editor.rte');
+var widget = require('web_editor.widget');
+var transcoder = require('web_editor.transcoder');
+var snippet_editor = require('web_editor.snippet.editor');
+
+widget.MediaDialog.include({
+ start: function () {
+ this._super();
+ this.$('[href="#editor-media-video"]').addClass('hidden');
+ }
+});
+
+editor.Class.include({
+ start: function () {
+ var self = this;
+ if (location.search.indexOf("enable_editor") !== -1) {
+ this.on('rte:start', this, function () {
+ // move the caret at the end of the text when click after all content
+ $("#wrapwrap").on('click', function (event) {
+ if ($(event.target).is("#wrapwrap") || $(event.target).is("#editable_area:empty")) {
+ setTimeout(function () {
+ var node = $("#editable_area *")
+ .filter(function () { return this.textContent.match(/\S|\u00A0/); })
+ .add($("#editable_area"))
+ .last()[0];
+ $.summernote.core.range.create(node, $.summernote.core.dom.nodeLength(node)).select();
+ },0);
+ }
+ });
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+});
+
+snippet_editor.Class.include({
+ start: function () {
+ var self = this;
+ this._super();
+ setTimeout(function () {
+ var $editable = $("#editable_area");
+ transcoder.img_to_font($editable);
+ transcoder.style_to_class($editable);
+ });
+ },
+ clean_for_save: function () {
+ this._super();
+ var $editable = $("#editable_area");
+ transcoder.class_to_style($editable);
+ transcoder.font_to_img($editable);
+ },
+
+});
+
+window.top.odoo[callback+"_updown"] = function (value, fields_values) {
+ var $editable = $("#editable_area");
+ value = value || "";
+ if (value.indexOf('on_change_model_and_list') === -1 && value !== $editable.html()) {
+ rte.history.recordUndo($editable, null, true);
+ if (snippet_editor.instance) {
+ snippet_editor.instance.make_active(false);
+ }
+
+ $editable.html(value);
+
+ transcoder.img_to_font($editable);
+ transcoder.style_to_class($editable);
+ } else {
+ $editable.trigger("content_changed");
+ }
+};
+
+}); \ No newline at end of file
diff --git a/addons/web_editor/static/src/js/rte.js b/addons/web_editor/static/src/js/rte.js
new file mode 100644
index 00000000000..c8d5655cbd6
--- /dev/null
+++ b/addons/web_editor/static/src/js/rte.js
@@ -0,0 +1,597 @@
+odoo.define('web_editor.rte', function (require) {
+'use strict';
+
+var ajax = require('web.ajax');
+var core = require('web.core');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+var summernote = require('web_editor.summernote');
+var base = require('web_editor.base');
+
+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;
+
+ this.makeSnap = function (event, rng) {
+ rng = rng || range.create();
+ var elEditable = $(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.html(oSnap.contents).scrollTop(oSnap.scrollTop);
+ $(".oe_overlay").remove();
+ $(".note-control-selection").hide();
+
+ $editable.trigger("content_changed");
+
+ if (!oSnap.bookmark || oSnap.event === "blur") {
+ return;
+ }
+
+ 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();
+ }
+ });
+
+
+ setTimeout(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");
+ },0);
+ };
+
+ 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].event === "activate")) {
+ 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 === "blur" || aUndo[pos].event === "activate" || aUndo[pos].event === "undo")) {
+ this.applySnap(aUndo[Math.max(++pos,aUndo.length-1)]);
+ }
+ };
+ this.hasRedo = function () {
+ return aUndo.length > pos+1;
+ };
+
+ var toSnap, split;
+ this.recordUndo = function ($editable, event, internal_history) {
+ var self = this;
+ if (!$editable) {
+ $editable = $(range.create().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(Math.max(pos,1), 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();
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+// add focusIn to jQuery to allow to move caret into a div of a contentEditable area
+
+$.extend($.expr[':'],{
+ o_editable: function(node,i,m){
+ while (node) {
+ if (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;
+ }
+});
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+/* ----- RICH TEXT EDITOR ---- */
+
+var RTE = Widget.extend({
+ init: function (EditorBar, config) {
+ var self = this;
+ this.EditorBar = EditorBar;
+ data.rte = 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;
+ };
+
+ if (config) {
+ this.config = config;
+ }
+ },
+ /**
+ * Add a record undo to history
+ * @param {DOM} target where the dom is changed is editable zone
+ */
+ historyRecordUndo: function ($target, event, internal_history) {
+ 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;
+ }
+ rng.select();
+ history.recordUndo($editable, event, internal_history);
+ },
+ /**
+ * Makes the page editable
+ *
+ * @param {Boolean} [restart=false] in case the edition was already set
+ * up once and is being re-enabled.
+ * @returns {$.Deferred} deferred indicating when the RTE is ready
+ */
+ start: function () {
+ var self = this;
+
+ this.saving_mutex = new utils.Mutex();
+
+ $.fn.carousel = this.edit_bootstrap_carousel;
+
+ $(document).on('keydown', this, this.onKeydown);
+ $(document).on('mousedown activate', this, this.onMousedown);
+ $(document).on('mouseup', this, this.onMouseup);
+
+ $('.o_not_editable').attr("contentEditable", false);
+
+ var $editable = this.editable();
+
+ $editable.addClass('o_editable').data('rte', self);
+
+ $editable.each(function () {
+ var $node = $(this);
+
+ // add class to display inline-block for empty t-field
+ if(window.getComputedStyle(this).display === "inline" && $node.data('oe-type') !== "image") {
+ $node.addClass('o_is_inline_editable');
+ }
+
+ // start element observation
+ $node.one('content_changed', function () {
+ $(this).addClass('o_dirty');
+ });
+
+ $node.data('initInnerHTML', $node.html());
+ });
+
+ $(document).on('content_changed', '.o_editable', function () {
+ self.trigger('change', this);
+ });
+
+ $('#wrapwrap, .o_editable').on('click', '*', this, this.onClick);
+
+ $('body').addClass("editor_enable");
+
+ $(document)
+ .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');
+ self.trigger('rte:start');
+ },
+
+ save: function (context) {
+ var self = this;
+
+ this.__saved = {}; // list of allready saved views and data
+
+ var editables = history.getEditableHasUndo();
+
+ var defs = $('.o_dirty')
+ .removeAttr('contentEditable')
+ .removeClass('o_dirty oe_carlos_danger o_is_inline_editable')
+ .map(function () {
+ var $el = $(this);
+
+ $el.find('[class]').filter(function () {
+ if (!this.className.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)
+ .then(undefined, function (thing, 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);
+ return $.Deferred().reject({
+ id: id,
+ error: response.data,
+ });
+ });
+ });
+ }).get();
+
+ return $.when.apply(null, defs).then(function () {
+ window.onbeforeunload = null;
+ }, function (failed) {
+ // If there were errors, re-enable edition
+ self.stop();
+ self.start();
+ // jquery's deferred being a pain in the ass
+ if (!_.isArray(failed)) { failed = [failed]; }
+
+ _(failed).each(function (failure) {
+ var html = failure.error.exception_type === "except_osv";
+ if (html) {
+ var msg = $("<div/>").text(failure.error.message).html();
+ var data = msg.substring(3,msg.length-2).split(/', u'/);
+ failure.error.message = '<b>' + data[0] + '</b>' + data[1];
+ }
+ $('.o_editable.' + failure.id)
+ .removeClass(failure.id)
+ .popover({
+ html: html,
+ trigger: 'hover',
+ content: failure.error.message,
+ placement: 'auto top',
+ })
+ // Force-show popovers so users will notice them.
+ .popover('show');
+ });
+ });
+ },
+
+ saveElement: function ($el, context) {
+ // remove multi edition
+ if ($el.data('oe-model')) {
+ var key = $el.data('oe-model')+":"+$el.data('oe-id')+":"+$el.data('oe-field')+":"+$el.data('oe-type')+":"+$el.data('oe-expression');
+ if (this.__saved[key]) return true;
+ this.__saved[key] = true;
+ }
+
+ return ajax.jsonRpc('/web/dataset/call', 'call', {
+ model: 'ir.ui.view',
+ method: 'save',
+ args: [
+ $el.data('oe-id'),
+ $el.prop('outerHTML'),
+ $el.data('oe-xpath') || null,
+ context || base.get_context()
+ ],
+ });
+ },
+
+ stop: function () {
+ if (this.$last) {
+ this.$last.destroy();
+ this.$last = null;
+ }
+
+ $.fn.carousel = this.init_bootstrap_carousel;
+
+ $(document).off('keydown', this.onKeydown);
+ $(document).off('mousedown applySnap', this.onMousedown);
+ $(document).off('mouseup', this.onMouseup);
+ $('.o_not_editable').removeAttr("contentEditable");
+ $(document).off('content_changed').removeClass('o_is_inline_editable').removeData('rte');
+ $('#wrapwrap, .o_editable').off('click', this.onClick);
+ $(document).tooltip('destroy');
+ $('body').removeClass("editor_enable");
+ this.trigger('rte:stop');
+ },
+
+ cancel: function () {
+ $('.o_editable').each(function () {
+ var $node = $(this);
+ $node.data('initInnerHTML', $node.html());
+ });
+ this.stop();
+ },
+
+ onClick: function (event) {
+ event.preventDefault();
+ },
+
+ // handler for cancel editor
+ onKeydown: function (event) {
+ if (event.keyCode === 27 && !$('.modal-content:visible').length) {
+ setTimeout(function () {
+ $('#editor-top-navbar [data-action="cancel"]').click();
+ var $modal = $('.modal-content > .modal-body').parents(".modal:first");
+ $modal.off('keyup.dismiss.bs.modal');
+ setTimeout(function () {
+ $modal.on('keyup.dismiss.bs.modal', function () {
+ $(this).modal('hide');
+ });
+ },500);
+ },0);
+ }
+ },
+
+ // activate editor
+ onMousedown: function (event) {
+ var $target = $(event.target);
+ var $editable = $target.closest('.o_editable');
+
+ if (!$editable.size()) {
+ return;
+ }
+
+ var rte = $editable.data('rte') || event.data;
+
+ if (rte && rte.$last && (!$editable.size() || rte.$last[0] != $editable[0])) {
+ var $destroy = rte.$last;
+ history.splitNext();
+
+ setTimeout(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)
+ rte.$last = null;
+ }
+ if ($editable.size() && (!rte.$last || rte.$last[0] != $editable[0]) &&
+ ($target.closest('[contenteditable]').attr('contenteditable') || "").toLowerCase() !== 'false') {
+
+ $editable.summernote(rte.config($editable));
+
+ $editable.data('NoteHistory', history);
+ rte.$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
+ }
+ }
+ },
+
+ onMouseup: function (event) {
+ var $target = $(event.target);
+ var $editable = $target.closest('.o_editable');
+
+ if (!$editable.size()) {
+ return;
+ }
+
+ var rte = event.data;
+ setTimeout(function () {
+ rte.historyRecordUndo($target, 'activate', true);
+ },0);
+ },
+
+ 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');
+ },
+
+ config: 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");
+ }
+ };
+ },
+});
+
+
+var data = {
+ 'history': history,
+ 'Class': RTE
+};
+return data;
+
+});
diff --git a/addons/web_editor/static/src/js/rte.summernote.js b/addons/web_editor/static/src/js/rte.summernote.js
new file mode 100644
index 00000000000..1086f4b369c
--- /dev/null
+++ b/addons/web_editor/static/src/js/rte.summernote.js
@@ -0,0 +1,781 @@
+odoo.define('web_editor.rte.summernote', function (require) {
+'use strict';
+
+var core = require('web.core');
+var ajax = require('web.ajax');
+var base = require('web_editor.base');
+var widgets = require('web_editor.widget');
+var rte = require('web_editor.rte');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ajax.jsonRpc('/web/dataset/call', 'call', {
+ 'model': 'ir.ui.view',
+ 'method': 'read_template',
+ 'args': ['web_editor.colorpicker', base.get_context()]
+}).done(function (data) {
+ QWeb.add_template(data);
+});
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+/* 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;
+var options = $.summernote.options;
+
+var tplButton = renderer.getTemplate().button;
+var tplIconButton = renderer.getTemplate().iconButton;
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+/* update and change the popovers content, and add history button */
+
+var fn_createPalette = renderer.createPalette;
+renderer.createPalette = function ($container, options) {
+ fn_createPalette.call(this, $container, options);
+
+ if (!QWeb.has_template('web_editor.colorpicker')) {
+ return;
+ }
+
+ var $color = $container.find('.note-color');
+ var html = QWeb.render('web_editor.colorpicker');
+ $color.find('.note-color-palette').prepend(html);
+ var $bg = $color.find('.colorpicker:first button');
+ var $fore = $color.find('.colorpicker:last button');
+
+ $bg.each(function () { $(this).attr('data-event', 'backColor').attr('data-value', $(this).attr('class')); });
+ $fore.each(function () { $(this).attr('data-event', 'foreColor').attr('data-value', $(this).attr('class').replace(/bg-/, 'text-')); });
+};
+
+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');
+
+ if (window === window.top) {
+ $popover.children().addClass("hidden-xs");
+ }
+
+ //////////////// image popover
+
+ // add center button for images
+ var $centerbutton = $(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();
+
+ var $alt = $('<div class="btn-group"/>');
+ $alt.prependTo($imagePopover.find('.popover-content'));
+ $alt.append('<button class="btn btn-default btn-sm btn-small" data-event="alt"><strong>Alt: </strong><span class="o_image_alt"></span></button>');
+
+ // padding button
+ var $padding = $('<div class="btn-group"/>');
+ $padding.insertBefore($imagePopover.find('.btn-group:first'));
+ var $button = $(tplIconButton('fa fa-plus-square-o', {
+ title: _t('Padding'),
+ dropdown: true
+ })).appendTo($padding);
+ var $ul = $('<ul class="dropdown-menu"/>').insertAfter($button);
+ $ul.append('<li><a data-event="padding" href="#" data-value="">'+_t('None')+'</a></li>');
+ $ul.append('<li><a data-event="padding" href="#" data-value="small">'+_t('Small')+'</a></li>');
+ $ul.append('<li><a data-event="padding" href="#" data-value="medium">'+_t('Medium')+'</a></li>');
+ $ul.append('<li><a data-event="padding" href="#" data-value="large">'+_t('Large')+'</a></li>');
+ $ul.append('<li><a data-event="padding" href="#" data-value="xl">'+_t('Xl')+'</a></li>');
+
+ // 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="img-circle"]'));
+
+ // add spin for fa
+ var $spin = $('<div class="btn-group hidden 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 hidden 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(".btn-group:first").remove();
+ $colorfa.find("ul.dropdown-menu").css('min-width', '172px');
+ $colorfa.find('button[data-event="color"]').attr('data-value', '{"foreColor": "#f00"}')
+ .find("i").css({'background': '', 'color': '#f00'});
+ $resizefa.after($colorfa);
+
+ // show dialog box and delete
+ var $imageprop = $('<div class="btn-group"/>');
+ $imageprop.appendTo($imagePopover.find('.popover-content'));
+ $(tplIconButton('fa fa-picture-o', {
+ title: _t('Edit'),
+ event: 'showImageDialog'
+ })).appendTo($imageprop);
+ $(tplIconButton('fa fa-trash-o', {
+ title: _t('Remove'),
+ event: 'delete'
+ })).appendTo($imageprop);
+
+ $imagePopover.find('.popover-content').append($airPopover.find(".note-history").clone());
+
+ $imagePopover.find('[data-event="showImageDialog"]').before($airPopover.find('[data-event="showLinkDialog"]').clone());
+
+ //////////////// link popover
+
+ $linkPopover.find('.popover-content').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').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.parent().removeClass('active');
+ $format.filter('[data-value="'+(node ? node.tagName.toLowerCase() : "p")+'"]')
+ .parent().addClass("active");
+ });
+
+ //////////////// tooltip
+
+ $airPopover.add($linkPopover).add($imagePopover).find("button")
+ .tooltip('destroy')
+ .tooltip({
+ container: 'body',
+ trigger: 'hover',
+ placement: 'bottom'
+ }).on('click', function () {$(this).tooltip('hide');});
+
+ return $popover;
+};
+
+var fn_boutton_update = eventHandler.popover.button.update;
+eventHandler.popover.button.update = function ($container, oStyle) {
+ // stop animation when edit content
+ var previous = $(".note-control-selection").data('target');
+ if (previous) {
+ $(previous).css({"-webkit-animation-play-state": "", "animation-play-state": "", "-webkit-transition": "", "transition": "", "-webkit-animation": "", "animation": ""});
+ }
+ if (oStyle.image) {
+ $(oStyle.image).css({"-webkit-animation": "none", "animation": "none"});
+ }
+ // end
+
+ fn_boutton_update.call(this, $container, oStyle);
+
+ if (oStyle.image) {
+ $container.find('[data-event]').parent().removeClass("active");
+
+ $container.find('a[data-event="padding"][data-value="small"]').parent().toggleClass("active", $(oStyle.image).hasClass("padding-small"));
+ $container.find('a[data-event="padding"][data-value="medium"]').parent().toggleClass("active", $(oStyle.image).hasClass("padding-medium"));
+ $container.find('a[data-event="padding"][data-value="large"]').parent().toggleClass("active", $(oStyle.image).hasClass("padding-large"));
+ $container.find('a[data-event="padding"][data-value="xl"]').parent().toggleClass("active", $(oStyle.image).hasClass("padding-xl"));
+ $container.find('a[data-event="padding"][data-value=""]').parent().toggleClass("active", !$container.find('.active a[data-event="padding"]').length);
+
+ if (dom.isImgFont(oStyle.image)) {
+
+ $container.find('.btn-group:not(.only_fa):has(button[data-event="resize"],button[data-event="imageShape"])').addClass("hidden");
+ $container.find('.only_fa').removeClass("hidden");
+ $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="imageShape"][data-value="fa-spin"]').toggleClass("active", $(oStyle.image).hasClass("fa-spin"));
+
+ $container.find('.note-color').removeClass("hidden");
+
+ } else {
+
+ $container.find('.hidden:not(.only_fa)').removeClass("hidden");
+ $container.find('.only_fa').addClass("hidden");
+ 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="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("hidden");
+ }
+
+ $container.find('.note-color').addClass("hidden");
+
+ }
+
+ $container.find('button[data-event="floatMe"][data-value="left"]').toggleClass("active", $(oStyle.image).hasClass("pull-left"));
+ $container.find('button[data-event="floatMe"][data-value="center"]').toggleClass("active", $(oStyle.image).hasClass("center-block"));
+ $container.find('button[data-event="floatMe"][data-value="right"]').toggleClass("active", $(oStyle.image).hasClass("pull-right"));
+
+ $(oStyle.image).trigger('attributes_change');
+ }
+};
+
+var fn_popover_update = eventHandler.popover.update;
+eventHandler.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 (!isAirMode || $(oStyle.range.sc).closest('[data-oe-model]:not([data-oe-model="ir.ui.view"]):not([data-oe-type="html"])').length) {
+ $imagePopover.hide();
+ $linkPopover.hide();
+ $airPopover.hide();
+ return;
+ }
+
+ 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();
+
+ range.createFromNode(dom.firstChild(oStyle.image)).select();
+ } else {
+ $(".note-control-selection").hide();
+ }
+
+ if (oStyle.image || (!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.sc).closest('.note-editable').length) {
+ $airPopover.hide();
+ } else {
+ $airPopover.show();
+ }
+};
+
+eventHandler.handle.update = function ($handle, oStyle, isAirMode) {
+ $handle.toggle(!!oStyle.image);
+ if (oStyle.image) {
+ var $selection = $handle.find('.note-control-selection');
+ var $image = $(oStyle.image);
+ var szImage = {
+ w: parseInt($image.outerWidth(true), 10),
+ h: parseInt($image.outerHeight(true), 10)
+ };
+ $selection.data('target', oStyle.image); // save current image element.
+ var sSizing = szImage.w + 'x' + szImage.h;
+ $selection.find('.note-control-selection-info').text(szImage.h > 50 ? sSizing : "");
+
+ $selection.find('.note-control-sizing').toggleClass('note-control-sizing note-control-holder').css({
+ 'border-top': 0,
+ 'border-left': 0
+ });
+ }
+};
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+/* hack for image and link editor */
+
+function getImgTarget () {
+ return $(".note-control-selection").data('target');
+}
+eventHandler.editor.padding = function ($editable, sValue) {
+ var $target = $(getImgTarget());
+ 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.editor.resize = function ($editable, sValue) {
+ var $target = $(getImgTarget());
+ $editable.data('NoteHistory').recordUndo();
+ var width = ($target.attr('style') || '').match(/(^|;|\s)width:\s*([0-9]+)%/);
+ if (width) {
+ width = width[2]/100;
+ }
+ $(getImgTarget()).css('width', width != sValue ? (sValue * 100) + '%' : '');
+};
+eventHandler.editor.resizefa = function ($editable, sValue) {
+ var $target = $(getImgTarget());
+ $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.editor.floatMe = function ($editable, sValue) {
+ var $target = $(getImgTarget());
+ $editable.data('NoteHistory').recordUndo();
+ switch (sValue) {
+ case 'center': $target.toggleClass('center-block').removeClass('pull-right pull-left'); break;
+ case 'left': $target.toggleClass('pull-left').removeClass('pull-right center-block'); break;
+ case 'right': $target.toggleClass('pull-right').removeClass('pull-left center-block'); break;
+ }
+};
+eventHandler.editor.imageShape = function ($editable, sValue) {
+ var $target = $(getImgTarget());
+ $editable.data('NoteHistory').recordUndo();
+ $target.toggleClass(sValue);
+};
+
+eventHandler.dialog.showLinkDialog = function ($editable, $dialog, linkInfo) {
+ $editable.data('range').select();
+ $editable.data('NoteHistory').recordUndo();
+
+ var editor = new widgets.LinkDialog($editable, linkInfo);
+ editor.appendTo(document.body);
+
+ var def = new $.Deferred();
+ editor.on("save", this, function (linkInfo) {
+ linkInfo.range.select();
+ $editable.data('range', linkInfo.range);
+ def.resolve(linkInfo);
+ $editable.trigger('keyup');
+ $('.note-popover .note-link-popover').show();
+ });
+ editor.on("cancel", this, function () {
+ def.reject();
+ });
+ return def;
+};
+eventHandler.dialog.showImageDialog = function ($editable) {
+ var r = $editable.data('range');
+ if (r.sc.tagName && r.sc.childNodes.length) {
+ r.sc = r.sc.childNodes[r.so];
+ }
+ var editor = new widgets.MediaDialog($editable, dom.isImg(r.sc) ? r.sc : null);
+ editor.appendTo(document.body);
+ return new $.Deferred().reject();
+};
+$.summernote.pluginEvents.alt = function (event, editor, layoutInfo, sorted) {
+ var $editable = layoutInfo.editable();
+ var $selection = layoutInfo.handle().find('.note-control-selection');
+ var media = $selection.data('target');
+ new widgets.alt($editable, media).appendTo(document.body);
+};
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+dom.isVoid = function (node) {
+ return node && /^BR|^IMG|^HR/.test(node.nodeName.toUpperCase()) || dom.isImg(node);
+};
+dom.isImg = function (node) {
+ return dom.isImgFont(node) || (node && (node.nodeName === "IMG" || (node.className && node.className.match(/(^|\s)media_iframe_video(\s|$)/i)) ));
+};
+dom.isForbiddenNode = function (node) {
+ return $(node).is(".media_iframe_video, .fa, img");
+};
+
+dom.isImgFont = function (node) {
+ 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<widgets.fontIcons.length; k++) {
+ if (_.intersection(widgets.fontIcons[k].icons, classNames).length) {
+ return true;
+ }
+ }
+ }
+ return false;
+};
+// re-overwrite font to include theme icons
+var isFont = dom.isFont;
+dom.isFont = function (node) {
+ return dom.isImgFont(node) || isFont(node);
+};
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+var fn_visible = $.summernote.pluginEvents.visible;
+$.summernote.pluginEvents.visible = function (event, editor, layoutInfo) {
+ var res = fn_visible.call(this, event, editor, layoutInfo);
+ var $node = $(dom.node(range.create().sc));
+ if (($node.is('[data-oe-type="html"]') || $node.is('[data-oe-field="arch"]')) && $node.hasClass("o_editable") && !$node[0].children.length) {
+ var p = $('<p><br/></p>')[0];
+ $node.append( p );
+ range.createFromNode(p.firstChild).select();
+ }
+ return res;
+};
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+/* fix ie and re-range to don't break snippet*/
+
+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.left : initial_data.event.clientX);
+ var dy = event.clientY - (event.shiftKey ? 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");
+
+ 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();
+ }
+ }
+ }
+
+ // remember_selection when click on non editable area
+ var r = range.create();
+ if ($(r ? dom.node(r.sc) : event.srcElement || event.target).closest('#web_editor-top-navbar, #oe_main_menu_navbar, .note-popover, .note-toolbar, .modal').length) {
+ if (!$(event.target).is('input, select, label, button, a')) {
+ if (!remember_selection && $editable[0]) {
+ remember_selection = range.create(dom.firstChild($editable[0]), 0);
+ }
+ if (!remember_selection && $editable[0]) {
+ try {
+ remember_selection.select();
+ } catch (e) {
+ console.warn(e);
+ }
+ }
+ }
+ } else if (r && $(dom.node(r.sc)).closest('.o_editable, .note-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 };
+ }
+}
+
+var last_div;
+var last_div_change;
+var last_editable;
+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);
+ $(document).on('mouseup', summernote_mouseup);
+ oLayoutInfo.editor.off('click').on('click', function (e) {e.preventDefault();}); // if the content editable is a link
+ oLayoutInfo.editor.on('dblclick', 'img, .media_iframe_video, span.fa, i.fa, span.fa', function (event) {
+ if (!$(event.target).closest(".note-toolbar").length) { // prevent icon edition of top bar for default summernote
+ new widgets.MediaDialog(oLayoutInfo.editor, event.target).appendTo(document.body);
+ }
+ });
+ $(document).on("keyup", reRangeSelectKey);
+
+ var clone_data = false;
+ var $node = oLayoutInfo.editor;
+ if ($node.data('oe-model')) {
+ $node.on('content_changed', function () {
+
+ var $nodes = $('[data-oe-model]')
+ .filter(function () { return this != $node[0];})
+ .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-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 $toolbar = $(oLayoutInfo.popover).add(oLayoutInfo.toolbar);
+ $('button[data-event="undo"], button[data-event="redo"]', $toolbar).attr('disabled', true);
+
+ $(oLayoutInfo.editor)
+ .add(oLayoutInfo.handle)
+ .add(oLayoutInfo.popover)
+ .add(oLayoutInfo.toolbar)
+ .on('click keyup', 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());
+ });
+};
+var fn_detach = eventHandler.detach;
+eventHandler.dettach = function (oLayoutInfo, options) {
+ fn_detach.call(this, oLayoutInfo, options);
+ oLayoutInfo.editor.off("dragstart");
+ $(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('Picture'),
+ 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'),
+ 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: {
+ recent: _t('Recent Color'),
+ more: _t('More Color'),
+ background: _t('Background Color'),
+ foreground: _t('Foreground Color'),
+ transparent: _t('Transparent'),
+ setTransparent: _t('Set transparent'),
+ reset: _t('Reset'),
+ resetToDefault: _t('Reset to default')
+ },
+ 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')
+ }
+};
+
+});
diff --git a/addons/web_editor/static/src/js/snippets.animation.js b/addons/web_editor/static/src/js/snippets.animation.js
new file mode 100644
index 00000000000..b3fccc5584a
--- /dev/null
+++ b/addons/web_editor/static/src/js/snippets.animation.js
@@ -0,0 +1,82 @@
+odoo.define('web_editor.snippets.animation', function (require) {
+'use strict';
+
+var Class = require('web.Class');
+var base = require('web_editor.base');
+
+var registry = [];
+var ready = [];
+
+var Animation = Class.extend({
+ selector: false,
+ $: function () {
+ return this.$el.find.apply(this.$el, arguments);
+ },
+ init: function (dom, editable_mode) {
+ this.$el = this.$target = $(dom);
+ this.start(editable_mode);
+ },
+ /*
+ * start
+ * This method is called after init
+ */
+ start: function (editable_mode) {
+ },
+ /*
+ * stop
+ * This method is called to stop the animation (e.g.: when rte is launch)
+ */
+ stop: function () {
+ },
+});
+
+var start = function (editable_mode, $target) {
+ for (var k in registry) {
+ var Animation = registry[k];
+ var selector = "";
+ if (Animation.prototype.selector) {
+ if (selector != "") selector += ", "
+ selector += Animation.prototype.selector;
+ }
+ if ($target) {
+ if ($target.is(selector)) selector = $target;
+ else continue;
+ }
+
+ $(selector).each(function() {
+ var $snipped_id = $(this);
+ if ( !$snipped_id.parents("#oe_snippets").length &&
+ !$snipped_id.parent("body").length &&
+ !$snipped_id.data("snippet-view")) {
+ ready.push($snipped_id);
+ $snipped_id.data("snippet-view", new Animation($snipped_id, editable_mode));
+ } else if ($snipped_id.data("snippet-view")) {
+ $snipped_id.data("snippet-view").start(editable_mode);
+ }
+ });
+ }
+};
+
+var stop = function () {
+ $(ready).each(function() {
+ var $snipped_id = $(this);
+ if ($snipped_id.data("snippet-view")) {
+ $snipped_id.data("snippet-view").stop();
+ }
+ });
+};
+
+base.ready(function () {
+ setTimeout(function () {
+ start();
+ },0);
+});
+
+return {
+ 'Class': Class,
+ 'registry': registry,
+ 'start': start,
+ 'stop': stop
+};
+
+}); \ No newline at end of file
diff --git a/addons/web_editor/static/src/js/snippets.editor.js b/addons/web_editor/static/src/js/snippets.editor.js
new file mode 100644
index 00000000000..9d37a9f84c8
--- /dev/null
+++ b/addons/web_editor/static/src/js/snippets.editor.js
@@ -0,0 +1,1137 @@
+odoo.define('web_editor.snippet.editor', function (require) {
+'use strict';
+
+var Class = require('web.Class');
+var ajax = require('web.ajax');
+var core = require('web.core');
+var Widget = require('web.Widget');
+var base = require('web_editor.base');
+var editor = require('web_editor.editor');
+var animation = require('web_editor.snippets.animation');
+var options = require('web_editor.snippets.options');
+
+var qweb = core.qweb;
+var dummy = editor.dummy;
+
+/* ----- SNIPPET SELECTOR ---- */
+
+ajax.loadXML('/web_editor/static/src/xml/snippets.xml', qweb);
+
+editor.Class.include({
+ init: function() {
+ var self = this;
+ var res = this._super.apply(this, arguments);
+ var $editable = this.rte.editable();
+ this.buildingBlock = new BuildingBlock(this, $editable);
+ this.buildingBlock.on("snippets:ready", this, function () {
+ self.trigger("snippets:ready");
+ });
+ return res;
+ },
+ start: function () {
+ var self = this;
+ animation.stop();
+ this.buildingBlock.insertAfter(this.$el);
+ animation.start(true);
+
+ this.rte.editable().find("*").off('mousedown mouseup click');
+ return this._super();
+ },
+ save: function () {
+ this.buildingBlock.clean_for_save();
+ this._super();
+ },
+});
+
+/* ----- SNIPPET SELECTOR ---- */
+
+$.extend($.expr[':'],{
+ hasData: function(node,i,m){
+ return !!_.toArray(node.dataset).length;
+ },
+ data: function(node,i,m){
+ return $(node).data(m[3]);
+ }
+});
+
+var globalSelector = {
+ closest: function () { return $(); },
+ all: function () { return $(); },
+ is: function () { return false; },
+};
+
+/* ----- Jquery activate block ---- */
+
+$.fn.extend({
+ activateBlock: function () {
+ var target = globalSelector.closest($(this))[0] || (dom.isBR(this) ? this.parentNode : dom.node(this));
+ 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);
+ return this;
+ }
+});
+
+/* ----- BuildingBlock (managment of drag&drop and focus/blur snippets) ---- */
+
+var BuildingBlock = Widget.extend({
+ template: 'web_editor.snippets',
+ activeSnippets: [],
+ init: function (parent, $editable) {
+ this.parent = parent;
+ this.$editable = $editable;
+
+ this._super.apply(this, arguments);
+ if(!$('#oe_manipulators').length){
+ $("<div id='oe_manipulators' class='o_css_editor'></div>").appendTo('body');
+ }
+ this.$active_snipped_id = false;
+ this.snippets = [];
+
+ data.instance = this;
+ },
+ start: function() {
+ var self = this;
+ this.$snippet = $("#oe_snippets");
+
+ this.$el
+ .on("mouseenter", function () { self.show(); })
+ .on("mouseleave", function (event) { if (event.clientX>0 && event.clientY>0) self.hide(); });
+
+ $(window).resize(function () {
+ setTimeout('$(document).click()',0);
+ });
+
+ this.fetch_snippet_templates();
+ this.bind_snippet_click_editor();
+
+ $(document).on('click', '.dropdown-submenu a[tabindex]', function (e) {
+ e.preventDefault();
+ });
+
+ var _isNotBreakable = $.summernote.core.dom.isNotBreakable;
+ $.summernote.core.dom.isNotBreakable = function (node) {
+ return _isNotBreakable(node) || $(node).is('div') || globalSelector.is($(node));
+ };
+
+ $(window).on('resize', function () {
+ if (self.$active_snipped_id && self.$active_snipped_id.data("snippet-editor")) {
+ self.cover_target(self.$active_snipped_id.data("snippet-editor").$overlay, self.$active_snipped_id);
+ }
+ });
+
+ $(document).on('mousemove', function () {
+ if (self.$active_snipped_id && self.$active_snipped_id.data("snippet-editor")) {
+ self.$active_snipped_id.data("snippet-editor").$overlay.removeClass('o_keypress');
+ }
+ });
+ $(document).on('keydown', function (event) {
+ if (self.$active_snipped_id && self.$active_snipped_id.data("snippet-editor")) {
+ self.$active_snipped_id.data("snippet-editor").$overlay.addClass('o_keypress');
+ }
+ if ((event.metaKey || (event.ctrlKey && !event.altKey)) && event.shiftKey && event.keyCode >= 48 && event.keyCode <= 57) {
+ self.$snippet.find('.scroll:first > ul li:eq('+(event.keyCode-49)+') a').trigger("click");
+ self.show();
+ event.preventDefault();
+ }
+ });
+ },
+
+ _get_snippet_url: function () {
+ return '/web_editor/snippets';
+ },
+ _add_check_selector : function (selector, no_check) {
+ var self = this;
+ var selector = selector.split(/\s*,/).join(":not(.o_snippet_not_selectable), ") + ":not(.o_snippet_not_selectable)";
+
+ if (no_check) {
+ return {
+ closest: function ($from, parentNode) {
+ return $from.closest(selector, parentNode);
+ },
+ all: function ($from) {
+ return $from ? $from.find(selector) : $(selector);
+ },
+ is: function ($from) {
+ return $from.is(selector);
+ }
+ };
+ } else {
+ var selector = selector.split(/\s*,/).join(":o_editable, ") + ":o_editable";
+ return {
+ closest: function ($from, parentNode) {
+ var parents = self.$editable.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;
+ });
+ },
+ all: function ($from) {
+ return $from ? $from.find(selector) : self.$editable.filter(selector).add(self.$editable.find(selector));
+ },
+ is: function ($from) {
+ return $from.is(selector);
+ }
+ };
+ }
+ },
+
+ fetch_snippet_templates: function () {
+ var self = this;
+ var url = this._get_snippet_url();
+ if (!url || !url.length) {
+ this.$snippet.detach();
+ return;
+ }
+ this.$el.find("#o_left_bar").addClass("hidden");
+ return ajax.jsonRpc(url, 'call', {}).then(function (html) {
+ self.compute_snippet_templates(html);
+ self.trigger("snippets:ready");
+ });
+ },
+ compute_snippet_templates: function (html) {
+ var self = this;
+ var $html = $(html);
+ var $left_bar = this.$el.find("#o_left_bar");
+ var $ul = $html.siblings("ul");
+ var $scroll = $html.siblings("#o_scroll");
+
+ if (!$scroll.length) {
+ throw new Error("Wrong snippets xml definition");
+ }
+
+ $ul.children().tooltip({
+ delay: { "show": 500, "hide": 100 },
+ container: 'body',
+ title: function () {
+ return (navigator.appVersion.indexOf('Mac') > -1 ? 'CMD' : 'CTRL')+'+SHIFT+'+($(this).index()+1);
+ },
+ trigger: 'hover',
+ placement: 'top'
+ }).on('click', function () {$(this).tooltip('hide');});
+
+ // t-snippet
+ $html.find('[data-oe-type="snippet"][data-oe-name]').each(function () {
+ var $div = $('<div/>').insertAfter(this).append(this).attr('name', $(this).data('oe-name'));
+ });
+ // end
+
+ self.templateOptions = [];
+ var selector = [];
+ var $styles = $html.find("[data-js], [data-selector]");
+ $styles.each(function () {
+ var $style = $(this);
+ var no_check = $style.data('no-check');
+ var option_id = $style.data('js');
+ var option = {
+ 'option' : option_id,
+ 'base_selector': $style.data('selector'),
+ 'selector': self._add_check_selector($style.data('selector'), no_check),
+ '$el': $style,
+ 'drop-near': $style.data('drop-near') && self._add_check_selector($style.data('drop-near'), no_check),
+ 'drop-in': $style.data('drop-in') && self._add_check_selector($style.data('drop-in'), no_check),
+ 'data': $style.data()
+ };
+ self.templateOptions.push(option);
+ selector.push(option.selector);
+ });
+
+ $styles.addClass("hidden");
+ globalSelector = {
+ closest: function ($from) {
+ var $temp;
+ var $target;
+ var len = selector.length;
+ for (var i = 0; i<len; i++) {
+ $temp = selector[i].closest($from, $target && $target[0]);
+ if (!$target || $temp.length) {
+ $target = $temp;
+ }
+ }
+ return $target;
+ },
+ all: function ($from) {
+ var $target;
+ var len = selector.length;
+ for (var i = 0; i<len; i++) {
+ if (!$target) $target = selector[i].all($from);
+ else $target = $target.add(selector[i].all($from));
+ }
+ return $target;
+ },
+ is: function ($from) {
+ var len = selector.length;
+ for (var i = 0; i<len; i++) {
+ if (selector[i].is($from)) {
+ return true;
+ }
+ }
+ return false;
+ },
+ };
+
+ var number = 0;
+
+ // oe_snippet_body
+ self.$snippets = $scroll.find(".o_panel_body").children()
+ .addClass("oe_snippet")
+ .each(function () {
+ if (!$('.oe_snippet_thumbnail', this).size()) {
+ var $div = $(
+ '<div class="oe_snippet_thumbnail">'+
+ '<div class="oe_snippet_thumbnail_img"/>'+
+ '<span class="oe_snippet_thumbnail_title"></span>'+
+ '</div>');
+ $div.find('span').text($(this).attr("name"));
+ $(this).prepend($div);
+
+ // from t-snippet
+ var thumbnail = $("[data-oe-thumbnail]", this).data("oe-thumbnail");
+ if (thumbnail) {
+ $div.find('.oe_snippet_thumbnail_img').css('background-image', 'url(' + thumbnail + ')');
+ }
+ // end
+ }
+ if (!$(this).data("selector")) {
+ $("> *:not(.oe_snippet_thumbnail)", this).addClass('oe_snippet_body');
+ }
+ number++;
+ });
+
+ // hide scroll if no snippets defined
+ if (!number) {
+ this.$snippet.detach();
+ } else {
+ this.$el.find("#o_left_bar").removeClass("hidden");
+ }
+ $("body").toggleClass("editor_has_snippets", !!number);
+
+ // select all default text to edit (if snippet default text)
+ /*self.$snippets.find('.oe_snippet_body, .oe_snippet_body *')
+ .contents()
+ .filter(function() {
+ return this.nodeType === 3 && this.textContent.match(/\S/);
+ }).parent().addClass("o_default_snippet_text");*/
+ $(document).on("mouseup", ".o_default_snippet_text", function (event) {
+ $(event.target).selectContent();
+ });
+ $(document).on("keyup", function (event) {
+ var r = $.summernote.core.range.create();
+ $(r && r.sc).closest(".o_default_snippet_text").removeClass("o_default_snippet_text");
+ });
+ // end
+
+ // clean t-oe
+ $html.find('[data-oe-model], [data-oe-type]').each(function () {
+ for (var k=0; k<this.attributes.length; k++) {
+ if (this.attributes[k].name.indexOf('data-oe-') === 0) {
+ $(this).removeAttr(this.attributes[k].name);
+ k--;
+ }
+ }
+ });
+ // end
+
+ $html.find('.o_not_editable').attr("contentEditable", false);
+
+ $left_bar.append($html);
+
+ // animate for list of snippet blocks
+ $left_bar.on('click', '.scroll-link', function (event) {
+ event.preventDefault();
+ var targetOffset = $($(this).attr("href")).position().top - $ul.outerHeight() + $scroll[0].scrollTop;
+ $scroll.animate({'scrollTop': targetOffset}, 750);
+ });
+ $scroll.on('scroll', function () {
+ var middle = $scroll.height()/4;
+ var $li = $ul.find("a").parent().removeClass('active');
+ var last;
+ for (var k=0; k<$li.length; k++) {
+ var li = $($li[k]);
+ if (!li.data('target')) {
+ li.data('target', $($("a", li).attr("href")));
+ }
+ if (li.data('target').position().top > middle) {
+ break;
+ }
+ last = $li[k];
+ }
+ $(last).addClass("active");
+ });
+ // end
+
+ // display scrollbar
+ $(window).on('resize', function () {
+ $scroll.css("overflow", "");
+ var height = $left_bar.height() - $ul.outerHeight();
+ $scroll.css("height", height);
+ var $last = $scroll.children(":visible").last().children(".o_panel_body");
+ $last.css({'min-height': (height-$last.prev().outerHeight())+'px'});
+ if ($scroll[0].scrollHeight + $ul[0].scrollHeight > document.body.clientHeight) {
+ $scroll.css("overflow", "auto").css("width", "226px");
+ } else {
+ $scroll.css("width", "");
+ }
+ }).trigger('resize');
+ // end
+
+ self.make_snippet_draggable(self.$snippets);
+ },
+
+ cover_target: function ($el, $target){
+ if($el.data('not-cover_target')) return;
+ var pos = $target.offset();
+ var mt = parseInt($target.css("margin-top") || 0);
+ var mb = parseInt($target.css("margin-bottom") || 0);
+ var width = $target.outerWidth();
+ var bigger = pos.left+width > $("body").outerWidth()-8;
+ $el.css({
+ 'width': width,
+ 'top': pos.top - mt - 5,
+ 'left': pos.left-1
+ });
+ $el.find(".oe_handle.e,.oe_handle.w").css({'height': $target.outerHeight() + mt + mb+1});
+ if (bigger) {
+ $el.find(".oe_handle.e").css({right: 0, margin: 0})
+ .find("div").css({right: 0, left: 'auto'});
+ } else {
+ $el.find(".oe_handle.e").css({right: "", margin: ""})
+ .find("div").css({right: "", left: ""});
+ }
+ $el.find(".oe_handle.s").css({'top': $target.outerHeight() + mt + mb});
+ $el.find(".oe_handle.size").css({'top': $target.outerHeight() + mt});
+ $el.find(".oe_handle.s,.oe_handle.n").css({'width': width-2});
+ },
+
+ show_blocks: function () {
+ var self = this;
+ var cache = {};
+ this.$snippet.find(".o_panel").each(function () {
+ var catcheck = false;
+ var $category = $(this);
+ $category.find(".oe_snippet_body").each(function () {
+ var $snippet = $(this);
+
+ var check = false;
+
+ for (var k in self.templateOptions) {
+ var option = self.templateOptions[k];
+ if ($snippet.is(option.base_selector)) {
+
+ cache[k] = cache[k] || {
+ 'drop-near': option['drop-near'] ? option['drop-near'].all() : [],
+ 'drop-in': option['drop-in'] ? option['drop-in'].all() : []
+ };
+
+ if (cache[k]['drop-near'].length || cache[k]['drop-in'].length) {
+ catcheck = true;
+ check = true;
+ break;
+ }
+ }
+ }
+
+ if (check) {
+ $snippet.closest(".oe_snippet").removeClass("disable");
+ } else {
+ $snippet.closest(".oe_snippet").addClass("disable");
+ }
+ });
+
+ $('#oe_snippets .scroll a[data-toggle="tab"][href="#' + $category.attr("id") + '"]').toggle(catcheck);
+ });
+ },
+ show: function () {
+ var self = this;
+ this.make_active(false);
+ this.$el.addClass("o_open");
+ this.show_blocks();
+ },
+ hide: function () {
+ this.$el.removeClass("o_open");
+ },
+ bind_snippet_click_editor: function () {
+ var self = this;
+ var snipped_event_flag;
+ $(document).on('click', '*', function (event) {
+ var srcElement = event.srcElement || (event.originalEvent && (event.originalEvent.originalTarget || event.originalEvent.target) || event.target);
+ if (self.editor_busy || snipped_event_flag===srcElement || !srcElement) {
+ return;
+ }
+ snipped_event_flag = srcElement;
+
+ setTimeout(function () {snipped_event_flag = false;}, 0);
+ var $target = $(srcElement);
+
+ if ($target.closest(".oe_overlay, .note-popover").length) {
+ return;
+ }
+
+ if (!globalSelector.is($target)) {
+ $target = globalSelector.closest($target);
+ }
+
+ if (self.$active_snipped_id && self.$active_snipped_id.is($target)) {
+ return;
+ }
+ self.make_active($target);
+ });
+ },
+ snippet_blur: function ($snippet) {
+ if ($snippet) {
+ if ($snippet.data("snippet-editor")) {
+ $snippet.data("snippet-editor").on_blur();
+ }
+ }
+ },
+ snippet_focus: function ($snippet) {
+ if ($snippet) {
+ if ($snippet.data("snippet-editor")) {
+ $snippet.data("snippet-editor").on_focus();
+ }
+ }
+ },
+ clean_for_save: function () {
+ var self = this;
+ var opt = options.registry;
+ var template = self.templateOptions;
+ for (var k in template) {
+ var Option = opt[template[k]['option']];
+ if (Option && Option.prototype.clean_for_save !== dummy) {
+ template[k].selector.all().filter(".o_dirty").each(function () {
+ new Option(self, null, $(this), k).clean_for_save();
+ });
+ }
+ }
+ this.$editable.find("*[contentEditable], *[attributeEditable]")
+ .removeAttr('contentEditable')
+ .removeProp('contentEditable')
+ .removeAttr('attributeEditable')
+ .removeProp('attributeEditable');
+ },
+ make_active: function ($snippet) {
+ if ($snippet && this.$active_snipped_id && this.$active_snipped_id.get(0) === $snippet.get(0)) {
+ return;
+ }
+ if (this.$active_snipped_id) {
+ this.snippet_blur(this.$active_snipped_id);
+ this.$active_snipped_id = false;
+ }
+ if ($snippet && $snippet.length) {
+ if(_.indexOf(this.snippets, $snippet.get(0)) === -1) {
+ this.snippets.push($snippet.get(0));
+ }
+ this.$active_snipped_id = $snippet;
+ this.create_overlay(this.$active_snipped_id);
+ this.snippet_focus($snippet);
+ }
+ this.$snippet.trigger('snippet-activated', $snippet);
+ if ($snippet) {
+ $snippet.trigger('snippet-activated', $snippet);
+ }
+ },
+ create_overlay: function ($snippet) {
+ if (typeof $snippet.data("snippet-editor") === 'undefined') {
+ var $targets = this.activate_overlay_zones($snippet);
+ if (!$targets.length) return;
+ $snippet.data("snippet-editor", new Editor(this, $snippet));
+ }
+ this.cover_target($snippet.data('overlay'), $snippet);
+ },
+
+ // activate drag and drop for the snippets in the snippet toolbar
+ make_snippet_draggable: function($snippets){
+ var self = this;
+ var $tumb = $snippets.find(".oe_snippet_thumbnail_img:first");
+ var left = $tumb.outerWidth()/2;
+ var top = $tumb.outerHeight()/2;
+ var $toInsert, dropped, $snippet, action, snipped_id;
+
+ $snippets.draggable({
+ greedy: true,
+ helper: 'clone',
+ zIndex: '1000',
+ appendTo: 'body',
+ cursor: "move",
+ handle: ".oe_snippet_thumbnail",
+ cursorAt: {
+ 'left': left,
+ 'top': top
+ },
+ start: function(){
+ self.hide();
+ dropped = false;
+ // snippet_selectors => to get drop-near, drop-in
+ $snippet = $(this);
+ var $base_body = $snippet.find('.oe_snippet_body');
+ var $selector_siblings = $();
+ var $selector_children = $();
+ var vertical = false;
+ var temp = self.templateOptions;
+ for (var k in temp) {
+ if ($base_body.is(temp[k].base_selector)) {
+ if (temp[k]['drop-near']) {
+ if (!$selector_siblings) $selector_siblings = temp[k]['drop-near'].all();
+ else $selector_siblings = $selector_siblings.add(temp[k]['drop-near'].all());
+ }
+ if (temp[k]['drop-in']) {
+ if (!$selector_children) $selector_children = temp[k]['drop-in'].all();
+ else $selector_children = $selector_children.add(temp[k]['drop-in'].all());
+ }
+ }
+ }
+
+ $toInsert = $base_body.clone();
+
+ if (!$selector_siblings.length && !$selector_children.length) {
+ console.debug($snippet.find(".oe_snippet_thumbnail_title").text() + " have not insert action: data-drop-near or data-drop-in");
+ return;
+ }
+
+ self.activate_insertion_zones($selector_siblings, $selector_children);
+
+ $('.oe_drop_zone').droppable({
+ over: function(){
+ dropped = true;
+ $(this).first().after($toInsert);
+ },
+ out: function(){
+ var prev = $toInsert.prev();
+ if(this === prev[0]){
+ dropped = false;
+ $toInsert.detach();
+ }
+ }
+ });
+ },
+ stop: function(ev, ui){
+ $toInsert.removeClass('oe_snippet_body');
+
+ if (! dropped && self.$editable.find('.oe_drop_zone') && ui.position.top > 3) {
+ var el = self.$editable.find('.oe_drop_zone').nearest({x: ui.position.left, y: ui.position.top}).first();
+ if (el.length) {
+ el.after($toInsert);
+ dropped = true;
+ }
+ }
+
+ self.$editable.find('.oe_drop_zone').droppable('destroy').remove();
+
+ if (dropped) {
+
+ var prev = $toInsert.first()[0].previousSibling;
+ var next = $toInsert.last()[0].nextSibling;
+ var rte = self.parent.rte;
+
+ if (prev) {
+ $toInsert.detach();
+ rte.historyRecordUndo($(prev));
+ $toInsert.insertAfter(prev);
+ } else if (next) {
+ $toInsert.detach();
+ rte.historyRecordUndo($(next));
+ $toInsert.insertBefore(next);
+ } else {
+ var $parent = $toInsert.parent();
+ $toInsert.detach();
+ rte.historyRecordUndo($parent);
+ $parent.prepend($toInsert);
+ }
+
+ $toInsert.closest(".o_editable").trigger("content_changed");
+
+ var $target = false;
+ $target = $toInsert;
+
+ setTimeout(function () {
+ self.$snippet.trigger('snippet-dropped', $target);
+
+ animation.start(true, $target);
+
+ // drop_and_build_snippet
+ self.create_overlay($target);
+ if ($target.data("snippet-editor")) {
+ $target.data("snippet-editor").drop_and_build_snippet();
+ }
+ for (var k in self.templateOptions) {
+ self.templateOptions[k].selector.all($target).each(function () {
+ var $snippet = $(this);
+ self.create_overlay($snippet);
+ if ($snippet.data("snippet-editor")) {
+ $snippet.data("snippet-editor").drop_and_build_snippet();
+ }
+ });
+ }
+ $target.closest(".o_editable").trigger("content_changed");
+ // end
+
+ self.make_active($target);
+ },0);
+ } else {
+ $toInsert.remove();
+ }
+ },
+ });
+ },
+
+ // return the original snippet in the editor bar from a snippet id (string)
+ get_snippet_from_id: function(id){
+ return $('.oe_snippet').filter(function(){
+ return $(this).data('option') === id;
+ }).first();
+ },
+
+ // Create element insertion drop zones. two css selectors can be provided
+ // selector.children -> will insert drop zones as direct child of the selected elements
+ // in case the selected elements have children themselves, dropzones will be interleaved
+ // with them.
+ // selector.siblings -> will insert drop zones after and before selected elements
+ activate_insertion_zones: function($selector_siblings, $selector_children){
+ var self = this;
+ var zone_template = $("<div class='oe_drop_zone oe_insert'></div>");
+
+ if ($selector_children) {
+ $selector_children.each(function (){
+ var $zone = $(this);
+ var css = window.getComputedStyle(this);
+ var float = css.float || css.cssFloat;
+ var $drop = zone_template.clone();
+
+ $zone.append($drop);
+ var node = $drop[0].previousSibling;
+ var test = !!(node && ((!node.tagName && node.textContent.match(/\S/)) || node.tagName === "BR"));
+ if (test) {
+ $drop.addClass("oe_vertical oe_vertical_text").css({
+ 'height': parseInt(window.getComputedStyle($zone[0]).lineHeight),
+ 'float': 'none',
+ 'display': 'inline-block'
+ });
+ } else if (float === "left" || float === "right") {
+ $drop.addClass("oe_vertical").css('height', Math.max(Math.min($zone.outerHeight(), $zone.children().last().outerHeight()), 30));
+ }
+
+ $drop = $drop.clone();
+
+ $zone.prepend($drop);
+ var node = $drop[0].nextSibling;
+ var test = !!(node && ((!node.tagName && node.textContent.match(/\S/)) || node.tagName === "BR"));
+ if (test) {
+ $drop.addClass("oe_vertical oe_vertical_text").css({
+ 'height': parseInt(window.getComputedStyle($zone[0]).lineHeight),
+ 'float': 'none',
+ 'display': 'inline-block'
+ });
+ } else if (float === "left" || float === "right") {
+ $drop.addClass("oe_vertical").css('height', Math.max(Math.min($zone.outerHeight(), $zone.children().first().outerHeight()), 30));
+ }
+ if (test) {
+ $drop.css({'float': 'none', 'display': 'inline-block'});
+ }
+ });
+
+ // add children near drop zone
+ $selector_siblings = $(_.uniq(($selector_siblings || $()).add($selector_children.children()).get()));
+ }
+
+ if ($selector_siblings) {
+ $selector_siblings.filter(':not(.oe_drop_zone):not(.oe_drop_clone)').each(function (){
+ var $zone = $(this);
+ var $drop;
+ var css = window.getComputedStyle(this);
+ var float = css.float || css.cssFloat;
+
+ if($zone.prev('.oe_drop_zone:visible').length === 0){
+ $drop = zone_template.clone();
+ if (float === "left" || float === "right") {
+ $drop.addClass("oe_vertical").css('height', Math.max(Math.min($zone.outerHeight(), $zone.prev().outerHeight() || Infinity), 30));
+ }
+ $zone.before($drop);
+ }
+ if($zone.next('.oe_drop_zone:visible').length === 0){
+ $drop = zone_template.clone();
+ if (float === "left" || float === "right") {
+ $drop.addClass("oe_vertical").css('height', Math.max(Math.min($zone.outerHeight(), $zone.next().outerHeight() || Infinity), 30));
+ }
+ $zone.after($drop);
+ }
+ });
+ }
+
+ var count;
+ do {
+ count = 0;
+ $zones = self.$editable.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.
+ var $zones = self.$editable.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 float_prev = prev.css('float') || 'none';
+ var float_next = next.css('float') || 'none';
+ var disp_prev = prev.css('display') || null;
+ var disp_next = next.css('display') || null;
+ if( (float_prev === 'left' || float_prev === 'right')
+ && (float_next === 'left' || float_next === 'right') ){
+ zone.remove();
+ }else if( !( disp_prev === null
+ || disp_next === null
+ || disp_prev === 'block'
+ || disp_next === 'block' )){
+ zone.remove();
+ }
+ });
+ },
+
+ // generate drop zones covering the elements selected by the selector
+ // we generate overlay drop zones only to get an idea of where the snippet are, the drop
+ activate_overlay_zones: function($targets){
+ var self = this;
+
+ function is_visible($el){
+ return $el.css('display') != 'none'
+ && $el.css('opacity') != '0'
+ && $el.css('visibility') != 'hidden';
+ }
+
+ // filter out invisible elements
+ $targets = $targets.filter(function(){ return is_visible($(this)); });
+
+ // filter out elements with invisible parents
+ $targets = $targets.filter(function(){
+ var parents = $(this).parents().filter(function(){ return !is_visible($(this)); });
+ return parents.length === 0;
+ });
+
+ $targets.each(function () {
+ var $target = $(this);
+ if (!$target.data('overlay')) {
+ var $zone = $(qweb.render('web_editor.snippet_overlay'));
+
+ // fix for pointer-events: none with ie9
+ if (document.body && document.body.addEventListener) {
+ $zone.on("click mousedown mousedown", function passThrough(event) {
+ event.preventDefault();
+ $target.each(function() {
+ // check if clicked point (taken from event) is inside element
+ event.srcElement = this;
+ $(this).trigger(event.type);
+ });
+ return false;
+ });
+ }
+
+ $zone.appendTo('#oe_manipulators');
+ $zone.data('target',$target);
+ $target.data('overlay',$zone);
+
+ var timer;
+ $target.closest('.o_editable').on("content_changed", function (event) {
+ if ($target.data('overlay') && $target.data('overlay').hasClass("oe_active")) {
+ clearTimeout(timer);
+ timer = setTimeout(function () {
+ if ($target.data('overlay')) {
+ self.cover_target($target.data('overlay'), $target);
+ }
+ },50);
+ }
+ });
+
+ var resize = function () {
+ if ($zone.parent().length) {
+ self.cover_target($zone, $target);
+ } else {
+ $('body').off("resize", resize);
+ }
+ };
+ $('body').on("resize", resize);
+ }
+ self.cover_target($target.data('overlay'), $target);
+ });
+ return $targets;
+ }
+});
+
+/* ----- Editor (object who contain overlay and the option list) ---- */
+
+var Editor = Class.extend({
+ init: function (BuildingBlock, dom) {
+ this.buildingBlock = BuildingBlock;
+ this.$target = $(dom);
+ this.$overlay = this.$target.data('overlay');
+ this.load_style_options();
+ this.get_parent_block();
+ this.start();
+ },
+
+ // activate drag and drop for the snippets in the snippet toolbar
+ _drag_and_drop: function(){
+ var self = this;
+ this.dropped = false;
+ this.$overlay.draggable({
+ greedy: true,
+ appendTo: 'body',
+ cursor: "move",
+ handle: ".oe_snippet_move",
+ cursorAt: {
+ left: 18,
+ top: 14
+ },
+ helper: function() {
+ var $clone = $(this).clone().css({width: "24px", height: "24px", border: 0});
+ $clone.find(".oe_overlay_options >:not(:contains(.oe_snippet_move)), .oe_handle").remove();
+ $clone.find(":not(.glyphicon)").css({position: 'absolute', top: 0, left: 0});
+ $clone.appendTo("body").removeClass("hidden");
+ return $clone;
+ },
+ start: _.bind(self._drag_and_drop_start, self),
+ stop: _.bind(self._drag_and_drop_stop, self)
+ });
+ },
+ _drag_and_drop_after_insert_dropzone: function (){},
+ _drag_and_drop_active_drop_zone: function ($zones){
+ var self = this;
+ $zones.droppable({
+ over: function(){
+ $(".oe_drop_zone.hide").removeClass("hide");
+ $(this).addClass("hide").first().after(self.$target);
+ self.dropped = true;
+ },
+ out: function(){
+ $(this).removeClass("hide");
+ self.$target.detach();
+ self.dropped = false;
+ },
+ });
+ },
+ _drag_and_drop_start: function (){
+ var self = this;
+ self.buildingBlock.hide();
+ self.buildingBlock.editor_busy = true;
+ 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.$overlay.addClass("hidden");
+
+ var $selector_siblings;
+ for (var i=0; i<self.selector_siblings.length; i++) {
+ if (!$selector_siblings) $selector_siblings = self.selector_siblings[i].all();
+ else $selector_siblings = $selector_siblings.add(self.selector_siblings[i].all());
+ }
+ var $selector_children;
+ for (var i=0; i<self.selector_children.length; i++) {
+ if (!$selector_children) $selector_children = self.selector_children[i].all();
+ else $selector_children = $selector_children.add(self.selector_children[i].all());
+ }
+
+ self.buildingBlock.activate_insertion_zones($selector_siblings, $selector_children);
+
+ $("body").addClass('move-important');
+
+ self._drag_and_drop_after_insert_dropzone();
+ self._drag_and_drop_active_drop_zone($('.oe_drop_zone'));
+ },
+ _drag_and_drop_stop: function (){
+ var self = this;
+ var $dropzone = this.$target.prev();
+ var prev = $dropzone.length && $dropzone[0].previousSibling;
+ var next = this.$target.last()[0].nextSibling;
+ var $parent = this.$target.parent();
+
+ $(".oe_drop_clone").after(this.$target);
+
+ this.$overlay.removeClass("hidden");
+ $("body").removeClass('move-important');
+ $('.oe_drop_zone').droppable('destroy').remove();
+ $(".oe_drop_clone, .oe_drop_to_remove").remove();
+
+ if (this.dropped) {
+ this.buildingBlock.parent.rte.historyRecordUndo(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].on_move();
+ }
+ }
+
+ self.buildingBlock.editor_busy = false;
+
+ self.get_parent_block();
+ setTimeout(function () {self.buildingBlock.create_overlay(self.$target);},0);
+ },
+
+ load_style_options: function () {
+ var self = this;
+ var $styles = this.$overlay.find('.oe_options');
+ var $ul = $styles.find('ul:first');
+ this.styles = {};
+ this.selector_siblings = [];
+ this.selector_children = [];
+ _.each(this.buildingBlock.templateOptions, function (val, option_id) {
+ if (!val.selector.is(self.$target)) {
+ return;
+ }
+ if (val['drop-near']) self.selector_siblings.push(val['drop-near']);
+ if (val['drop-in']) self.selector_children.push(val['drop-in']);
+
+ var option = val['option'];
+ var Editor = options.registry[option] || options.Class;
+ var editor = self.styles[option] = new Editor(self.buildingBlock, self, self.$target, option_id);
+ $ul.append(editor.$el.addClass("snippet-option-" + option));
+ editor.start();
+ });
+
+ if (!this.selector_siblings.length && !this.selector_children.length) {
+ this.$overlay.find(".oe_snippet_move, .oe_snippet_clone, .oe_snippet_remove").addClass('hidden');
+ }
+
+ if ($ul.find("li").length) {
+ $styles.removeClass("hidden");
+ }
+ this.$overlay.find('[data-toggle="dropdown"]').dropdown();
+ },
+
+ get_parent_block: function () {
+ var self = this;
+ var $button = this.$overlay.find('.oe_snippet_parent');
+ var $parent = globalSelector.closest(this.$target.parent());
+ if ($parent.length) {
+ $button.removeClass("hidden");
+ $button.off("click").on('click', function (event) {
+ event.preventDefault();
+ setTimeout(function () {
+ self.buildingBlock.make_active($parent);
+ }, 0);
+ });
+ } else {
+ $button.addClass("hidden");
+ }
+ },
+
+ /*
+ * start
+ * This method is called after init and _readXMLData
+ */
+ start: function () {
+ var self = this;
+ if (!this.$target.parent().is(':o_editable')) {
+ this.$overlay.find('.oe_snippet_move, .oe_snippet_clone, .oe_snippet_remove').remove();
+ } else {
+ this.$overlay.on('click', '.oe_snippet_clone', _.bind(this.on_clone, this));
+ this.$overlay.on('click', '.oe_snippet_remove', _.bind(this.on_remove, this));
+ this._drag_and_drop();
+ }
+ },
+
+ on_clone: function (event) {
+ event.preventDefault();
+ var $clone = this.$target.clone(false);
+
+ this.buildingBlock.parent.rte.historyRecordUndo(this.$target);
+
+ this.$target.after($clone);
+ for (var i in this.styles){
+ this.styles[i].on_clone($clone);
+ }
+ this.buildingBlock.create_overlay(this.$target);
+ return false;
+ },
+
+ on_remove: function (event) {
+ event.preventDefault();
+ this.on_blur();
+
+ this.buildingBlock.parent.rte.historyRecordUndo(this.$target);
+
+ var index = _.indexOf(this.buildingBlock.snippets, this.$target.get(0));
+ for (var i in this.styles){
+ this.styles[i].on_remove();
+ }
+ delete this.buildingBlock.snippets[index];
+
+ var $editable = this.$target.closest(".o_editable");
+
+ // remove node and his empty
+ var node = this.$target.parent()[0];
+
+ this.$target.remove();
+ this.$overlay.remove();
+
+ if (node && node.firstChild) {
+ $.summernote.core.dom.removeSpace(node, node.firstChild, 0, node.lastChild, 1);
+ if (!node.firstChild.tagName && node.firstChild.textContent === " ") {
+ node.firstChild.parentNode.removeChild(node.firstChild);
+ }
+ }
+
+ // clean editor if they are image or table in deleted content
+ $(".note-control-selection").hide();
+ $('.o_table_handler').remove();
+
+ return false;
+ },
+
+ /*
+ * drop_and_build_snippet
+ * This method is called just after that a thumbnail is drag and dropped into a drop zone
+ * (after the insertion of this.$body, if this.$body exists)
+ */
+ drop_and_build_snippet: function () {
+ for (var i in this.styles){
+ this.styles[i].drop_and_build_snippet();
+ }
+ },
+
+ /* on_focus
+ * This method is called when the user click inside the snippet in the dom
+ */
+ on_focus : function () {
+ this.$overlay.addClass('oe_active');
+ for (var i in this.styles){
+ this.styles[i].on_focus();
+ }
+ },
+
+ /* on_focus
+ * This method is called when the user click outside the snippet in the dom, after a focus
+ */
+ on_blur : function () {
+ for (var i in this.styles){
+ this.styles[i].on_blur();
+ }
+ this.$overlay.removeClass('oe_active');
+ },
+});
+
+var data = {
+ 'Class': BuildingBlock,
+ 'globalSelector': globalSelector,
+};
+return data;
+
+});
diff --git a/addons/web_editor/static/src/js/snippets.options.js b/addons/web_editor/static/src/js/snippets.options.js
new file mode 100644
index 00000000000..a46c00baa25
--- /dev/null
+++ b/addons/web_editor/static/src/js/snippets.options.js
@@ -0,0 +1,534 @@
+odoo.define('web_editor.snippets.options', function (require) {
+'use strict';
+
+var Class = require('web.Class');
+var ajax = require('web.ajax');
+var core = require('web.core');
+var base = require('web_editor.base');
+var editor = require('web_editor.editor');
+var widget = require('web_editor.widget');
+var animation = require('web_editor.snippets.animation');
+
+var qweb = core.qweb;
+
+/* ----- Editor option (object link the the xml with data-js) ---- */
+
+var Option = Class.extend({
+ // initialisation (don't overwrite)
+ init: function (BuildingBlock, editor, $target, option_id) {
+ this.buildingBlock = BuildingBlock;
+ this.editor = editor;
+ this.$target = $target;
+ var option = this.buildingBlock.templateOptions[option_id];
+ var styles = this.$target.data("snippet-option-ids") || {};
+ styles[option_id] = this;
+ this.$target.data("snippet-option-ids", styles);
+ this.$overlay = this.$target.data('overlay') || $('<div>');
+ this.option= option_id;
+ this.$el = option.$el.find(">li").clone();
+ this.data = option.$el.data();
+ },
+
+ // helper for this.$target.find
+ $: function (selector) {
+ return this.$target(selector);
+ },
+
+ _bind_li_menu: function () {
+ this.$el.filter("li:hasData").find('a:first')
+ .off('mouseenter click')
+ .on('mouseenter click', _.bind(this._mouse, this));
+
+ this.$el
+ .off('mouseenter click', "li:hasData a")
+ .on('mouseenter click', "li:hasData a", _.bind(this._mouse, this));
+
+ this.$el.closest("ul").add(this.$el)
+ .off('mouseleave')
+ .on('mouseleave', _.bind(this.reset, this));
+
+ this.$el
+ .off('mouseleave', "ul")
+ .on('mouseleave', "ul", _.bind(this.reset, this));
+
+ this.reset_methods = [];
+ },
+
+ /**
+ * this method handles mouse:over and mouse:leave on the snippet editor menu
+ */
+ _time_mouseleave: null,
+ _mouse: function (event) {
+ var $next = $(event.currentTarget).parent();
+
+ // triggers preview or apply methods if a menu item has been clicked
+ this.select(event.type === "click" ? "click" : "over", $next);
+ if (event.type === 'click') {
+ this.set_active();
+ this.$target.trigger("snippet-option-change", [this]);
+ } else {
+ this.$target.trigger("snippet-option-preview", [this]);
+ }
+ },
+ /*
+ * select and set item active or not (add highlight item and his parents)
+ * called before start
+ */
+ set_active: function () {
+ var classes = _.uniq((this.$target.attr("class") || '').split(/\s+/));
+ this.$el.find('[data-toggle_class], [data-select_class]')
+ .add(this.$el)
+ .filter('[data-toggle_class], [data-select_class]')
+ .removeClass("active")
+ .filter('[data-toggle_class="' + classes.join('"], [data-toggle_class="') + '"] ,'+
+ '[data-select_class="' + classes.join('"], [data-select_class="') + '"]')
+ .addClass("active");
+ },
+
+ start: function () {
+ this.set_active();
+ this.$target.on('snippet-option-reset', _.bind(this.set_active, this));
+ this._bind_li_menu();
+ },
+
+ on_focus : function () {
+ this._bind_li_menu();
+ },
+
+ on_blur : function () {
+ },
+
+ on_clone: function ($clone) {
+ },
+
+ on_move: function () {
+ },
+
+ on_remove: function () {
+ },
+
+ drop_and_build_snippet: function () {
+ },
+
+ reset: function () {
+ var self = this;
+ var lis = self.$el.add(self.$el.find('li')).filter('.active').get();
+ lis.reverse();
+ _.each(lis, function (li) {
+ var $li = $(li);
+ for (var k in self.reset_methods) {
+ var method = self.reset_methods[k];
+ if ($li.is('[data-'+method+']') || $li.closest('[data-'+method+']').size()) {
+ delete self.reset_methods[k];
+ }
+ }
+ self.select("reset", $li);
+ });
+
+ for (var k in self.reset_methods) {
+ var method = self.reset_methods[k];
+ if (method) {
+ self[method]("reset", null);
+ }
+ }
+ self.reset_methods = [];
+ self.$target.trigger("snippet-option-reset", [this]);
+ },
+
+ // call data-method args as method (data-only can be used)
+ select: function (type, $li) {
+ var self = this,
+ $methods = [],
+ el = $li[0];
+
+ if ($li.data('only') && type !== $li.data('only')) {
+ return;
+ }
+
+ if (type==="click") {
+ this.reset();
+ this.buildingBlock.parent.rte.historyRecordUndo(this.$target);
+ }
+
+ function filter (k) { return k !== 'oeId' && k !== 'oeModel' && k !== 'oeField' && k !== 'oeXpath' && k !== 'oeSourceId' && k !== 'only';}
+ function hasData(el) {
+ for (var k in el.dataset) {
+ if (filter (k)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ function method(el) {
+ var data = {};
+ for (var k in el.dataset) {
+ if (filter (k)) {
+ data[k] = el.dataset[k];
+ }
+ }
+ return data;
+ }
+
+ while (el && this.$el.is(el) || _.some(this.$el.map(function () {return $.contains(this, el);}).get()) ) {
+ if (hasData(el)) {
+ $methods.push(el);
+ }
+ el = el.parentNode;
+ }
+
+ $methods.reverse();
+
+ _.each($methods, function (el) {
+ var $el = $(el);
+ var methods = method(el);
+
+ for (var k in methods) {
+ if (self[k]) {
+ if (type !== "reset" && self.reset_methods.indexOf(k) === -1) {
+ self.reset_methods.push(k);
+ }
+ self[k](type, methods[k], $el);
+ } else {
+ console.error("'"+self.option+"' snippet have not method '"+k+"'");
+ }
+ }
+ });
+ },
+
+ // default method for snippet
+ toggle_class: function (type, value, $li) {
+ var $lis = this.$el.find('[data-toggle_class]').add(this.$el).filter('[data-toggle_class]');
+
+ function map ($lis) {
+ return $lis.map(function () {return $(this).data("toggle_class");}).get().join(" ");
+ }
+ var classes = map($lis);
+ var active_classes = map($lis.filter('.active, :has(.active)'));
+
+ this.$target.removeClass(classes);
+ this.$target.addClass(active_classes);
+
+ if (type !== 'reset') {
+ this.$target.toggleClass(value);
+ }
+ },
+ select_class: function (type, value, $li) {
+ var $lis = this.$el.find('[data-select_class]').add(this.$el).filter('[data-select_class]');
+
+ var classes = $lis.map(function () {return $(this).data('select_class');}).get();
+
+ this.$target.removeClass(classes.join(" "));
+ if(value) this.$target.addClass(value);
+ },
+ eval: function (type, value, $li) {
+ var fn = new Function("node", "type", "value", "$li", value);
+ fn.call(this, this, type, value, $li);
+ },
+
+ clean_for_save: editor.dummy
+});
+
+/* ----- default options ---- */
+
+var media = Option.extend({
+ start: function () {
+ var self =this;
+ this._super();
+ animation.start(true, this.$target);
+ this.$target.closest(".o_editable").on("content_changed", function () {
+ if (!self.$target.parent().length) {
+ self.$target.remove();
+ self.$overlay.remove();
+ }
+ });
+ },
+ edition: function (type, value) {
+ if(type !== "click") return;
+ var self = this;
+ var _editor = new widget.MediaDialog(this.$target.closest('.o_editable'), this.$target[0]);
+ _editor.appendTo(document.body);
+ _editor.on('saved', this, function (item, old) {
+ self.editor.on_blur();
+ self.buildingBlock.make_active(false);
+ if (self.$target.parent().data("oe-field") !== "image") {
+ setTimeout(function () {
+ self.buildingBlock.make_active($(item));
+ },0);
+ }
+ });
+ },
+ on_focus : function () {
+ var self = this;
+ var $parent = this.$target.parent();
+
+ if ($parent.data("oe-field") === "image" && $parent.hasClass('o_editable')) {
+ this.$overlay.addClass("hidden");
+ self.edition('click', null);
+ self.buildingBlock.make_active(false);
+ }
+ }
+});
+
+var colorpicker = Option.extend({
+ start: function () {
+ var self = this;
+ var res = this._super();
+
+ if (!this.$el.find('.colorpicker').length) {
+ this.$el.find('li').append( qweb.render('web_editor.colorpicker') );
+ }
+ if (this.$el.data('area')) {
+ this.$target = this.$target.find(this.$el.data('area'));
+ this.$el.removeData('area').removeAttr('area');
+ }
+
+ var classes = [];
+ this.$el.find(".colorpicker button").map(function () {
+ var $color = $(this);
+ var color = $color.attr("class");
+ if (self.$target.hasClass(color)) {
+ self.color = color;
+ $color.parent().addClass("selected");
+ }
+ classes.push(color);
+ });
+ this.classes = classes.join(" ");
+
+ this.bind_events();
+ return res;
+ },
+ bind_events: function () {
+ var self = this;
+ var $td = this.$el.find(".colorpicker td");
+ var $colors = this.$el.find(".colorpicker button");
+ $colors
+ .mouseenter(function () {
+ self.$target.removeClass(self.classes).addClass($(this).attr("class"));
+ })
+ .mouseleave(function () {
+ self.$target.removeClass(self.classes)
+ .addClass($td.filter(".selected").children().attr("class"));
+ })
+ .click(function () {
+ $td.removeClass("selected");
+ $(this).parent().addClass("selected");
+ });
+ }
+});
+
+var background = Option.extend({
+ start: function ($change_target) {
+ this.$bg = $change_target || this.$target;
+ this._super();
+ var src = this.$bg.css("background-image").replace(/url\(['"]*|['"]*\)|^none$/g, "");
+ if (this.$bg.hasClass('oe_custom_bg')) {
+ this.$el.find('li[data-choose_image]').data("background", src).attr("data-background", src);
+ }
+ },
+ background: function(type, value, $li) {
+ if (value && value.length) {
+ this.$bg.attr("style", 'background-image: url(' + value + ')' + (this.$bg.attr("style") || '').replace(/background-image:[^;]+/, '') );
+ this.$bg.addClass("oe_img_bg");
+ } else {
+ this.$bg.css("background-image", "");
+ this.$bg.removeClass("oe_img_bg").removeClass("oe_custom_bg");
+ }
+ },
+ choose_image: function(type, value, $li) {
+ if(type !== "click") return;
+
+ var self = this;
+ var $image = $('<img class="hidden"/>');
+ $image.attr("src", value);
+ $image.appendTo(self.$bg);
+
+ var _editor = new widget.MediaDialog(null, $image[0]);
+ _editor.appendTo(document.body);
+ _editor.$('[href="#editor-media-video"], [href="#editor-media-icon"]').addClass('hidden');
+
+ _editor.on('saved', self, function () {
+ var value = $image.attr("src");
+ $image.remove();
+ self.$el.find('li[data-choose_image]').data("background", value).attr("data-background", value);
+ self.background(type, value,$li);
+ self.$bg.addClass('oe_custom_bg');
+ self.$bg.trigger("snippet-option-change", [self]);
+ self.set_active();
+ });
+ _editor.on('cancel', self, function () {
+ $image.remove();
+ });
+ },
+ set_active: function () {
+ var self = this;
+ var src = this.$bg.css("background-image").replace(/url\(['"]*|['"]*\)|^none$/g, "");
+ this._super();
+
+ this.$el.find('li[data-background]:not([data-background=""])')
+ .removeClass("active")
+ .each(function () {
+ var background = $(this).data("background") || $(this).attr("data-background");
+ if ((src.length && background.length && src.indexOf(background) !== -1) || (!src.length && !background.length)) {
+ $(this).addClass("active");
+ }
+ });
+
+ if (!this.$el.find('li[data-background].active').size()) {
+ this.$el.find('li[data-background=""]:not([data-choose_image])').addClass("active");
+ } else {
+ this.$el.find('li[data-background=""]:not([data-choose_image])').removeClass("active");
+ }
+ }
+});
+
+/* t-field options */
+
+var many2one = Option.extend({
+ start: function () {
+ var self = this;
+
+ 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"))
+ .insertAfter(this.$overlay.find('.oe_options'));
+
+ 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 () {
+ if (self.$overlay.find('.oe_options').hasClass('hidden')) {
+ self.$btn.css('height', '0').find('> a').addClass('hidden');
+ self.$ul.show().css({
+ 'top': '-24px', 'margin': '0', 'padding': '2px 0', 'position': 'relative'
+ });
+ } else {
+ self.$btn.find('a').on('click', function (e) {
+ self.clear();
+ });
+ }
+ },0);
+
+ // bind search input
+ this.$search.find('input')
+ .focus()
+ .on('keyup', function(e) {
+ self.find_existing($(this).val());
+ });
+
+ // bind result
+ this.$ul.on('click', "li:not(:first) a", function (e) {
+ self.select_record(this);
+ });
+ },
+
+ on_focus: function () {
+ this.$target.attr('contentEditable', 'false');
+ this.clear();
+ this._super();
+ },
+
+ clear: function () {
+ var self = this;
+ this.$search.siblings().remove();
+ self.$search.find('input').val("");
+ setTimeout(function () {
+ self.$search.find('input').focus();
+ },0);
+ },
+
+ find_existing: 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]);
+ }
+
+ ajax.jsonRpc('/web/dataset/call_kw', 'call', {
+ model: this.Model,
+ method: 'search_read',
+ args: [domain, this.Model === "res.partner" ? ['name', 'display_name', 'city', 'country_id'] : ['name', 'display_name']],
+ kwargs: {
+ order: 'name DESC',
+ limit: 5,
+ context: base.get_context(),
+ }
+ }).then(function (result){
+ self.$search.siblings().remove();
+ self.$search.after(qweb.render("web_editor.many2one.search",{contacts:result}));
+ });
+ },
+
+ get_contact_rendering: function (options) {
+ return ajax.jsonRpc('/web/dataset/call_kw', 'call', {
+ model: 'ir.qweb.field.contact',
+ method: 'get_record_to_html',
+ args: [[this.ID]],
+ kwargs: {
+ options: options,
+ context: base.get_context(),
+ }
+ });
+ },
+
+ select_record: 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.buildingBlock.parent.rte.historyRecordUndo(this.$target);
+
+ 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);
+ self.get_contact_rendering($node.data('oe-contact-options'))
+ .then(function (html){
+ $node.html(html);
+ });
+ });
+ } else {
+ self.$target.html($(li).data("name"));
+ }
+
+ setTimeout(function () {
+ self.buildingBlock.make_active(false);
+ },0);
+ }
+});
+
+/* end*/
+
+return {
+ 'registry': {
+ 'media': media,
+ 'colorpicker': colorpicker,
+ 'background': background,
+ 'many2one': many2one,
+ },
+ 'Class': Option
+};
+
+});
diff --git a/addons/web_editor/static/src/js/summernote.js b/addons/web_editor/static/src/js/summernote.js
new file mode 100644
index 00000000000..56dd3249a6c
--- /dev/null
+++ b/addons/web_editor/static/src/js/summernote.js
@@ -0,0 +1,2192 @@
+odoo.define('web_editor.summernote', function (require) {
+'use strict';
+
+var core = require('web.core');
+require('summernote/summernote'); // wait that summernote is loaded
+
+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.editor;
+var renderer = $.summernote.renderer;
+var options = $.summernote.options;
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+/* Add method to Summernote*/
+
+dom.hasContentAfter = function (node) {
+ var next;
+ while (node.nextSibling) {
+ next = node.nextSibling;
+ if (next.tagName || next.textContent.match(/\S|\u00A0/) || dom.isBR(next)) return next;
+ node = next;
+ }
+};
+dom.hasContentBefore = function (node) {
+ var prev;
+ while (node.previousSibling) {
+ prev = node.previousSibling;
+ if (prev.tagName || prev.textContent.match(/\S|\u00A0/) || dom.isBR(prev)) return prev;
+ node = prev;
+ }
+};
+dom.ancestorHaveNextSibling = function (node, pred) {
+ pred = pred || dom.hasContentAfter;
+ while (!node.nextSibling || !pred(node)) { node = node.parentNode; }
+ return node;
+};
+dom.ancestorHavePreviousSibling = function (node, pred) {
+ pred = pred || dom.hasContentBefore;
+ while (!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;
+};
+/**
+ * Checks that the node only has a @style, not e.g. @class or whatever
+ */
+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) || (dom.pasteTextApply.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) && !cur.textContent.match(/\S|\u00A0/) && (dom.isText(prev) || prev.textContent.match(/\S|\u00A0/))) {
+ return true;
+ }
+ if (prev && !dom.isBR(prev) && dom.isEqual(prev, cur) &&
+ ((prev.tagName && window.getComputedStyle(prev).display === "inline" &&
+ cur.tagName && window.getComputedStyle(cur).display === "inline"))) {
+ return true;
+ }
+ if (dom.isEqual(parent, cur) &&
+ ((parent.tagName && window.getComputedStyle(parent).display === "inline" &&
+ cur.tagName && window.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) {
+
+ } 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);
+ if (temp = (previous ? dom.hasContentBefore(node) : dom.hasContentAfter(node))) {
+ 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;
+ 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" && window.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) && !cur.textContent.match(/\S|\u00A0/)) {
+ removed = true;
+ if (cur === begin) {
+ so = 0;
+ begin = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(cur)));
+ }
+ if (cur === end) {
+ eo = 1;
+ end = dom.firstChild(dom.hasContentAfter(dom.ancestorHaveNextSibling(cur)));
+ 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 exp1 = /[\t\n\r ]+/g;
+ var exp2 = /(?!([ ]|\u00A0)|^)\u00A0(?!([ ]|\u00A0)|$)/g;
+ if (cur === begin) {
+ var temp = cur.textContent.substr(0, so);
+ var _temp = temp.replace(exp1, ' ').replace(exp2, ' ');
+ so -= temp.length - _temp.length;
+ }
+ if (cur === end) {
+ var temp = cur.textContent.substr(0, eo);
+ var _temp = temp.replace(exp1, ' ').replace(exp2, ' ');
+ eo -= temp.length - _temp.length;
+ }
+ var 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: eo
+ };
+};
+dom.node = function (node) {
+ return node.tagName ? node : node.parentNode;
+};
+dom.pasteTextApply = "h1 h2 h3 h4 h5 h6 li p".split(" ");
+dom.pasteTextClose = "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small li pre".split(" ");
+dom.pasteText = function (textNode, offset, text, isOnlyText) {
+ // clean the node
+ var node = dom.node(textNode);
+ var data = dom.merge(node.parentNode, textNode, offset, textNode, offset, null, true);
+ data = dom.removeSpace(node.parentNode, data.sc, data.so, data.ec, data.eo);
+ // Break the text node up
+ if (data.sc.tagName) {
+ if (node === data.sc.parentNode) {
+ data.sc = node.insertBefore(document.createTextNode(" "), data.sc);
+ } else if (node.firstChild && !dom.isBR(dom.firstChild(node))) {
+ data.sc = node.insertBefore(document.createTextNode(" "), dom.firstChild(node));
+ } else {
+ data.sc = node.appendChild(document.createTextNode(" "));
+ }
+ data.so = 0;
+ }
+ data.sc.splitText(data.so);
+ var first = data.sc;
+ var last = data.sc.nextSibling;
+
+ isOnlyText = isOnlyText || !text.match('\n');
+
+ if (!isOnlyText) {
+ // tag to close and open
+ var tag = node.tagName.toLowerCase();
+ if(dom.pasteTextApply.indexOf(tag) === -1) {
+ text = text.split('\n').join("<br/>");
+ } else {
+ text = "<"+tag+">"+text.split('\n').join("</"+tag+"><"+tag+">")+"</"+tag+">";
+ }
+
+ var $text = $(text);
+
+ // split parent node and insert text
+ if(dom.pasteTextClose.indexOf(tag) !== -1) {
+ var $next = $(node).clone().empty();
+ $next.append( last );
+ $(node).after( $next );
+ $(node).after( $text );
+ } else {
+ $(data.sc).after( $text );
+ }
+ } else {
+ first.appendData( text );
+ }
+
+ // clean the dom content
+ data = dom.merge(node.parentNode.parentNode, last, 0, last, 0, null, true);
+ data = dom.removeSpace(node.parentNode.parentNode, data.sc, data.so, data.ec, data.eo);
+
+ // move caret
+ range.create(data.sc, data.so, data.ec, data.eo).select();
+};
+dom.removeBetween = function (sc, so, ec, eo, towrite) {
+ 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)) {
+ sc.parentNode.insertBefore(document.createTextNode('\u00A0'), sc);
+ }
+ }
+ if (!eo && sc !== ec) {
+ ec = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(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)) {
+ 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)) {
+ nodes[i].parentNode.removeChild(nodes[i]);
+ }
+ }
+
+ sc = before ? dom.lastChild(before) : dom.firstChild(after);
+ so = sc.textContent.length;
+
+ if (before) {
+ var text = sc.textContent.replace(/[ \t\n\r]+$/, '\u00A0');
+ so -= sc.textContent.length - text.length;
+ sc.textContent = text;
+ }
+ if (towrite && !node.firstChild && node.parentNode && !dom.isNotBreakable(node)) {
+ var br = $("<br/>")[0];
+ node.appendChild(br);
+ sc = br;
+ so = 0;
+ }
+ dom.autoMerge(sc, false);
+
+ } else {
+
+ var text = ancestor.textContent;
+ ancestor.textContent = text.slice(0, so) + text.slice(eo, Infinity).replace(/^[ \t\n\r]+/, '\u00A0');
+
+ }
+
+ eo = so;
+ if(!dom.isBR(sc) && !sc.textContent.match(/\S|\u00A0/) && !dom.isText(dom.hasContentBefore(sc)) && !dom.isText(dom.hasContentAfter(sc))) {
+ ancestor = dom.node(sc);
+ var text = document.createTextNode('\u00A0');
+ $(sc).before(text);
+ sc = text;
+ so = 0;
+ eo = 1;
+ }
+ return {
+ sc: sc,
+ so: so,
+ ec: sc,
+ eo: eo
+ };
+};
+dom.indent = function (node) {
+ var margin = parseFloat(node.style.marginLeft || 0)+1.5;
+ node.style.marginLeft = margin + "em";
+ return margin;
+};
+dom.outdent = function (node) {
+ var margin = parseFloat(node.style.marginLeft || 0)-1.5;
+ node.style.marginLeft = margin > 0 ? margin + "em" : "";
+ return margin;
+};
+dom.scrollIntoViewIfNeeded = function (node) {
+ var 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(window.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(window.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");
+};
+dom.listBetween = function (sc, ec) {
+ var nodes = [];
+ var ancestor = dom.commonAncestor(sc, ec);
+ dom.walkPoint({'node': sc, 'offset': 0}, {'node': ec, 'offset': 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]').is('[contenteditable="true"]');
+};
+
+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):/i)))) );
+};
+dom.isVisibleText = function (textNode) {
+ return !!textNode.textContent.match(/\S|\u00A0/);
+};
+/**
+ * order the style of the node to compare 2 nodes and remove attribute if empty
+ *
+ * @param {Node} node
+ * @return {String} className
+ */
+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;
+};
+/**
+ * order the class of the node to compare 2 nodes and remove attribute if empty
+ *
+ * @param {Node} node
+ * @return {String} className
+ */
+dom.orderClass = function (node) {
+ var className = 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;
+};
+/**
+ * return the current node (if text node, return parentNode)
+ *
+ * @param {Node} node
+ * @return {Node} node
+ */
+dom.node = function (node) {
+ return dom.isText(node) ? node.parentNode : node;
+};
+/**
+ * move all childNode to an other node
+ *
+ * @param {Node} from
+ * @param {Node} to
+ */
+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);
+ }
+ }
+};
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+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)));
+ so = sc.textContent.length;
+ } else if (!eo) {
+ ec = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(end)));
+ eo = ec.textContent.length;
+ } else {
+ ec = dom.firstChild(dom.hasContentAfter(dom.ancestorHaveNextSibling(end)));
+ 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);
+};
+range.WrappedRange.prototype.deleteContents = function (towrite) {
+ var prevBP = dom.removeBetween(this.sc, this.so, this.ec, this.eo, towrite);
+
+ $(dom.node(prevBP.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler
+ return new range.WrappedRange(
+ prevBP.sc,
+ prevBP.so,
+ prevBP.ec,
+ prevBP.eo
+ );
+};
+range.WrappedRange.prototype.clean = function (mergeFilter, all) {
+ var node = dom.node(this.sc === this.ec ? this.sc : this.commonAncestor());
+ 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 + '<li><a data-event="fontSize" href="#" data-value="' + v + '">' +
+ '<i class="fa fa-check"></i> ' + v +
+ '</a></li>';
+ }, '');
+
+ var sLabel = '<span class="note-current-fontsize">11</span>';
+ return renderer.getTemplate().button(sLabel, {
+ title: lang.font.size,
+ dropdown: '<ul class="dropdown-menu">' + items + '</ul>'
+ });
+};
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+/* add some text commands */
+
+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.mac['BACKSPACE'] = 'backspace';
+options.keyMap.mac['CMD+BACKSPACE'] = 'delete';
+options.keyMap.mac['SHIFT+BACKSPACE'] = 'delete';
+options.keyMap.mac['ENTER'] = 'enter';
+options.keyMap.mac['ESCAPE'] = 'cancel';
+
+$.summernote.pluginEvents.insertTable = function (event, editor, layoutInfo, sDim) {
+ var $editable = layoutInfo.editable();
+ var dimension = sDim.split('x');
+ var rng = range.create();
+ rng = rng.deleteContents();
+
+ var isBodyContainer = dom.isBodyContainer;
+ dom.isBodyContainer = dom.isNotBreakable;
+ rng.insertNode(editor.table.createTable(dimension[0], dimension[1]));
+ dom.isBodyContainer = isBodyContainer;
+
+ editor.afterCommand($editable);
+ event.preventDefault();
+ return false;
+};
+$.summernote.pluginEvents.tab = function (event, editor, layoutInfo, outdent) {
+ var $editable = layoutInfo.editable();
+ var r = range.create();
+ var outdent = outdent || false;
+
+ if (r.isCollapsed()) {
+ if (r.isOnCell() && r.isOnCellFirst()) {
+ var td = dom.ancestor(r.sc, dom.isCell);
+ if (!outdent && !dom.nextElementSibling(td) && !dom.nextElementSibling(td.parentNode)) {
+ range.create(td.lastChild, td.lastChild.textContent.length, td.lastChild, td.lastChild.textContent.length).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 {
+ $editable.data('NoteHistory').splitNext();
+ editor.table.tab(r, outdent);
+ }
+ event.preventDefault();
+ return false;
+ }
+
+ 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 {
+ if (!outdent){
+ var next = r.sc.splitText(r.so);
+ 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);
+ var 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();
+ }
+ event.preventDefault();
+ return false;
+ }
+ }
+ event.preventDefault();
+ return false;
+};
+$.summernote.pluginEvents.untab = function (event, editor, layoutInfo) {
+ return $.summernote.pluginEvents.tab(event, editor, layoutInfo, true);
+};
+$.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];
+
+ // table: add a tr
+ var td = dom.ancestor(r.sc, dom.isCell);
+ if (td && (r.sc === td || r.sc === td.lastChild || (dom.isBR(td.lastChild) && r.sc === td.lastChild.previousSibling)) && r.so === r.sc.textContent.length && r.isOnCell() && !dom.nextElementSibling(td)) {
+ var $node = $(td.parentNode);
+ var $clone = $node.clone();
+ $clone.children().html(dom.blank);
+ $node.after($clone);
+ var node = dom.firstElementChild($clone[0]) || $clone[0];
+ range.create(node, 0, node, 0).select();
+ dom.scrollIntoViewIfNeeded(br);
+ event.preventDefault();
+ return false;
+ }
+
+ var contentBefore = r.sc.textContent.slice(0,r.so).match(/\S|\u00A0/);
+ if (!contentBefore && dom.isText(r.sc)) {
+ var node = r.sc.previousSibling;
+ while (!contentBefore && node && dom.isText(node)) {
+ contentBefore = dom.isVisibleText(node);
+ node = node.previousSibling;
+ }
+ }
+
+ var node = dom.node(r.sc);
+ 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).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
+ node = $('<p></p>').append(br).insertAfter(dom.ancestor(r.sc, dom.isList))[0];
+ node = br;
+ } else if (last === r.sc) {
+ if (dom.isBR(last)) {
+ last = last.parentNode;
+ }
+ var $node = $(last);
+ var $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});
+ if (!contentBefore) {
+ var cur = dom.node(dom.lastChild(node.previousSibling));
+ if (!dom.isBR(cur)) {
+ $(cur).html(br);
+ }
+ }
+ if (!node.textContent.match(/\S|\u00A0/)) {
+ 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 && !r.isCollapsed()) {
+ r = r.deleteContents(true);
+ 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);
+ while (node.parentNode) {
+ if (dom.isForbiddenNode(node)) {
+ var text = node.previousSibling;
+ if (text && dom.isText(text) && text.textContent.match(/\S|\u00A0/)) {
+ 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 summernote_keydown_clean (field) {
+ setTimeout(function () {
+ var r = range.create();
+ if (!r) return;
+ var node = r[field];
+ while (dom.isText(node)) {node = node.parentNode;}
+ node = node.parentNode;
+ var data = dom.merge(node, r.sc, r.so, r.ec, r.eo, null, true);
+ data = dom.removeSpace(node.parentNode, data.sc, data.so, data.sc, data.so);
+
+ range.create(data.sc, data.so, data.sc, data.so).select();
+ },0);
+}
+
+function remove_table_content(sc, ec) {
+ var nodes = dom.listBetween(sc, ec);
+ nodes.push(dom.node(sc), dom.node(ec));
+ for (var i in nodes) {
+ if (dom.isCell(nodes[i])) {
+ $(nodes[i]).html("<br/>");
+ }
+ }
+ event.preventDefault();
+ 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.isContentEditable()) {
+ return false;
+ }
+ if (!r.isCollapsed()) {
+ if (dom.isCell(dom.node(r.sc)) || dom.isCell(dom.node(r.ec))) {
+ return remove_table_content(r.sc, r.ec);
+ }
+ 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;
+
+ // 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.makeOffsetPath(parent, node)[0];
+ if (index>0) {
+ var next = node.previousSibling;
+ range.create(next,next.textContent.length).select();
+ }
+ if (!dom.hasContentAfter(node) && !dom.hasContentBefore(node)) {
+ parent.appendChild($('<br/>')[0]);
+ }
+ parent.removeChild(node);
+ node = parent;
+ }
+ }
+ // 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;
+ var 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)) &&
+ 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);
+ var next = dom.firstChild(dom.hasContentAfter(dom.ancestorHaveNextSibling(target)));
+ if (dom.isBR(next)) {
+ 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) || window.getComputedStyle(temp).display === "inline") && (dom.isText(temp2) || window.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.isContentEditable()) {
+ event.preventDefault();
+ return false;
+ }
+ if (!r.isCollapsed()) {
+ if (dom.isCell(dom.node(r.sc)) || dom.isCell(dom.node(r.ec))) {
+ return remove_table_content(r.sc, r.ec);
+ }
+ 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 = range.create(data.sc, data.so);
+ r.select();
+ target = r.sc;
+ offset = r.so;
+
+ 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;
+
+ // 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)) {
+ 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) {
+ tr.parentNode.removeChild(tr);
+ node = (dom.lastElementChild(prevTr).lastChild && dom.lastElementChild(prevTr).lastChild.tagName ? dom.lastElementChild(prevTr).lastChild.previousSibling : dom.lastElementChild(prevTr).lastChild) || dom.lastElementChild(prevTr);
+ 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;
+ var 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)) {
+ var 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) || window.getComputedStyle(temp).display === "inline") && (dom.isText(temp2) || window.getComputedStyle(temp2).display === "inline")) {
+ if (dom.isText(temp2)) {
+ temp2.textContent = temp2.textContent.replace(/\S\s*$/, '');
+ } else {
+ $.summernote.pluginEvents.backspace(event, editor, layoutInfo);
+ }
+ }
+ }
+
+ var rng = range.create();
+ if (rng) {
+ $(dom.node(rng.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler
+ dom.scrollIntoViewIfNeeded(rng.sc.parentNode.previousElementSibling || rng.sc);
+ }
+
+ event.preventDefault();
+ return false;
+};
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+/* add list command (create a uggly dom for chrome) */
+
+function isFormatNode(node) {
+ return node.tagName && options.styleTags.indexOf(node.tagName.toLowerCase()) !== -1;
+}
+
+$.summernote.pluginEvents.insertUnorderedList = function (event, editor, layoutInfo, sorted) {
+ var $editable = layoutInfo.editable();
+ $editable.data('NoteHistory').recordUndo($editable);
+
+ var parent;
+ var rng = range.create();
+ var node = rng.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;
+ parent.insertBefore(ul, node);
+ while (node.firstChild) {
+ ul.appendChild(node.firstChild);
+ }
+ parent.removeChild(node);
+ rng.select();
+ return;
+
+ } else if (node.tagName === (sorted ? "OL" : "UL")) {
+
+ 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);
+ rng.select();
+ return;
+
+ }
+ node = parent;
+ }
+
+ var p0 = rng.sc;
+ while (p0 && p0.parentNode && p0.parentNode !== $editable[0] && !isFormatNode(p0)) {
+ p0 = p0.parentNode;
+ }
+ if (!p0) return;
+ var p1 = rng.ec;
+ while (p1 && p1.parentNode && p1.parentNode !== $editable[0] && !isFormatNode(p1)) {
+ p1 = p1.parentNode;
+ }
+ if (!p0.parentNode || p0.parentNode !== p1.parentNode) {
+ return;
+ }
+
+ var parent = p0.parentNode;
+ var ul = document.createElement(sorted ? "ol" : "ul");
+ parent.insertBefore(ul, p0);
+ var childNodes = parent.childNodes;
+ var brs = [];
+ var begin = false;
+ for (var 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 (var i=0; i<brs.length; i++) {
+ parent.removeChild(brs[i]);
+ }
+ rng.clean().select();
+ event.preventDefault();
+ return false;
+};
+$.summernote.pluginEvents.insertOrderedList = function (event, editor, layoutInfo) {
+ return $.summernote.pluginEvents.insertUnorderedList(event, editor, layoutInfo, true);
+};
+$.summernote.pluginEvents.indent = function (event, editor, layoutInfo, outdent) {
+ var $editable = layoutInfo.editable();
+ $editable.data('NoteHistory').recordUndo($editable);
+ var r = range.create();
+
+ var flag = false;
+ function indentUL (UL, start, end) {
+ var next;
+ var tagName = UL.tagName;
+ var node = UL.firstChild;
+ var parent = UL.parentNode;
+ var ul = document.createElement(tagName);
+ var li = document.createElement("li");
+ li.style.listStyle = "none";
+ 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;
+ node.parentNode.insertBefore(li, node);
+ }
+ next = dom.nextElementSibling(node);
+ if (flag) {
+ ul.appendChild(node);
+ }
+ if (node === end || $.contains(node, end)) {
+ flag = false;
+ break;
+ }
+ 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));
+ }
+ var 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 tagName = UL.tagName;
+ 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) {
+ ul = node.parentNode;
+ li.parentNode.insertBefore(node, li);
+ if (!ul.children.length) {
+ if (ul.parentNode.tagName === "LI") {
+ ul = ul.parentNode;
+ }
+ ul.parentNode.removeChild(ul);
+ }
+ }
+
+ 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)) {
+ flag = true;
+ }
+ if (flag) {
+ if (outdent) {
+ dom.outdent(p);
+ } else {
+ dom.indent(p);
+ }
+ }
+ if (p === end || $.contains(p, end)) {
+ flag = false;
+ }
+ }
+
+ var ancestor = r.commonAncestor();
+ var $dom = $(ancestor);
+
+ if (!dom.isList(ancestor)) {
+ $dom = $(ancestor).children();
+ }
+ if (!$dom.length) {
+ $dom = $(dom.ancestor(r.sc, dom.isList));
+ if (!$dom.length) {
+ $dom = $(r.sc).closest(options.styleTags.join(','));
+ }
+ }
+
+ $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)) {
+ 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) {
+ var $editable = layoutInfo.editable();
+ $editable.data('NoteHistory').recordUndo($editable);
+ event.preventDefault();
+
+ var r = range.create();
+ if (!r) {
+ return;
+ }
+ 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);
+ for (var i=0; i<nodes.length; i++) {
+ if (dom.isBR(nodes[i]) || (dom.isText(nodes[i]) && nodes[i].textContent.match(/\S|\u00A0/)) || 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) {
+ 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 node = range.create().sc.parentNode;
+ document.execCommand('removeFormat');
+ document.execCommand('removeFormat');
+ var r = range.create();
+ 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;
+};
+var fn_boutton_updateRecentColor = eventHandler.toolbar.button.updateRecentColor;
+eventHandler.toolbar.button.updateRecentColor = function (elBtn, sEvent, sValue) {
+ fn_boutton_updateRecentColor.call(this, elBtn, sEvent, sValue);
+ var font = $(elBtn).closest('.note-color').find('.note-recent-color i')[0];
+
+ if (sEvent === "foreColor") {
+ if (sValue.indexOf('text-') !== -1) {
+ font.className += ' ' + sValue;
+ font.style.color = '';
+ } else {
+ font.className = font.className.replace(/(^|\s+)text-\S+/);
+ font.style.color = sValue !== 'inherit' ? sValue : "";
+ }
+ } else {
+ if (sValue.indexOf('bg-') !== -1) {
+ font.className += ' ' + sValue;
+ font.style.backgroundColor = "";
+ } else {
+ font.className = font.className.replace(/(^|\s+)bg-\S+/);
+ font.style.backgroundColor = sValue !== 'inherit' ? sValue : "";
+ }
+ }
+ event.preventDefault();
+ return false;
+};
+
+options.fontSizes = [_t('Default'), 8, 9, 10, 11, 12, 14, 18, 24, 36, 48, 62];
+$.summernote.pluginEvents.applyFont = function (event, editor, layoutInfo, color, bgcolor, size) {
+ var rng = range.create();
+ var startPoint = rng.getStartPoint();
+ var endPoint = rng.getEndPoint();
+
+ if (rng.isCollapsed()) {
+ 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
+ if (endPoint.offset && endPoint.offset != dom.nodeLength(endPoint.node)) {
+ var ancestor = dom.ancestor(endPoint.node, dom.isFont) || endPoint.node;
+ dom.splitTree(ancestor, endPoint);
+ }
+ if (startPoint.offset && startPoint.offset != dom.nodeLength(startPoint.node)) {
+ var ancestor = dom.ancestor(startPoint.node, dom.isFont) || startPoint.node;
+ var 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);
+
+ // apply font: foreColor, backColor, size (the color can be use a class text-... or bg-...)
+ var node, font, $font, fonts = [], className;
+ if (color || bgcolor || size) {
+ for (var 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+/);
+
+ if (color) {
+ for (var 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 (var 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(window.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 (var 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)
+ var nodes = [];
+ dom.walkPoint(startPoint, endPoint, function (point) {
+ nodes.push(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, $prev;
+ for (var i=0; i<nodes.length; i++) {
+ node = nodes[i];
+
+ if ((dom.isText(node) || dom.isBR(node)) && !dom.isVisibleText(node)) {
+ remove(node);
+ nodes.splice(i,1);
+ i--;
+ 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);
+
+ if (!className && !style) {
+ remove(node, node.parentNode);
+ nodes.splice(i,1);
+ i--;
+ continue;
+ }
+
+ if (i>0 && (font = dom.ancestor(nodes[i-1], dom.isFont))) {
+ className2 = font.getAttribute('class');
+ style2 = font.getAttribute('style');
+ if (node !== font && className == className2 && style == style2) {
+ remove(node, font);
+ nodes.splice(i,1);
+ i--;
+ 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 $editable = layoutInfo.editable();
+ 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) {
+ var $editable = layoutInfo.editable();
+ $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, foreColor, null, null);
+ editor.afterCommand($editable);
+};
+$.summernote.pluginEvents.backColor = function (event, editor, layoutInfo, backColor) {
+ var $editable = layoutInfo.editable();
+ var r = range.create();
+ 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);
+ editor.afterCommand($editable);
+};
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+options.onCreateLink = function (sLinkUrl) {
+ if (sLinkUrl.indexOf('mailto:') === 0) {
+ // pass
+ } else if (sLinkUrl.indexOf('@') !== -1 && sLinkUrl.indexOf(':') === -1) {
+ sLinkUrl = 'mailto:' + sLinkUrl;
+ } else if (sLinkUrl.indexOf('://') === -1 && sLinkUrl.indexOf('/') !== 0 && sLinkUrl.indexOf('#') !== 0) {
+ sLinkUrl = 'http://' + sLinkUrl;
+ }
+ return sLinkUrl;
+};
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+/* table */
+
+function summernote_table_scroll (event) {
+ if (range.create().isOnCell()) {
+ $('.o_table_handler').remove();
+ }
+}
+function summernote_table_update (oStyle) {
+ var r = range.create();
+ if (!r || !r.isOnCell() || !r.isOnCellFirst()) {
+ $('.o_table_handler').remove();
+ return;
+ }
+ var table = dom.ancestor(oStyle.range.sc, dom.isTable);
+ 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.popover.update;
+eventHandler.popover.update = function ($popover, oStyle, isAirMode) {
+ fn_popover_update.call(this, $popover, oStyle, isAirMode);
+ summernote_table_update(oStyle);
+};
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+function summernote_paste (event) {
+ // keep norma feature if copy a picture
+ var clipboardData = event.originalEvent.clipboardData;
+ if (clipboardData.items) {
+ var item = list.last(clipboardData.items);
+ var isClipboardImage = item.kind === 'file' && item.type.indexOf('image/') !== -1;
+ if (isClipboardImage) {
+ return true;
+ }
+ }
+
+ var $editable = $(event.currentTarget);
+ $editable = $editable.is('[contenteditable]') ? $editable : $editable.find('[contenteditable]');
+ $editable.data('NoteHistory').recordUndo($editable);
+
+ if (["INPUT", "TEXTAREA"].indexOf(event.target.tagName) === -1) {
+ var r = range.create();
+ if (!r.isCollapsed()) {
+ r = r.deleteContents();
+ r.select();
+ }
+
+ var text = clipboardData.getData("text/plain").replace(/</g, "&lt;");
+ dom.pasteText(r.sc, r.so, text);
+ event.preventDefault();
+ return false;
+ }
+}
+
+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);
+ oLayoutInfo.editor.on("paste", summernote_paste);
+ $editable.on("scroll", summernote_table_scroll);
+};
+var fn_dettach = eventHandler.dettach;
+eventHandler.dettach = function (oLayoutInfo, options) {
+ var $editable = oLayoutInfo.editor.hasClass('note-editable') ? oLayoutInfo.editor : oLayoutInfo.editor.find('.note-editable');
+ fn_dettach.call(this, oLayoutInfo, options);
+ oLayoutInfo.editor.off("paste", summernote_paste);
+ $editable.off("scroll", summernote_table_scroll);
+};
+
+return $.summernote;
+
+});
diff --git a/addons/web_editor/static/src/js/tour.rte.js b/addons/web_editor/static/src/js/tour.rte.js
new file mode 100644
index 00000000000..c257176fd6f
--- /dev/null
+++ b/addons/web_editor/static/src/js/tour.rte.js
@@ -0,0 +1,398 @@
+odoo.define('web_editor.tour', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Tour = require('web.Tour');
+var base = require('web_editor.base');
+var snippet_editor = require('web_editor.snippet.editor');
+
+var _t = core._t;
+
+base.ready().done(function () {
+
+var click_event = function(el, type) {
+ var evt = document.createEvent("MouseEvents");
+ evt.initMouseEvent(type, true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, el);
+ el.dispatchEvent(evt);
+};
+
+Tour.register({
+ id: 'rte',
+ name: "Test RTE",
+ mode: 'test',
+ path: '/web_editor/field/html?callback=FieldTextHtml_0&enable_editor=1&datarecord=%7B%7D',
+ steps: [
+ {
+ title: "Change html for this test",
+ onload: function () {
+ var $el = $(this.element);
+ var html = '\n'+
+ '<section>\n'+
+ ' <div class="container">\n'+
+ ' <div class="row">\n'+
+ ' <div class="col-md-6 mt16">\n'+
+ '<h1 id="text_title_id">Batnae municipium in Anthemusia</h1> \n'+
+ ' <p>Batnae municipium in Anthemusia conditum Macedonum manu priscorum ab Euphrate flumine brevi spatio disparatur, refertum mercatoribus opulentis, ubi annua sollemnitate prope Septembris initium mensis ad.</p>\n'+
+ ' <p> Quam <img style="width: 25%" src="/web_editor/static/src/img/drag_here.png"/> quidem <span class="fa fa-flag fa-2x"></span> partem accusationis admiratus sum et moleste tuli potissimum esse Atratino datam. Neque enim decebat neque aetas.</p>\n'+
+ ' <p>Et hanc quidem praeter oppida multa duae civitates exornant Seleucia opus Seleuci regis, et Claudiopolis quam deduxit coloniam Claudius Caesar. Isaura enim antehac nimium potens, olim subversa ut rebellatrix.</p>'+
+ '<p>Harum trium sententiarum nulli prorsus assentior.</p>\n'+
+ ' </div>\n'+
+ ' <div class="col-md-6 mt16">\n'+
+ ' <img class="img img-responsive shadow mb16" src="/web_editor/static/src/img/drag_here.png" alt="Odoo text and image block">\n'+
+ ' </div>\n'+
+ ' </div>\n'+
+ '</section>\n';
+ $("#editable_area").html(html);
+ }
+ },
+ {
+ element: '#editable_area > section .row > div:first',
+ title: "simulate triple click and change text bg-color",
+ onload: function () {
+ var $el = $(this.element);
+ var $h1 = $('h1', $el);
+ $.summernote.core.range.create($h1[0].firstChild, 0, $('p', $el)[0], 0).select();
+ click_event($h1[0], 'mouseup');
+ }
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first',
+ element: '.note-popover button[data-event="color"]:visible',
+ title: "change text bg-color after triple click",
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first:not(:has(p font)) h1 font',
+ element: '.note-color button.dropdown-toggle:visible',
+ title: "change selection to change text color",
+ onload: function () {
+ var $el = $('#editable_area > section .row > div:first:not(:has(p font)) h1 font');
+ $.summernote.core.range.create($el[0].firstChild, 5, $el[0].firstChild, 10).select();
+ click_event($el[0], 'mouseup');
+ }
+ },
+ {
+ element: 'div[data-target-event="foreColor"]:visible .note-color-row:eq(1) button[data-event="foreColor"]:first',
+ title: "change text color",
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first h1 font:eq(2)',
+ element: '.note-color button.dropdown-toggle:visible',
+ title: "change selection to change text bg-color again",
+ onload: function () {
+ var $el = $('#editable_area > section .row > div:first h1 font:eq(2)');
+ $.summernote.core.range.create($el.prev()[0].firstChild, 3, $el[0].firstChild, 10).select();
+ click_event($el.prev()[0], 'mouseup');
+ }
+ },
+ {
+ element: 'div[data-target-event="backColor"] .colorpicker button[data-event="backColor"]:first',
+ title: "change text backColor again",
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first h1 font:eq(4)',
+ element: '.note-color button.dropdown-toggle:visible',
+ title: "change selection (h1 and p) to change text color with class",
+ onload: function () {
+ var $el = $('#editable_area > section .row > div:first h1 font:eq(4)');
+ $.summernote.core.range.create($el.prev()[0].firstChild, 3, $el.parent("h1").next("p")[0].firstChild, 30).select();
+ click_event($el.prev()[0], 'mouseup');
+ }
+ },
+ {
+ element: 'div[data-target-event="foreColor"]:visible button[data-event="foreColor"][data-value^="text-"]:first',
+ title: "change text foreColor again",
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first p font',
+ element: '.o_editable.note-editable.o_dirty',
+ title: "delete selection",
+ keydown: 46 // delete
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first:not(:has(p font)) h1',
+ element: '.o_editable.note-editable.o_dirty',
+ title: "clean and delete (backspace) an other selection",
+ onload: function () {
+ var $el = $(this.waitFor);
+ $.summernote.core.range.createFromNode($el.next("p")[0]).clean();
+ $.summernote.core.range.create($el.find('font:containsExact(ici)')[0].firstChild, 1, $el.next().next()[0].firstChild, 5).select();
+ click_event($el.find('font:last')[0], 'mouseup');
+ },
+ keydown: 8 // backspace
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first:has( font:last:containsExact(i) ):has( p:first:containsRegex(/^uam/) ) h1',
+ element: '.o_editable.note-editable.o_dirty',
+ title: "delete an other selection",
+ onload: function () {
+ var $el = $(this.waitFor);
+ $.summernote.core.range.create($el.find('font:first')[0].firstChild, 3, $el.next("p")[0].childNodes[2], 8).select();
+ click_event($el.find('font:first')[0], 'mouseup');
+ },
+ keydown: 46
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first:has( font:last:containsExact(Bat) )',
+ element: '.note-air-popover button[data-event="undo"]',
+ title: "undo",
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first:has( font:last:containsExact(i) )',
+ element: '.note-air-popover button[data-event="undo"]',
+ title: "undo adain",
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first:not(:has(p font)) h1',
+ element: '.o_editable.note-editable.o_dirty',
+ title: "delete (backspace) after undo",
+ keydown: 8 // backspace
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first:has( font:last:containsExact(i) ):has( p:first:containsRegex(/^uam/) )',
+ element: '#editable_area > section .row > div:first img[style*="25%"]',
+ title: "click on image",
+ },
+ {
+ element: '.note-image-popover:visible button[data-event="resize"][data-value="0.5"]',
+ title: "Click on resize half",
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first img[style*="50%"]',
+ element: '.note-image-popover:visible button[data-event="showImageDialog"]',
+ title: "Click on edit picture",
+ },
+ {
+ waitFor: '.modal #editor-media-image',
+ element: '.modal a[data-toggle="tab"]:contains(Pictogram)',
+ title: "Click on pictogram tab",
+ },
+ {
+ element: '#editor-media-icon.active span.fa:first',
+ title: "select a pictogram",
+ },
+ {
+ waitFor: '#editor-media-icon.active span.font-icons-selected',
+ element: '.modal button.save',
+ title: "save pictogram",
+ },
+ {
+ element: '.note-image-popover button[data-event="resizefa"][data-value="3"]',
+ title: "select a size for the pictogram",
+ },
+ {
+ waitFor: '#wrapwrap span.fa-3x',
+ element: '.note-image-popover:visible button[data-event="floatMe"][data-value="right"]',
+ title: "click on float right",
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first span.fa.pull-right',
+ element: '.note-image-popover:visible button[data-event="showLinkDialog"]',
+ title: "click on create link",
+ },
+ {
+ waitFor: '.modal a#link-preview:containsRegex(/^<span [^>]+><\\/span>$/) > span.fa.fa-3x.pull-right',
+ element: '.modal .dropdown:has(.link-style) a[data-toggle="dropdown"]',
+ title: "click on color style",
+ },
+ {
+ element: '.modal .dropdown ul label.btn-success',
+ title: "choose success style",
+ },
+ // {
+ // waitFor: '.modal a#link-preview.btn:containsRegex(/^<span [^>]+><\\/span>$/)',
+ // element: '.modal .select2-container.url-source a.select2-choice',
+ // title: "click to choose an internal page",
+ // },
+ // {
+ // element: '.select2-drop:visible .select2-search input',
+ // title: "search 'contact'",
+ // sampleText: "contact",
+ // },
+ // {
+ // element: '.select2-drop:visible .select2-results .select2-result div:contains(/page/)',
+ // title: "select /page/contactus",
+ // },
+ {
+ // waitNot: '.select2-drop:visible',
+ element: '#link-text',
+ title: "change text label",
+ sampleText: "ABC[IMG] DEF",
+ },
+ {
+ waitFor: '.modal a#link-preview.btn',
+ element: '#link-external',
+ sampleText: "http://www.odoo.com",
+ title: "insert a link url",
+ },
+ {
+ waitFor: '.modal a#link-preview.btn:containsRegex(/^ABC<span [^>]+><\\/span> DEF$/)',
+ element: '.modal button.save',
+ title: "save link",
+ },
+ {
+ waitNot: '#link-preview',
+ waitFor: 'a.btn[href^="http://"]:has(span.fa.fa-3x.pull-right)',
+ element: '#editable_area > section .row > div:last img',
+ title: "click on other picture",
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first span.fa.pull-right',
+ element: '.note-image-popover:visible button[data-event="showLinkDialog"]',
+ title: "click on create link again",
+ },
+ {
+ waitFor: '.modal a#link-preview:containsRegex(/^<img [^>]+>$/)',
+ element: '.modal .dropdown:has(.link-style) a[data-toggle="dropdown"]',
+ title: "click on color style again",
+ },
+ {
+ element: '.modal .dropdown ul label.btn-success',
+ title: "choose success style",
+ },
+ {
+ waitFor: '.modal a#link-preview.btn',
+ element: '#link-external',
+ sampleText: "test@test.test",
+ title: "insert an email",
+ },
+ {
+ waitFor: '.modal a#link-preview.btn[href="mailto:test@test.test"]',
+ element: '.modal button.save',
+ title: "save link",
+ },
+ {
+ waitNot: '#link-preview',
+ waitFor: '#editable_area > section .row > div:eq(1) > a > img',
+ element: '#editable_area > section .row > div:first p:eq(2)',
+ title: "triple enter then double backspace",
+ keydown: [66, 13, 66, 13, 13, 8, 8], // B enter B enter enter backspace backspace
+ onload: function () {
+ var p = $(this.element)[0].firstChild;
+ $.summernote.core.range.create(p, p.textContent.length, p, p.textContent.length).select();
+ click_event(p, 'mouseup');
+ },
+ },
+ {
+ waitNot: '#editable_area > section .row > div:first p:eq(4), #editable_area > section .row > div:eq(3)',
+ waitFor: '#editable_area > section .row > div:first p:eq(3)',
+ title: "add ul content",
+ onload: function () {
+ var $el = $('#editable_area > section .row > div:first');
+ var html = ' <ul> '+
+ '\n <li> <p>Batnae municipium. </p></li>'+
+ '\n <li> Seleucia praeter.</li>'+
+ '\n <li><p>Et hanc quidem.</p></li>'+
+ '\n </ul>';
+ $el.append(html);
+ var node = $el.find('ul li p:last')[0].firstChild;
+ $.summernote.core.range.create(node, 6).select();
+ click_event(node, 'mouseup');
+ }
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first ul li p:first',
+ element: '.note-air-popover .note-style button.dropdown-toggle',
+ title: "click on style dropdown",
+ },
+ {
+ element: '.note-air-popover .note-style ul:visible a[data-value="h3"]',
+ title: "select h3",
+ onload: function () {
+ var node = $('#editable_area > section .row > div:first ul li p:last')[0].firstChild;
+ $.summernote.core.range.create(node, 0).select();
+ click_event(node, 'mouseup');
+ }
+ },
+ {
+ element: '#editable_area > section .row > div:first > ul > li > h3',
+ title: "double tabulation",
+ keydown: [9, 9] // tabulation
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first ul > li > ul > li > ul > li > h3',
+ element: '.note-air-popover button[data-event="insertOrderedList"]',
+ title: "click on order list",
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first ul > li > ul > li > ol > li > h3',
+ element: '#editable_area > section .row > div:first ul li > p:last',
+ title: "enter in ul",
+ keydown: [66, 13], // enter
+ onload: function () {
+ $(this.element)[0].firstChild.textContent += "";
+ $.summernote.core.range.create($(this.element)[0].firstChild, 7).select();
+ click_event($(this.element)[0], 'mouseup');
+ }
+ },
+ {
+ element: '#editable_area > section .row > div:first ul li > p:eq(1):containsRegex(/^municipium./)',
+ title: "backspace in list",
+ keydown: 8
+ },
+ {
+ waitFor: '#editable_area > section .row > div:first ul li p:eq(1)',
+ title: "end",
+ },
+ ]
+});
+
+
+Tour.register({
+ id: 'rte_inline',
+ name: "Test RTE Inline",
+ mode: 'test',
+ path: '/web_editor/field/html/inline?callback=FieldTextHtml_0&enable_editor=1&datarecord=%7B%7D',
+ steps: [
+ {
+ title: "Change html for this test",
+ onload: function () {
+ var $el = $(this.element);
+ var html = '\n'+
+ '<div>\n'+
+ ' <table cellspacing="0" cellpadding="0" width="100%">\n'+
+ ' <tbody>\n'+
+ ' <tr>\n'+
+ ' <td valign="center" width="270">\n'+
+ ' <img src="/logo.png" alt="Your Logo" class="img-circle img-thumbnail">\n'+
+ ' </td>\n'+
+ ' <td valign="center" width="270">\n'+
+ ' <a href="https://www.facebook.com/Odoo"><span class="fa fa-facebook-square fa-2x text-primary"></span></a>\n'+
+ ' <span style="color: rgb(255, 0, 0);" class="fa fa-4x fa-google-plus-square pull-right"></span>\n'+
+ ' </td>\n'+
+ ' </tr>\n'+
+ ' </tbody>\n'+
+ ' </table>\n'+
+ '</div>';
+ $("#editable_area").html(html);
+ }
+ },
+ {
+ title: "call clean for save",
+ element: '#wrapwrap table',
+ onload: function () {
+ snippet_editor.instance.clean_for_save();
+ }
+ },
+ {
+ waitFor: '#wrapwrap img:first[style*="border-bottom-left-radius:50%"][style*="border-top-width"][style*="padding-top"]',
+ title: "check the image style",
+ },
+ {
+ waitFor: '#wrapwrap img:eq(1)[src^="/web_editor/font_to_img/"][src$="/rgb(66,139,202)/27"]',
+ title: "check the font image src",
+ },
+ {
+ waitFor: '#wrapwrap img:eq(1)[style*="height:2em"]',
+ title: "check the font class to css",
+ },
+ {
+ waitFor: '#wrapwrap img:eq(2)[style*="float:right"]',
+ title: "check the second font class to css",
+ },
+ ]
+});
+
+
+});
+
+});
diff --git a/addons/web_editor/static/src/js/transcoder.js b/addons/web_editor/static/src/js/transcoder.js
new file mode 100644
index 00000000000..c7bcd14107f
--- /dev/null
+++ b/addons/web_editor/static/src/js/transcoder.js
@@ -0,0 +1,172 @@
+odoo.define('web_editor.transcoder', function (require) {
+'use strict';
+
+var widget = require('web_editor.widget');
+
+var cache = {};
+var rulesCache = [];
+var getMatchedCSSRules = function (a) {
+ if(cache[a.tagName + "." +a.className]) {
+ return cache[a.tagName + "." +a.className];
+ }
+ if (!rulesCache.length) {
+ var sheets = document.styleSheets;
+ for(var i = sheets.length-1; i >= 0 ; i--) {
+ var rules = sheets[i].rules || sheets[i].cssRules;
+ if (rules) {
+ for(var r = rules.length-1; r >= 0; r--) {
+ var selectorText = rules[r].selectorText;
+ if (selectorText &&
+ rules[r].cssText &&
+ selectorText.indexOf(".") !== -1 &&
+ selectorText.indexOf(".note-") === -1 &&
+
+ selectorText.indexOf(":hover") === -1 &&
+ selectorText.indexOf(":before") === -1 &&
+ selectorText.indexOf(":after") === -1 &&
+ selectorText.indexOf(":active") === -1 &&
+ selectorText.indexOf(":link") === -1 &&
+ selectorText.indexOf("::") === -1) {
+ var st = selectorText.split(/\s*,\s*/);
+ for (var k=0; k<st.length; k++) {
+ rulesCache.push({ 'selector': st[k], 'style': rules[r].style });
+ }
+ }
+ }
+ }
+ }
+ rulesCache.reverse();
+ }
+
+ var css = [];
+ a.matches = a.matches || a.webkitMatchesSelector || a.mozMatchesSelector || a.msMatchesSelector || a.oMatchesSelector;
+ for(var r = 0; r < rulesCache.length; r++) {
+ if (a.matches(rulesCache[r].selector)) {
+ var style = rulesCache[r].style;
+ if (style.parentRule) {
+ var style_obj = {};
+ for (var k=0, len=style.length; k<len; k++) {
+ style_obj[style[k]] = style[style[k]];
+ }
+ 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.replace(/#[a-z0-9_-]+/gi, function () { a++; return ""; });
+ var b = 0;
+ selector.replace(/(\.[a-z0-9_-]+)|(\[.*?\])/gi, function () { b++; return ""; });
+ var c = 0;
+ 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]); });
+
+ var style = {};
+ _.each(css, function (v,k) {
+ _.each(v[1], function (v,k) {
+ if (!style[k] || style[k].indexOf('important') === -1 || v.indexOf('important') !== -1) {
+ style[k] = v;
+ }
+ });
+ });
+ return cache[a.tagName + "." +a.className] = style;
+};
+
+// convert font awsome into image
+var font_to_img = function ($editable) {
+ $(".fa", $editable).each(function () {
+ var $font = $(this);
+ var content;
+ _.find(widget.fontIcons, function (font) {
+ return _.find(widget.getCssSelectors(font.parser), function (css) {
+ if ($font.is(css[0].replace(/::?before$/, ''))) {
+ content = css[1].match(/content:\s*['"]?(.)['"]?/)[1];
+ return true;
+ }
+ });
+ });
+ if (content) {
+ var size = parseInt(parseFloat($font.css("font-size"))/parseFloat($font.parent().css("font-size")),10);
+ var color = $font.css("color").replace(/\s/g, '');
+ var src = _.str.sprintf('/web_editor/font_to_img/%s/%s/'+$font.width(), window.encodeURI(content), window.encodeURI(color));
+ var style = $font.attr("style");
+ style = (style ? style.replace(/\s/g, '').replace(/(^|;)height:[^;]*/, '$1').replace(/(^|;)font-size:[^;]*/, '$1') : "") + "height:"+size+"em;";
+ var $img = $("<img/>").attr("src", src).attr("data-class", $font.attr("class")).attr("style", style);
+ $font.replaceWith($img);
+ } else {
+ $font.remove();
+ }
+ });
+};
+// convert image into font awsome
+var img_to_font = function ($editable) {
+ $("img[src*='/web_editor/font_to_img/']", $editable).each(function () {
+ var $img = $(this);
+ var $font = $("<span/>").attr("class", $img.data("class")).attr("style", $img.attr("style")).css("height", "");
+ $img.replaceWith($font);
+ });
+};
+
+// convert class into inline style to send by mail
+var class_to_style = function ($editable) {
+ if (!rulesCache.length) {
+ getMatchedCSSRules($editable[0]);
+ }
+ var selector = _.map(rulesCache, function (a) { return a.selector;}).join(",");
+ $editable.find(selector).each(function () {
+ var $target = $(this);
+ var css = getMatchedCSSRules(this);
+ var style = $target.attr("style") || "";
+ _.each(css, function (v,k) {
+ if (style.indexOf(k) === -1) {
+ style = k+":"+v+";"+style;
+ }
+ });
+ $target.attr("style", style);
+ });
+};
+// convert style into inline class from mail
+var style_to_class = function ($editable) {
+ getMatchedCSSRules($editable[0]);
+ var classes = [];
+ $editable.find("[class]").each(function () {
+ classes = classes.concat(this.className.split(/\s+/));
+ });
+
+ var maybe_selector = _.filter(rulesCache, function (a) { return !!_.find(classes, function (b) { return a.selector.indexOf("."+b) !== -1; } ); });
+ var selector = _.map(maybe_selector, function (a) { return a.selector;}).join(",");
+
+ var $c = $('<span/>').appendTo("body");
+
+ $editable.find(selector).each(function () {
+ var $target = $(this);
+ var css = getMatchedCSSRules(this);
+ var style = "";
+ _.each(css, function (v,k) {
+ if (style.indexOf(k) === -1) {
+ style = k+":"+v+";"+style;
+ }
+ });
+ css = $c.attr("style", style).attr("style").split(/\s*;\s*/);
+ style = $target.attr("style") || "";
+ _.each(css, function (v) {
+ style = style.replace(v, '');
+ });
+ $target.attr("style", style.replace(/;+(\s;)*/g, ';').replace(/^;/g, ''));
+ });
+ $c.remove();
+};
+
+return {
+ 'font_to_img': font_to_img,
+ 'img_to_font': img_to_font,
+ 'class_to_style': class_to_style,
+ 'style_to_class': style_to_class,
+};
+
+}); \ No newline at end of file
diff --git a/addons/web_editor/static/src/js/translator.js b/addons/web_editor/static/src/js/translator.js
new file mode 100644
index 00000000000..190706a111c
--- /dev/null
+++ b/addons/web_editor/static/src/js/translator.js
@@ -0,0 +1,214 @@
+odoo.define('web_editor.translate', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Model = require('web.Model');
+var ajax = require('web.ajax');
+var Class = require('web.Class');
+var Widget = require('web.Widget');
+var base = require('web_editor.base');
+var rte = require('web_editor.rte');
+
+var qweb = core.qweb;
+
+ajax.loadXML('/web_editor/static/src/xml/translator.xml', qweb);
+
+
+var translatable = !!$('html').data('translatable');
+var edit_translations = !!$('html').data('edit_translations');
+
+function unbind_click(event) {
+ if (event.ctrlKey || !$(event.target).is(':o_editable')) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+}
+
+
+var RTE_Translate = rte.Class.extend({
+ saveElement: function ($el, context) {
+ // remove multi edition
+ if ($el.data('oe-translation-id')) {
+ var key = 'translation:'+$el.data('oe-translation-id');
+ if (this.__saved[key]) return true;
+ this.__saved[key] = true;
+
+ return ajax.jsonRpc('/web/dataset/call', 'call', {
+ model: 'ir.translation',
+ method: 'write',
+ args: [
+ [+$el.data('oe-translation-id')],
+ {value: $el.html(), state: 'translated'},
+ context || base.get_context()
+ ],
+ });
+ }
+
+ return this._super.apply(this, arguments);
+ },
+});
+
+var Translate = Widget.extend({
+ events: {
+ 'click [data-action="save"]': 'save_and_reload',
+ 'click [data-action="cancel"]': 'cancel',
+ },
+ template: 'web_editor.translator',
+ init: function (parent, $target, lang) {
+ this.parent = parent;
+ this.ir_translation = new Model('ir.translation');
+ this.lang = lang || base.get_context().lang;
+ this.setTarget($target);
+ this._super();
+
+ this.rte = new RTE_Translate(this, this.config);
+ this.rte.on('change', this, this.onChange);
+ },
+ start: function () {
+ this._super();
+ return this.edit();
+ },
+ setTarget: function ($target) {
+ this.$target = $target.find('[data-oe-translation-id], [data-oe-model][data-oe-id][data-oe-field]');
+ },
+ find: function (selector) {
+ return selector ? this.$target.find(selector).addBack().filter(selector) : this.$target;
+ },
+ edit: function () {
+ var flag = false;
+ window.onbeforeunload = function(event) {
+ if ($('.o_editable.o_dirty').length && !flag) {
+ flag = true;
+ setTimeout(function () {flag=false;},0);
+ return _t('This document is not saved!');
+ }
+ };
+ this.$target.addClass("o_editable");
+ this.rte.start();
+ this.translations = [];
+ this.markTranslatableNodes();
+ this.onTranslateReady();
+ },
+ onTranslateReady: function () {
+ this.$el.show();
+ this.trigger("edit");
+ },
+ onChange: function (node) {
+ var $node = $(node);
+ var trans = this.getTranlationObject($node[0]);
+ $node.toggleClass('o_dirty', trans.value !== $node.html().replace(/[ \t\n\r]+/, ' '));
+ },
+ getTranlationObject: function (node) {
+ var $node = $(node);
+ var id = +$node.data('oe-translation-id');
+ if (!id) {
+ id = $node.data('oe-model')+','+$node.data('oe-id')+','+$node.data('oe-field');
+ }
+ var trans = _.find(this.translations, function (trans) {
+ return trans.id === id;
+ });
+ if (!trans) {
+ this.translations.push(trans = {'id': id});
+ }
+ return trans;
+ },
+ markTranslatableNodes: function (node) {
+ var self = this;
+ this.$target.each(function () {
+ var $node = $(this);
+ var trans = self.getTranlationObject(this);
+ trans.value = (trans.value ? trans.value : $node.html() ).replace(/[ \t\n\r]+/, ' ');
+ });
+ this.$target.parent().on('click', unbind_click);
+ console.info('Click on CTRL when you click in an translatable area to have the default behavior');
+ },
+ unarkTranslatableNode: function () {
+ this.$target.removeClass('o_editable').removeAttr('contentEditable');
+ this.$target.parent().off('click', this.__unbind_click);
+ this.$target_attr.off('mousedown click mouseup', this.__translate_attribute);
+ },
+ save_and_reload: function () {
+ return this.save().then(function () {
+ window.location.href = window.location.href.replace(/&?edit_translations(=[^&]*)?/g, '');
+ });
+ },
+ save: function () {
+ var context = base.get_context();
+ context.lang = this.lang;
+ return this.rte.save(context);
+ },
+ cancel: function () {
+ var self = this;
+ this.rte.cancel();
+ this.$target.each(function () {
+ $(this).html(self.getTranlationObject(this).value);
+ });
+ this.unarkTranslatableNode();
+ this.trigger("cancel");
+ this.$el.hide();
+ },
+ destroy: function () {
+ this.cancel();
+ this.$el.remove();
+ this._super();
+ },
+
+ config: function ($editable) {
+ if ($editable.data('oe-model')) {
+ return {
+ 'airMode' : true,
+ 'focus': false,
+ 'airPopover': [
+ ['history', ['undo', 'redo']],
+ ],
+ 'styleWithSpan': false,
+ 'inlinemedia' : ['p'],
+ 'lang': "odoo",
+ 'onChange': function (html, $editable) {
+ $editable.trigger("content_changed");
+ }
+ };
+ }
+ return {
+ 'airMode' : true,
+ 'focus': false,
+ 'airPopover': [
+ ['font', ['bold', 'italic', 'underline', 'clear']],
+ ['fontsize', ['fontsize']],
+ ['color', ['color']],
+ ['history', ['undo', 'redo']],
+ ],
+ 'styleWithSpan': false,
+ 'inlinemedia' : ['p'],
+ 'lang': "odoo",
+ 'onChange': function (html, $editable) {
+ $editable.trigger("content_changed");
+ }
+ };
+ }
+});
+
+
+if (edit_translations) {
+ base.ready().then(function () {
+ data.instance = new Translate(this, $('#wrapwrap'));
+ data.instance.prependTo(document.body);
+
+ $('a[href*=edit_translations]').each(function () {
+ this.href = this.href.replace(/[$?]edit_translations[^&?]+/, '');
+ });
+ $('form[action*=edit_translations]').each(function () {
+ this.action = this.action.replace(/[$?]edit_translations[^&?]+/, '');
+ });
+ });
+}
+
+var data = {
+ 'translatable': translatable,
+ 'edit_translations': edit_translations,
+ 'Class': Translate,
+};
+return data;
+
+});
diff --git a/addons/web_editor/static/src/js/widgets.js b/addons/web_editor/static/src/js/widgets.js
new file mode 100644
index 00000000000..6fab80ad802
--- /dev/null
+++ b/addons/web_editor/static/src/js/widgets.js
@@ -0,0 +1,1069 @@
+odoo.define('web_editor.widget', function (require) {
+'use strict';
+
+var core = require('web.core');
+var ajax = require('web.ajax');
+var Widget = require('web.Widget');
+var base = require('web_editor.base');
+var rte = require('web_editor.rte');
+
+var QWeb = core.qweb;
+var range = $.summernote.core.range;
+var dom = $.summernote.core.dom;
+
+//////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+var Dialog = Widget.extend({
+ events: {
+ 'hidden.bs.modal': 'destroy',
+ 'click button.save': 'save',
+ 'click button[data-dismiss="modal"]': 'cancel',
+ },
+ init: function () {
+ this._super();
+ },
+ start: function () {
+ var sup = this._super();
+ this.$el.modal({backdrop: 'static'});
+ this.$('input:first').focus();
+ return sup;
+ },
+ save: function () {
+ this.close();
+ this.trigger("saved");
+ },
+ cancel: function () {
+ this.trigger("cancel");
+ },
+ close: function () {
+ this.$el.modal('hide');
+ },
+});
+
+/**
+ * alt widget. Lets users change a alt & title on a media
+ */
+var alt = Dialog.extend({
+ template: 'web_editor.dialog.alt',
+ init: function ($editable, media) {
+ this.$editable = $editable;
+ this.media = media;
+ this.alt = ($(this.media).attr('alt') || "").replace(/&quot;/g, '"');
+ this.title = ($(this.media).attr('title') || "").replace(/&quot;/g, '"');
+ return this._super();
+ },
+ save: function () {
+ var self = this;
+ range.createFromNode(self.media).select();
+ this.$editable.data('NoteHistory').recordUndo();
+ var alt = this.$('#alt').val();
+ var title = this.$('#title').val();
+ $(this.media).attr('alt', alt ? alt.replace(/"/g, "&quot;") : null).attr('title', title ? title.replace(/"/g, "&quot;") : null);
+ setTimeout(function () {
+ click_event(self.media, "mouseup");
+ },0);
+ return this._super();
+ },
+});
+
+var click_event = function(el, type) {
+ var evt = document.createEvent("MouseEvents");
+ evt.initMouseEvent(type, true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, el);
+ el.dispatchEvent(evt);
+};
+
+/**
+ * MediaDialog widget. Lets users change a media, including uploading a
+ * new image, font awsome or video and can change a media into an other
+ * media
+ *
+ * options: select_images: allow the selection of more of one image
+ */
+var MediaDialog = Dialog.extend({
+ template: 'web_editor.dialog.media',
+ events : _.extend({}, Dialog.prototype.events, {
+ 'input input#icon-search': 'search',
+ }),
+ init: function ($editable, media, options) {
+ this._super();
+ if ($editable) {
+ this.$editable = $editable;
+ this.rte = this.$editable.rte || this.$editable.data('rte');
+ }
+ this.options = options || {};
+ this.old_media = media;
+ this.media = media;
+ this.isNewMedia = !media;
+ this.range = range.create();
+ },
+ start: function () {
+ var self = this;
+
+ this.only_images = this.options.only_images || this.options.select_images || (this.media && $(this.media).parent().data("oe-field") === "image");
+ if (this.only_images) {
+ this.$('[href="#editor-media-video"], [href="#editor-media-icon"]').addClass('hidden');
+ }
+
+ if (this.media) {
+ if (this.media.nodeName === "IMG") {
+ this.$('[href="#editor-media-image"]').tab('show');
+ } else if (this.media.className.match(/(^|\s)media_iframe_video($|\s)/)) {
+ this.$('[href="#editor-media-video"]').tab('show');
+ } else if (this.media.parentNode.className.match(/(^|\s)media_iframe_video($|\s)/)) {
+ this.media = this.media.parentNode;
+ this.$('[href="#editor-media-video"]').tab('show');
+ } else if (this.media.className.match(/(^|\s)fa($|\s)/)) {
+ this.$('[href="#editor-media-icon"]').tab('show');
+ }
+ }
+
+ this.imageDialog = new ImageDialog(this, this.media, this.options);
+ this.imageDialog.appendTo(this.$("#editor-media-image"));
+ this.iconDialog = new fontIconsDialog(this, this.media, this.options);
+ this.iconDialog.appendTo(this.$("#editor-media-icon"));
+ this.videoDialog = new VideoDialog(this, this.media, this.options);
+ this.videoDialog.appendTo(this.$("#editor-media-video"));
+
+ this.active = this.imageDialog;
+
+ $('a[data-toggle="tab"]').on('shown.bs.tab', function (event) {
+ if ($(event.target).is('[href="#editor-media-image"]')) {
+ self.active = self.imageDialog;
+ self.$('li.search, li.previous, li.next').removeClass("hidden");
+ } else if ($(event.target).is('[href="#editor-media-icon"]')) {
+ self.active = self.iconDialog;
+ self.$('li.search, li.previous, li.next').removeClass("hidden");
+ self.$('.nav-tabs li.previous, .nav-tabs li.next').addClass("hidden");
+ } else if ($(event.target).is('[href="#editor-media-video"]')) {
+ self.active = self.videoDialog;
+ self.$('.nav-tabs li.search').addClass("hidden");
+ }
+ });
+
+ return this._super();
+ },
+ save: function () {
+ if (this.options.select_images) {
+ this.trigger("saved", this.active.save());
+ this.close();
+ return;
+ }
+ if(this.rte) {
+ this.range.select();
+ this.rte.historyRecordUndo(this.media);
+ }
+
+ var self = this;
+ if (self.media) {
+ this.media.innerHTML = "";
+ if (this.active !== this.imageDialog) {
+ this.imageDialog.clear();
+ }
+ if (this.active !== this.iconDialog) {
+ this.iconDialog.clear();
+ }
+ if (this.active !== this.videoDialog) {
+ this.videoDialog.clear();
+ }
+ } else {
+ this.media = document.createElement("img");
+ this.range.insertNode(this.media, true);
+ this.active.media = this.media;
+ }
+ this.active.save();
+
+ if (this.active.add_class) {
+ $(this.media).addClass(this.active.add_class);
+ }
+
+ $(document.body).trigger("media-saved", [self.active.media, self.old_media]);
+ self.trigger("saved", [self.active.media, self.old_media]);
+ setTimeout(function () {
+ if (!self.active.media.parentNode) {
+ return;
+ }
+ range.createFromNode(self.active.media).select();
+ click_event(self.active.media, "mousedown");
+ if (!this.only_images) {
+ setTimeout(function () {
+ if($(self.active.media).parent().data("oe-field") !== "image") {
+ click_event(self.active.media, "click");
+ }
+ click_event(self.active.media, "mouseup");
+ },0);
+ }
+ },0);
+
+ this.close();
+ },
+ searchTimer: null,
+ search: function () {
+ var self = this;
+ var needle = this.$("input#icon-search").val();
+ clearTimeout(this.searchTimer);
+ this.searchTimer = setTimeout(function () {
+ self.active.search(needle || "");
+ },250);
+ }
+});
+
+/**
+ * ImageDialog widget. Lets users change an image, including uploading a
+ * new image in OpenERP or selecting the image style (if supported by
+ * the caller).
+ */
+var IMAGES_PER_ROW = 6;
+var IMAGES_ROWS = 2;
+var ImageDialog = Widget.extend({
+ template: 'web_editor.dialog.image',
+ events: _.extend({}, Dialog.prototype.events, {
+ 'change .url-source': function (e) {
+ this.changed($(e.target));
+ },
+ 'click button.filepicker': function () {
+ var filepicker = this.$('input[type=file]');
+ if (!_.isEmpty(filepicker)){
+ filepicker[0].click();
+ }
+ },
+ 'click .js_disable_optimization': function () {
+ this.$('input[name="disable_optimization"]').val('1');
+ var filepicker = this.$('button.filepicker');
+ if (!_.isEmpty(filepicker)){
+ filepicker[0].click();
+ }
+ },
+ 'change input[type=file]': 'file_selection',
+ 'submit form': 'form_submit',
+ 'change input.url': "change_input",
+ 'keyup input.url': "change_input",
+ //'change select.image-style': 'preview_image',
+ 'click .existing-attachments img': 'select_existing',
+ 'click .existing-attachment-remove': 'try_remove',
+ }),
+ init: function (parent, media, options) {
+ this._super();
+ this.options = options || {};
+ this.parent = parent;
+ this.media = media;
+ this.images = [];
+ this.page = 0;
+ },
+ start: function () {
+ this.$preview = this.$('.preview-container').detach();
+ var self = this;
+ var res = this._super();
+ var o = { url: null, alt: null };
+ // avoid typos, prevent addition of new properties to the object
+ Object.preventExtensions(o);
+
+ if ($(this.media).is("img")) {
+ o.url = this.media.getAttribute('src');
+ } else {
+ this.add_class = "img-responsive pull-left";
+ }
+ this.parent.$(".pager > li").click(function (e) {
+ e.preventDefault();
+ var $target = $(e.currentTarget);
+ if ($target.hasClass('disabled')) {
+ return;
+ }
+ self.page += $target.hasClass('previous') ? -1 : 1;
+ self.display_attachments();
+ });
+ this.set_image(o.url, o.alt);
+ this.fetch_existing();
+ return res;
+ },
+ push: function (url, alt, id) {
+ if (this.options.select_images) {
+ var img = _.select(this.images, function (v) { return v.url == url;});
+ if (img.length) {
+ this.images.splice(this.images.indexOf(img[0]),1);
+ return;
+ }
+ } else {
+ this.images = [];
+ }
+ this.images.push({'url': url, 'alt': alt, 'id': id});
+ },
+ save: function () {
+ if (this.options.select_images) {
+ this.parent.trigger("save", this.images);
+ return this.images;
+ }
+ this.parent.trigger("save", this.media);
+
+ var img = this.images[0] || {
+ 'url': this.$(".existing-attachments img:first").attr('src'),
+ 'alt': this.$(".existing-attachments img:first").attr('alt')
+ };
+
+ if (this.media.tagName !== "IMG") {
+ var media = document.createElement('img');
+ $(this.media).replaceWith(media);
+ this.media = media;
+ }
+
+ $(this.media).attr('src', img.url).attr('alt', img.alt);
+
+ var style = this.style;
+ this.media.setAttribute('src', img.url);
+ if (style) { this.media.addClass(style); }
+
+ return this.media;
+ },
+ clear: function () {
+ this.media.className = this.media.className.replace(/(^|\s)(img(\s|$)|img-[^\s]*)/g, ' ');
+ },
+ cancel: function () {
+ this.trigger('cancel');
+ },
+ change_input: function (e) {
+ var $input = $(e.target);
+ var $button = $input.parent().find("button");
+ if ($input.val() === "") {
+ $button.addClass("btn-default").removeClass("btn-primary");
+ } else {
+ $button.removeClass("btn-default").addClass("btn-primary");
+ }
+ },
+ search: function (needle) {
+ var self = this;
+ this.fetch_existing(needle).then(function () {
+ self.selected_existing();
+ });
+ },
+ set_image: function (url, alt, error) {
+ var self = this;
+ if (url) {
+ this.push(url, alt);
+ }
+ this.$('input.url').val('');
+ this.fetch_existing().then(function () {
+ self.selected_existing();
+ });
+ },
+ form_submit: function (event) {
+ var self = this;
+ var $form = this.$('form[action="/web_editor/attachment/add"]');
+ if (!$form.find('input[name="upload"]').val().length) {
+ var url = $form.find('input[name="url"]').val();
+ if (this.selected_existing().size()) {
+ event.preventDefault();
+ return false;
+ }
+ }
+ $form.find('.well > div').hide().last().after('<span class="fa fa-spin fa-3x fa-refresh"/>');
+
+ var callback = _.uniqueId('func_');
+ this.$('input[name=func]').val(callback);
+ window[callback] = function (attachments, error) {
+ delete window[callback];
+ $form.find('.well > span').remove();
+ $form.find('.well > div').show();
+ if (error || !attachments.length) {
+ self.file_selected(null, error || !attachments.length);
+ }
+ for (var i=0; i<attachments.length; i++) {
+ self.file_selected(attachments[i]['local_url'] || '/web_editor/image/ir.attachment/'+attachments[i].id+'_'+(new Date().getTime())+'/datas', error);
+ }
+ };
+ },
+ file_selection: function () {
+ this.$el.addClass('nosave');
+ this.$('form').removeClass('has-error').find('.help-block').empty();
+ this.$('button.filepicker').removeClass('btn-danger btn-success');
+ this.$('form').submit();
+ },
+ file_selected: function(url, error) {
+ var $button = this.$('button.filepicker');
+ if (!error) {
+ $button.addClass('btn-success');
+ } else {
+ url = null;
+ this.$('form').addClass('has-error')
+ .find('.help-block').text(error);
+ $button.addClass('btn-danger');
+ }
+ this.set_image(url, null, error);
+
+ if (!this.options.select_images) {
+ // auto save and close popup
+ this.parent.save();
+ }
+ },
+ fetch_existing: function (needle) {
+ var domain = [['res_model', '=', 'ir.ui.view'], '|',
+ ['mimetype', '=', false], ['mimetype', '=like', 'image/%']];
+ if (needle && needle.length) {
+ domain.push('|', ['datas_fname', 'ilike', needle], ['name', 'ilike', needle]);
+ }
+ return ajax.jsonRpc('/web/dataset/call_kw', 'call', {
+ model: 'ir.attachment',
+ method: 'search_read',
+ args: [],
+ kwargs: {
+ domain: domain,
+ fields: ['name', 'local_url'],
+ order: 'id desc',
+ context: base.get_context()
+ }
+ }).then(this.proxy('fetched_existing'));
+ },
+ fetched_existing: function (records) {
+ this.records = records;
+ this.display_attachments();
+ },
+ display_attachments: function () {
+ this.$('.help-block').empty();
+ var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
+
+ var from = this.page * per_screen;
+ var records = this.records;
+
+ // Create rows of 3 records
+ var rows = _(records).chain()
+ .slice(from, from + per_screen)
+ .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
+ .values()
+ .value();
+ this.$('.existing-attachments').replaceWith(
+ QWeb.render(
+ 'web_editor.dialog.image.existing.content', {rows: rows}));
+ this.parent.$('.pager')
+ .find('li.previous').toggleClass('disabled', (from === 0)).end()
+ .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
+
+ this.selected_existing();
+ },
+ select_existing: function (e) {
+ var $img = $(e.currentTarget);
+ this.push($img.attr('src'), $img.attr('alt'), $img.data('id'));
+ this.selected_existing();
+ },
+ selected_existing: function () {
+ var self = this;
+ this.$('.existing-attachment-cell.media_selected').removeClass("media_selected");
+ var $select = this.$('.existing-attachment-cell img').filter(function () {
+ var $img = $(this);
+ var url = $img.attr("src");
+ return !!_.select(self.images, function (v) {
+ if (v.url === url) {
+ if (!v.id) {
+ v.id = $img.data('id');
+ v.alt = $img.attr('alt');
+ }
+ return true;
+ }
+ }).length;
+ });
+ $select.parent().addClass("media_selected");
+ return $select;
+ },
+ try_remove: function (e) {
+ var $help_block = this.$('.help-block').empty();
+ var self = this;
+ var $a = $(e.target);
+ var id = parseInt($a.data('id'), 10);
+ var attachment = _.findWhere(this.records, {id: id});
+ var $both = $a.parent().children();
+
+ $both.css({borderWidth: "5px", borderColor: "#f00"});
+
+ return ajax.jsonRpc('/web_editor/attachment/remove', 'call', {'ids': [id]}).then(function (prevented) {
+ if (_.isEmpty(prevented)) {
+ self.records = _.without(self.records, attachment);
+ self.display_attachments();
+ return;
+ }
+ $both.css({borderWidth: "", borderColor: ""});
+ $help_block.replaceWith(QWeb.render(
+ 'web_editor.dialog.image.existing.error', {
+ views: prevented[id]
+ }
+ ));
+ });
+ },
+});
+
+
+var cacheCssSelectors = {};
+var getCssSelectors = function(filter) {
+ var css = [];
+ if (cacheCssSelectors[filter]) {
+ return cacheCssSelectors[filter];
+ }
+ var sheets = document.styleSheets;
+ for(var i = 0; i < sheets.length; i++) {
+ var rules = sheets[i].rules || sheets[i].cssRules;
+ if (rules) {
+ for(var r = 0; r < rules.length; r++) {
+ var selectorText = rules[r].selectorText;
+ if (selectorText) {
+ var match = selectorText.match(filter);
+ if (match) {
+ css.push([match[1], rules[r].cssText.replace(/(^.*\{\s*)|(\s*\}\s*$)/g, '')]);
+ }
+ }
+ }
+ }
+ }
+ return cacheCssSelectors[filter] = css;
+};
+function computeFonts() {
+ _.each(fontIcons, function (data) {
+ data.icons = _.map(getCssSelectors(data.parser), function (css) {
+ return css[0].slice(1, css[0].length).replace(/::?before$/, '');
+ });
+ });
+}
+
+rte.Class.include({
+ init: function (EditorBar) {
+ this._super.apply(this, arguments);
+ computeFonts();
+ }
+});
+
+/* 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 (eg: fa fa-refresh)
+ * - parser: regular expression used to select all font in css style sheets
+ */
+var fontIcons = [{'base': 'fa', 'parser': /(?=^|\s)(\.fa-[0-9a-z_-]+::?before)/i}];
+
+
+/**
+ * FontIconsDialog widget. Lets users change a font awsome, suport all
+ * font awsome loaded in the css files.
+ */
+var fontIconsDialog = Widget.extend({
+ template: 'web_editor.dialog.font-icons',
+ events : _.extend({}, Dialog.prototype.events, {
+ 'click .font-icons-icon': function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.$('#fa-icon').val(e.target.getAttribute('data-id'));
+ $(".font-icons-icon").removeClass("font-icons-selected");
+ $(event.target).addClass("font-icons-selected");
+ },
+ }),
+
+ // extract list of font (like awsome) from the cheatsheet.
+ renderElement: function() {
+ this.iconsParser = fontIcons;
+ this.icons = _.flatten(_.map(fontIcons, function (data) {
+ return data.icons;
+ }));
+ this._super();
+ },
+
+ init: function (parent, media) {
+ this._super();
+ this.parent = parent;
+ this.media = media;
+ if (_.find(fontIcons, function (data) {return !data.icons;})) {
+ computeFonts();
+ }
+ },
+ start: function () {
+ return this._super().then(this.proxy('load_data'));
+ },
+ search: function (needle) {
+ var iconsParser = this.iconsParser;
+ if (needle) {
+ var parser = [];
+ var fontIcon;
+
+ for (var k=0; k<fontIcons.length; k++) {
+ fontIcon = fontIcons[k];
+ var icons = _(fontIcon.icons).filter(function (icon) {
+ return icon.indexOf(needle) !== -1;
+ });
+ if (icons.length) {
+ parser.push({
+ base: fontIcon.base,
+ icons: icons
+ });
+ }
+ }
+ iconsParser = parser;
+ }
+ this.$('div.font-icons-icons').html(
+ QWeb.render('web_editor.dialog.font-icons.icons', {'iconsParser': iconsParser}));
+ },
+ /**
+ * Removes existing FontAwesome classes on the bound element, and sets
+ * all the new ones if necessary.
+ */
+ save: function () {
+ var self = this;
+ this.parent.trigger("save", this.media);
+ var icons = this.icons;
+ var style = this.media.attributes.style ? this.media.attributes.style.value : '';
+ var classes = (this.media.className||"").split(/\s+/);
+ var non_fa_classes = _.reject(classes, function (cls) {
+ return self.getFont(cls);
+ });
+ var final_classes = non_fa_classes.concat(this.get_fa_classes());
+ if (this.media.tagName !== "SPAN") {
+ var media = document.createElement('span');
+ $(this.media).replaceWith(media);
+ this.media = media;
+ style = style.replace(/\s*width:[^;]+/, '');
+ }
+ $(this.media).attr("class", _.compact(final_classes).join(' ')).attr("style", style);
+ },
+ /**
+ * return the data font object (with base, parser and icons) or null
+ */
+ getFont: function (classNames) {
+ if (!(classNames instanceof Array)) {
+ classNames = (classNames||"").split(/\s+/);
+ }
+ var fontIcon, className;
+ for (var i=0; i<classNames.length; i++) {
+ className = classNames[i];
+ for (var k=0; k<fontIcons.length; k++) {
+ fontIcon = fontIcons[k];
+ if (className === fontIcon.base || fontIcon.icons.indexOf(className) !== -1) {
+ return {
+ 'base': fontIcon.base,
+ 'parser': fontIcon.parser,
+ 'icons': fontIcon.icons,
+ 'font': className
+ };
+ }
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Looks up the various FontAwesome classes on the bound element and
+ * sets the corresponding template/form elements to the right state.
+ * If multiple classes of the same category are present on an element
+ * (e.g. fa-lg and fa-3x) the last one occurring will be selected,
+ * which may not match the visual look of the element.
+ */
+ load_data: function () {
+ var classes = (this.media&&this.media.className||"").split(/\s+/);
+ for (var i = 0; i < classes.length; i++) {
+ var cls = classes[i];
+ switch(cls) {
+ case 'fa-1x':case 'fa-2x':case 'fa-3x':case 'fa-4x':case 'fa-5x':
+ // size classes
+ this.$('#fa-size').val(cls);
+ continue;
+ case 'fa-spin':
+ case 'fa-rotate-90':case 'fa-rotate-180':case 'fa-rotate-270':
+ case 'fa-flip-horizontal':case 'fa-rotate-vertical':
+ this.$('#fa-rotation').val(cls);
+ continue;
+ case 'fa-fw':
+ continue;
+ case 'fa-border':
+ this.$('#fa-border').prop('checked', true);
+ continue;
+ case '': continue;
+ default:
+ $(".font-icons-icon").removeClass("font-icons-selected").filter("."+cls).addClass("font-icons-selected");
+ for (var k=0; k<this.icons.length; k++) {
+ if (this.icons.indexOf(cls) !== -1) {
+ this.$('#fa-icon').val(cls);
+ break;
+ }
+ }
+ }
+ }
+ },
+ /**
+ * Serializes the dialog to an array of FontAwesome classes. Includes
+ * the base ``fa``.
+ */
+ get_fa_classes: function () {
+ var font = this.getFont(this.$('#fa-icon').val());
+ return [
+ font ? font.base : 'fa',
+ font ? font.font : "",
+ this.$('#fa-size').val(),
+ this.$('#fa-rotation').val(),
+ this.$('#fa-border').prop('checked') ? 'fa-border' : ''
+ ];
+ },
+ clear: function () {
+ this.media.className = this.media.className.replace(/(^|\s)(fa(\s|$)|fa-[^\s]*)/g, ' ');
+ },
+});
+
+
+function createVideoNode(url) {
+ // video url patterns(youtube, instagram, vimeo, dailymotion, youku)
+ var ytRegExp = /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/;
+ var ytMatch = url.match(ytRegExp);
+
+ var igRegExp = /\/\/instagram.com\/p\/(.[a-zA-Z0-9]*)/;
+ var igMatch = url.match(igRegExp);
+
+ var vRegExp = /\/\/vine.co\/v\/(.[a-zA-Z0-9]*)/;
+ var vMatch = url.match(vRegExp);
+
+ var vimRegExp = /\/\/(player.)?vimeo.com\/([a-z]*\/)*([0-9]{6,11})[?]?.*/;
+ var vimMatch = url.match(vimRegExp);
+
+ var dmRegExp = /.+dailymotion.com\/(video|hub)\/([^_]+)[^#]*(#video=([^_&]+))?/;
+ var dmMatch = url.match(dmRegExp);
+
+ var youkuRegExp = /\/\/v\.youku\.com\/v_show\/id_(\w+)\.html/;
+ var youkuMatch = url.match(youkuRegExp);
+
+ var $video = $('<iframe>');
+ if (ytMatch && ytMatch[1].length === 11) {
+ var youtubeId = ytMatch[1];
+ $video = $('<iframe>')
+ .attr('src', '//www.youtube.com/embed/' + youtubeId)
+ .attr('width', '640').attr('height', '360');
+ } else if (igMatch && igMatch[0].length) {
+ $video = $('<iframe>')
+ .attr('src', igMatch[0] + '/embed/')
+ .attr('width', '612').attr('height', '710')
+ .attr('scrolling', 'no')
+ .attr('allowtransparency', 'true');
+ } else if (vMatch && vMatch[0].length) {
+ $video = $('<iframe>')
+ .attr('src', vMatch[0] + '/embed/simple')
+ .attr('width', '600').attr('height', '600')
+ .attr('class', 'vine-embed');
+ } else if (vimMatch && vimMatch[3].length) {
+ $video = $('<iframe webkitallowfullscreen mozallowfullscreen allowfullscreen>')
+ .attr('src', '//player.vimeo.com/video/' + vimMatch[3])
+ .attr('width', '640').attr('height', '360');
+ } else if (dmMatch && dmMatch[2].length) {
+ $video = $('<iframe>')
+ .attr('src', '//www.dailymotion.com/embed/video/' + dmMatch[2])
+ .attr('width', '640').attr('height', '360');
+ } else if (youkuMatch && youkuMatch[1].length) {
+ $video = $('<iframe webkitallowfullscreen mozallowfullscreen allowfullscreen>')
+ .attr('height', '498')
+ .attr('width', '510')
+ .attr('src', '//player.youku.com/embed/' + youkuMatch[1]);
+ } else {
+ // this is not a known video link. Now what, Cat? Now what?
+ }
+
+ $video.attr('frameborder', 0);
+
+ return $video;
+}
+
+/**
+ * VideoDialog widget. Lets users change a video, support all summernote
+ * video, and embled iframe
+ */
+var VideoDialog = Widget.extend({
+ template: 'web_editor.dialog.video',
+ events : _.extend({}, Dialog.prototype.events, {
+ 'click input#urlvideo ~ button': 'get_video',
+ 'click input#embedvideo ~ button': 'get_embed_video',
+ 'change input#urlvideo': 'change_input',
+ 'keyup input#urlvideo': 'change_input',
+ 'change input#embedvideo': 'change_input',
+ 'keyup input#embedvideo': 'change_input'
+ }),
+ init: function (parent, media) {
+ this._super();
+ this.parent = parent;
+ this.media = media;
+ },
+ start: function () {
+ this.$preview = this.$('.preview-container').detach();
+ this.$iframe = this.$("iframe");
+ var $media = $(this.media);
+ if ($media.hasClass("media_iframe_video")) {
+ var src = $media.data('src');
+ this.$("input#urlvideo").val(src);
+ this.$("#autoplay").attr("checked", src.indexOf('autoplay=1') != -1);
+ this.get_video();
+ } else {
+ this.add_class = "pull-left";
+ }
+ return this._super();
+ },
+ change_input: function (e) {
+ var $input = $(e.target);
+ var $button = $input.parent().find("button");
+ if ($input.val() === "") {
+ $button.addClass("btn-default").removeClass("btn-primary");
+ } else {
+ $button.removeClass("btn-default").addClass("btn-primary");
+ }
+ },
+ get_embed_video: function (event) {
+ event.preventDefault();
+ var embedvideo = this.$("input#embedvideo").val().match(/src=["']?([^"']+)["' ]?/);
+ if (embedvideo) {
+ this.$("input#urlvideo").val(embedvideo[1]);
+ this.get_video(event);
+ }
+ return false;
+ },
+ get_video: function (event) {
+ if (event) event.preventDefault();
+ var $video = createVideoNode(this.$("input#urlvideo").val());
+ this.$iframe.replaceWith($video);
+ this.$iframe = $video;
+ return false;
+ },
+ save: function () {
+ this.parent.trigger("save", this.media);
+ var video_id = this.$("#video_id").val();
+ if (!video_id) {
+ this.$("button.btn-primary").click();
+ video_id = this.$("#video_id").val();
+ }
+ var video_type = this.$("#video_type").val();
+ var $iframe = $(
+ '<div class="media_iframe_video" data-src="'+this.$iframe.attr("src")+'">'+
+ '<div class="css_editable_mode_display">&nbsp;</div>'+
+ '<div class="media_iframe_video_size" contentEditable="false">&nbsp;</div>'+
+ '<iframe src="'+this.$iframe.attr("src")+'" frameborder="0" allowfullscreen="allowfullscreen" contentEditable="false"></iframe>'+
+ '</div>');
+ $(this.media).replaceWith($iframe);
+ this.media = $iframe[0];
+ },
+ clear: function () {
+ if (this.media.dataset.src) {
+ try {
+ delete this.media.dataset.src;
+ } catch(e) {
+ this.media.dataset.src = undefined;
+ }
+ }
+ this.media.className = this.media.className.replace(/(^|\s)media_iframe_video(\s|$)/g, ' ');
+ },
+});
+
+/* ----- EDITOR: LINK & MEDIA ---- */
+var LinkDialog = Dialog.extend({
+ template: 'web_editor.dialog.link',
+ events: _.extend({}, Dialog.prototype.events, {
+ 'change :input.url-source': 'changed',
+ 'keyup :input.url': 'onkeyup',
+ 'keyup :input': 'preview',
+ 'click button.remove': 'remove_link',
+ 'change input#link-text': function (e) {
+ this.text = $(e.target).val();
+ },
+ 'change .link-style': function (e) {
+ this.preview();
+ },
+ }),
+ init: function (editable, linkInfo) {
+ this._super(editable, linkInfo);
+ this.editable = editable;
+ this.data = linkInfo || {};
+
+ this.data.className = "";
+ if (this.data.range) {
+ this.data.iniClassName = $(this.data.range.sc).filter("a").attr("class") || "";
+ this.data.className = this.data.iniClassName.replace(/(^|\s+)btn(-[a-z0-9_-]*)?/gi, ' ');
+
+ var is_link = this.data.range.isOnAnchor();
+ var r = this.data.range;
+
+ 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);
+ 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 && 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, ' ');
+ },
+ start: function () {
+ this.bind_data();
+ this.$('input.url-source:eq(1)').closest('.list-group-item').addClass('active');
+ return this._super();
+ },
+ get_data: function (test) {
+ var self = this;
+ var def = new $.Deferred();
+ var $e = this.$('.active input.url-source');
+ if (!$e.length) {
+ $e = this.$('input.url-source:first');
+ }
+ var val = $e.val();
+ var label = this.$('#link-text').val() || val;
+
+ if (label && this.data.images) {
+ for(var i=0; i<this.data.images.length; i++) {
+ label = label.replace(/</, "&lt;").replace(/>/, "&gt;").replace(/\[IMG\]/, this.data.images[i].outerHTML);
+ }
+ }
+
+ if (!test && (!val || !$e[0].checkValidity())) {
+ // FIXME: error message
+ $e.closest('.form-group').addClass('has-error');
+ $e.focus();
+ def.reject();
+ }
+
+ var style = this.$("input[name='link-style-type']:checked").val() || '';
+ var size = this.$("input[name='link-style-size']:checked").val() || '';
+ var classes = (this.data.className || "") + (style && style.length ? " btn " : "") + style + " " + size;
+ var isNewWindow = this.$('input.window-new').prop('checked');
+
+ if ($e.hasClass('email-address') && $e.val().indexOf("@") !== -1) {
+ self.get_data_buy_mail(def, $e, isNewWindow, label, classes);
+ } else {
+ self.get_data_buy_url(def, $e, isNewWindow, label, classes);
+ }
+ return def;
+ },
+ get_data_buy_mail: function (def, $e, isNewWindow, label, classes) {
+ var val = $e.val();
+ def.resolve(val.indexOf("mailto:") === 0 ? val : 'mailto:' + val, isNewWindow, label, classes);
+ },
+ get_data_buy_url: function (def, $e, isNewWindow, label, classes) {
+ def.resolve($e.val(), isNewWindow, label, classes);
+ },
+ save: function () {
+ var self = this;
+ var _super = this._super.bind(this);
+ return this.get_data()
+ .then(function (url, new_window, label, classes) {
+ self.data.url = url;
+ self.data.newWindow = new_window;
+ self.data.text = label;
+ self.data.className = classes.replace(/\s+/gi, ' ').replace(/^\s+|\s+$/gi, '');
+
+ self.trigger("save", self.data);
+ }).then(_super);
+ },
+ bind_data: function () {
+ var href = this.data.url;
+ var new_window = this.data.isNewWindow;
+ var text = this.data.text;
+ var classes = this.data.iniClassName;
+
+ this.$('input#link-text').val(text);
+ this.$('input.window-new').prop('checked', new_window);
+
+ if (classes) {
+ this.$('input[value!=""]').each(function () {
+ var $option = $(this);
+ if (classes.indexOf($option.val()) !== -1) {
+ $option.attr("checked", "checked");
+ }
+ });
+ }
+
+ if (href) {
+ var match;
+ if(match = /mailto:(.+)/.exec(href)) {
+ this.$('input.email-address').val(match = /mailto:(.+)/.exec(href) ? match[1] : '');
+ } else {
+ this.$('input.url').val(href);
+ }
+ }
+ this.preview();
+ },
+ changed: function (e) {
+ $(e.target).closest('.list-group-item')
+ .addClass('active')
+ .siblings().removeClass('active')
+ .addBack().removeClass('has-error');
+ this.preview();
+ },
+ onkeyup: function (e) {
+ var $e = $(e.target);
+ var is_link = ($e.val()||'').length && $e.val().indexOf("@") === -1;
+ this.$('input.window-new').closest("div").toggle(is_link);
+ this.preview();
+ },
+ preview: function () {
+ var $preview = this.$("#link-preview");
+ this.get_data(true).then(function (url, new_window, label, classes) {
+ $preview.attr("target", new_window ? '_blank' : "")
+ .attr("href", url && url.length ? url : "#")
+ .html((label && label.length ? label : url))
+ .attr("class", classes.replace(/pull-\w+/, ''));
+ });
+ }
+});
+
+return {
+ 'getCssSelectors': getCssSelectors,
+ 'Dialog': Dialog,
+ 'alt': alt,
+ 'MediaDialog': MediaDialog,
+ 'fontIcons': fontIcons,
+ 'LinkDialog': LinkDialog
+};
+
+});
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 00000000000..5e3ed9695cd
--- /dev/null
+++ b/addons/web_editor/static/src/xml/backend.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="web_editor.FieldTextHtmlSimple">
+ <div class="oe_form_field oe_form_field_html_text" t-att-style="widget.node.attrs.style">
+ <textarea readonly="readonly"/>
+ </div>
+ </t>
+
+ <t t-name="web_editor.FieldTextHtml">
+ <div class="oe_form_field oe_form_field_html oe_form_embedded_html" t-att-style="widget.node.attrs.style">
+ <iframe/>
+ </div>
+ </t>
+
+ <t t-name="web_editor.FieldTextHtml.button.reset">
+ <div class="btn-group pull-right">
+ <button class="o_reset_translation btn btn-default btn-sm btn-small" title="Reset from source"><span class="fa fa-upload"></span></button>
+ </div>
+ </t>
+
+ <t t-name="web_editor.FieldTextHtml.button.translate">
+ <div class="btn-group pull-right">
+ <button t-if="widget.field.translate &amp;&amp; widget.get('effective_readonly')" class="oe_field_translate btn btn-default btn-sm btn-small" style="height: 24px; padding: 1px 3px 0px 5px">
+ <img border="0" class="oe_input_icon" height="16" width="16" src="/web/static/src/img/icons/terp-translate.png" style="margin: 2px 0px -2px 0px;"/>
+ </button>
+ </div>
+ </t>
+
+ <t t-name="web_editor.FieldTextHtml.translate">
+ <div t-if="widget.view.datarecord.id &amp;&amp; widget.languages.length" style="margin: 5px; position: fixed; top: 0; left: 0; z-index: 2000;">
+ <select style="
+ width: 148px;
+ height: 22px;
+ padding: 2px 4px;
+ border: 1px solid #ccc;
+ -moz-border-radius: 3px;
+ -webkit-border-radius: 3px;
+ border-radius: 3px;
+ background: white;">
+ <t t-foreach="widget.languages" t-as="lang">
+ <option value="en_US" t-att-selected="'en_US' == widget.lang ? 'selected' : null">
+ <t t-if="!widget.get('effective_readonly')">English (edit mode)</t>
+ <t t-if="widget.get('effective_readonly')">English</t>
+ </option>
+ <option t-att-value="lang.code" t-att-selected="lang.code == widget.lang ? 'selected' : null"><t t-esc="lang.name"/></option>
+ </t>
+ </select>
+ </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="height: 24px; padding: 1px 2px 1px 3px;">
+ <img border="0" class="oe_input_icon" height="16" width="16" src="/web/static/src/img/icons/STOCK_FULLSCREEN.png" style="margin: 2px 0px -2px 0px;"/>
+ </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 00000000000..b2f4d583052
--- /dev/null
+++ b/addons/web_editor/static/src/xml/editor.xml
@@ -0,0 +1,372 @@
+<templates id="template" xml:space="preserve">
+ <t t-name="web_editor.editorbar">
+ <div id="web_editor-top-edit" class="o_css_editor navbar navbar-inverse navbar-fixed-top hidden-xs">
+ <form class="navbar-form text-muted">
+ <button type="button" data-action="save"
+ class="btn btn-primary btn-xs">Save</button>
+ <span> or </span>
+ <a href="#" data-action="cancel" class="btn btn-link btn-xs">Discard</a>
+ </form>
+ </div>
+ </t>
+
+ <t t-name="web_editor.dialog">
+ <div class="modal note-link-dialog" tabindex="-1" role="dialog" aria-hidden="true">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
+ <h3 class="modal-title"><t t-esc="title"/></h3>
+ </div>
+ <div class="modal-body"><t t-raw="0"/></div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-primary save" t-if="!button">Save</button>
+ <button type="button" class="btn btn-primary save" t-if="button"><t t-raw="button"/></button>
+ <button type="button" class="btn hidden wait" disabled="disabled"/>
+ <span> or </span>
+ <a href="#" data-dismiss="modal" aria-hidden="true">Discard</a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+ <t t-name="web_editor.dialog.media">
+ <div class="modal note-image-dialog" tabindex="-1" role="dialog" aria-hidden="true">
+ <div class="modal-dialog select-media">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
+ <h3 class="modal-title">Select a Media</h3>
+ </div>
+ <div class="modal-body">
+ <ul class="nav nav-tabs">
+ <li class="active"><a href="#editor-media-image" data-toggle="tab">Image</a></li>
+ <li><a href="#editor-media-icon" data-toggle="tab">Pictogram</a></li>
+ <li><a href="#editor-media-video" data-toggle="tab">Video</a></li>
+ <li class="search" style="float: right;">
+ <ul class="pager mb0 mt0">
+ <li class="previous disabled"><a href="#">← Previous</a></li>
+ <li class="next disabled"><a href="#">Next →</a></li>
+ </ul>
+ </li>
+ <li class="search" style="float: right;">
+ <form action="#">
+ <div class="form-group font-icons fa fa-search mb0">
+ <input type="search" class="form-control" id="icon-search"/>
+ </div>
+ </form>
+ </li>
+ </ul>
+
+ <!-- Tab panes -->
+ <div class="tab-content">
+ <div class="tab-pane fade in active" id="editor-media-image">
+ </div>
+ <div class="tab-pane fade" id="editor-media-icon">
+ </div>
+ <div class="tab-pane fade" id="editor-media-video">
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-primary save">Save</button>
+ <button type="button" class="btn hidden wait" disabled="disabled"/>
+ or
+ <a href="#" data-dismiss="modal" aria-hidden="true">Discard</a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+ <t t-name="web_editor.dialog.image">
+ <div>
+ <form method="POST"
+ action="/web_editor/attachment/add"
+ enctype="multipart/form-data"
+ target="fileframe"
+ class="form-inline">
+ <div class="well">
+ <div class="form-group pull-left">
+ <input type="file" name="upload" accept="image/*" multiple="multiple" style="position: absolute; opacity: 0; width: 1px; height: 1px;"/>
+ <input type="hidden" name="disable_optimization" value=""/>
+ <div class="btn-group">
+ <button type="button" class="btn btn-primary filepicker">Upload an image from your computer</button>
+ <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown">
+ <span class="caret"></span>
+ <span class="sr-only">Alternate Upload</span>
+ </button>
+ <ul class="dropdown-menu" role="menu">
+ <li><a href="#" class="js_disable_optimization">Upload image without optimization</a></li>
+ </ul>
+ </div>
+ <button type="button" class="btn hidden wait" disabled="disabled">Uploading...</button>
+ </div>
+ <div>
+ <span class="text-muted"> — or — </span>
+ <label for="iamgeurl">Add an image URL</label>
+ <div class="form-group btn-group">
+ <input type="text" name="url" class="form-control url pull-left" style="width: 320px;" id="iamgeurl" placeholder="http://openerp.com/logo.png"/>
+ <button class="btn btn-default" type="submit">Add</button>
+ </div>
+ </div>
+ </div>
+ <input type="hidden" name="func"/>
+ <div class="help-block"/>
+ <div class="existing-attachments">
+ <t t-call="web_editor.dialog.image.existing"/>
+ </div>
+ </form>
+ </div>
+ <iframe src="about:blank" name="fileframe" class="hidden"/>
+ </t>
+ <t t-name="web_editor.dialog.image.existing">
+ <div class="modal" tabindex="-1" role="dialog" aria-hidden="true">
+ <div class="modal-dialog select-image">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
+ <h3 class="modal-title">Select a Picture</h3>
+ </div>
+ <div class="modal-body has-error">
+ <div class="existing-attachments"/>
+ <div class="help-block"/>
+ </div>
+ <div class="modal-footer">
+ <a href="#" data-dismiss="modal" aria-hidden="true">Discard</a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+ <t t-name="web_editor.dialog.image.existing.content">
+ <div class="existing-attachments">
+ <div class="row mt16" t-foreach="rows" t-as="row">
+ <div class="col-sm-2 existing-attachment-cell"
+ t-foreach="row" t-as="attachment">
+ <i class="fa fa-times existing-attachment-remove" t-att-data-id="attachment.id"/>
+ <img t-att-src="attachment.local_url" t-att-alt="attachment.name" t-att-title="attachment.name" t-att-data-id="attachment.id" class="img img-responsive"/>
+ </div>
+ </div>
+ </div>
+ </t>
+ <t t-name="web_editor.dialog.image.existing.error">
+ <div class="help-block">
+ <p>The image could not be deleted because it is used in the
+ following pages or views:</p>
+ <ul t-foreach="views" t-as="view">
+ <li>
+ <a t-attf-href="/web#model=ir.ui.view&amp;id=#{view.id}">
+ <t t-esc="view.name"/>
+ </a>
+ </li>
+ </ul>
+ </div>
+ </t>
+
+ <t t-name="web_editor.discard">
+ <div class="modal" tabindex="-1" role="dialog" aria-hidden="true">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
+ <h3 class="modal-title">Discard edition</h3>
+ </div>
+ <div class="modal-body">
+ <p>
+ If you discard the current edition, <em>all</em>
+ unsaved changes will be lost.
+ </p>
+ <p>You can cancel to return to the edition mode.</p>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-danger">Discard</button>
+ or
+ <a href="#" data-dismiss="modal" aria-hidden="true">Cancel</a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="web_editor.dialog.alt">
+ <t t-call="web_editor.dialog">
+ <t t-set="title">Change media Alt and Title</t>
+ <form action="#" class="form-horizontal">
+ <div class="form-group">
+ <label for="alt" class="col-sm-2 control-label">Alt:</label>
+ <div class="col-sm-9"><input id="alt" type="text" class="form-control" required="required" t-att-value="widget.alt"/></div>
+ </div>
+ <div class="form-group">
+ <label for="title" class="col-sm-2 control-label">Title:</label>
+ <div class="col-sm-9"><input id="title" type="text" class="form-control" required="required" t-att-value="widget.title"/></div>
+ </div>
+ </form>
+ </t>
+ </t>
+
+ <t t-name="web_editor.dialog.font-icons">
+ <form action="#">
+ <input type="hidden" id="fa-icon"/>
+ <input type="hidden" id="fa-size" value="fa-1x"/>
+ <div class="font-icons-icons">
+ <t t-call="web_editor.dialog.font-icons.icons">
+ <t t-set="iconsParser" t-value="widget.iconsParser"/>
+ </t>
+ </div>
+ </form>
+ </t>
+ <t t-name="web_editor.dialog.font-icons.icons">
+ <t t-foreach="iconsParser" t-as="data">
+ <span t-foreach="data.icons" t-as="icon"
+ t-attf-class="#{data.base} font-icons-icon #{icon}"
+ t-att-data-id="icon"/>
+ </t>
+ </t>
+
+ <t t-name="web_editor.dialog.video">
+ <form action="#" class="form-inline">
+ <div class="well">
+ <div class="form-group btn-group">
+ <span class="text-muted pull-right" style="margin-left:10px; line-height: 2em;">(Youtube, Vimeo, Dailymotion)</span>
+ <label for="urlvideo" style="width: 220px; line-height: 2em;" class="pull-left">Set a video URL</label>
+ <input type="text"
+ name="url"
+ class="form-control url pull-left"
+ style="width: 400px;"
+ id="urlvideo"
+ placeholder="//www.youtube.com/embed/yws1tbgNV7k"
+ t-translation="off"/>
+ <button class="btn btn-default">Preview</button>
+ </div>
+ <div class="form-group btn-group">
+ <label for="urlvideo" style="width: 220px; line-height: 2em;" class="pull-left"><span class="text-muted">— or —</span> Embed Video (HTML)</label>
+ <input type="text"
+ name="embed"
+ class="form-control url pull-left"
+ style="width: 400px;"
+ id="embedvideo"
+ placeholder='&lt;iframe src="//www.youtube.com/embed/yws1tbgNV7k"&gt;&lt;/iframe&gt;'
+ t-translation="off"/>
+ <button class="btn btn-default">Preview</button>
+ </div>
+ </div>
+
+ <div id="video-preview" style="width: 450px; margin: 0 auto;">
+ <div class="media_iframe_video">
+ <div class="media_iframe_video_size"></div>
+ <iframe
+ src=""
+ frameborder="0"
+ allowfullscreen="allowfullscreen"></iframe>
+ </div>
+ </div>
+
+ <div class="text-center mt32">
+ <div class="checkbox">
+ <label><input type="checkbox" id="autoplay"/> Autoplay</label>
+ </div>
+ </div>
+ </form>
+ </t>
+
+ <t t-name="web_editor.dialog.link">
+ <t t-call="web_editor.dialog">
+ <t t-set="title">Link to</t>
+ <form>
+ <div class="list-group">
+ <div class="form-group list-group-item active">
+ <h3 class="list-group-item-heading">
+ <label for="link-new" class="control-label">
+ Link Label
+ </label>
+ </h3>
+ <input type="text" class="form-control" id="link-text" required="required"/>
+ </div>
+ </div>
+ <ul class="list-group">
+ <li class="list-group-item form-group clearfix active">
+ <div class="pull-right" style="display: none;">
+ <label>
+ <input type="checkbox" class="window-new"/>
+ Open in new window
+ </label>
+ </div>
+ <h4 class="list-group-item-heading">
+ <label for="link-external" class="control-label">
+ URL or Email Address
+ </label>
+ </h4>
+ <input type="text" class="form-control url email-address url-source"
+ id="link-external" placeholder="http://openerp.com"/>
+ </li>
+ </ul>
+ <div class="form-horizontal link-style">
+ <div class="form-group">
+ <label class="col-sm-2 control-label">
+ Style
+ </label>
+ <div class="col-sm-4">
+ <div class="dropdown" style="line-height: 2em;">
+ <a data-toggle="dropdown" class="btn btn-default" href="#">Color Style <span class="caret"></span></a>
+ <ul class="dropdown-menu" role="menu" aria-labelledby="dLabel">
+ <li>
+ <label class="btn btn-link">Link<input value="" class="link-style" type="radio" name="link-style-type" checked="checked"/></label>
+ </li>
+ <li>
+ <label class="btn btn-default">Basic<input value="btn-default" class="link-style" type="radio" name="link-style-type"/></label>
+ </li>
+ <li>
+ <label class="btn btn-success">Success<input value="btn-success" class="link-style" type="radio" name="link-style-type"/></label>
+ </li>
+ <li>
+ <label class="btn btn-primary">Primary<input value="btn-primary" class="link-style" type="radio" name="link-style-type"/></label>
+ </li>
+ <li>
+ <label class="btn btn-info">Info<input value="btn-info" class="link-style" type="radio" name="link-style-type"/></label>
+ </li>
+ <li>
+ <label class="btn btn-warning">Warning<input value="btn-warning" class="link-style" type="radio" name="link-style-type"/></label>
+ </li>
+ <li>
+ <label class="btn btn-danger">Danger<input value="btn-danger" class="link-style" type="radio" name="link-style-type"/></label>
+ </li>
+ </ul>
+ </div>
+ </div>
+ <div class="col-sm-4">
+ <div class="dropdown" style="line-height: 2em;">
+ <a data-toggle="dropdown" class="btn btn-default" href="#">Size <span class="caret"></span></a>
+ <ul class="dropdown-menu" role="menu" aria-labelledby="dLabel">
+ <li>
+ <label class="btn btn-default btn-xs">Extra Small<input value="btn-xs" class="link-style" type="radio" name="link-style-size"/></label>
+ </li>
+ <li>
+ <label class="btn btn-default btn-sm">Small<input value="btn-sm" class="link-style" type="radio" name="link-style-size"/></label>
+ </li>
+ <li>
+ <label class="btn btn-default">Default<input value="" class="link-style" type="radio" name="link-style-size" checked="checked"/></label>
+ </li>
+ <li>
+ <label class="btn btn-default btn-lg">Large<input value="btn-lg" class="link-style" type="radio" name="link-style-size"/></label>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="form-horizontal link-style">
+ <div class="form-group">
+ <label class="col-sm-2 control-label">
+ Preview
+ </label>
+ <div class="col-sm-10" style="line-height: 2em;">
+ <div style="overflow: auto; max-width: 100%; max-height: 200px;">
+ <a id="link-preview" href="#"></a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+ </t>
+ </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 00000000000..ee996548379
--- /dev/null
+++ b/addons/web_editor/static/src/xml/snippets.xml
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="utf-8"?>
+<templates id="template" xml:space="preserve">
+
+ <!-- Snippet loader -->
+ <t t-name="web_editor.snippets_style">
+ <li class="navbar-form">
+ <div class="btn-group">
+ <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
+ Block style <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu" id="oe_snippets_style" role="menu"></ul>
+ </div>
+ </li>
+ </t>
+
+ <t t-name="web_editor.snippets">
+ <div id='oe_snippets' class="hidden-xs o_open">
+ <div id="o_arrow">
+ <span class="fa fa-angle-double-right fa-1x" style="top: 10px;"></span>
+ <div>Insert Blocks</div>
+ <span class="fa fa-angle-double-right fa-1x" style="bottom: 10px;"></span>
+ </div>
+ <div id="o_left_bar"></div>
+ </div>
+ </t>
+
+ <t t-name="web_editor.snippet_overlay">
+ <div class="oe_overlay">
+ <div class="oe_overlay_options" contentEditable="false">
+ <div class="btn-group">
+ <a href="#" class="btn btn-default btn-sm oe_snippet_parent" title="Select Container Block"><i class="fa fa-upload"></i></a>
+ <div class="dropdown oe_options hidden btn-group">
+ <a href="#" data-toggle="dropdown" class="btn btn-primary btn-sm" title="Customize">Customize <span class="caret"/></a>
+ <ul class="dropdown-menu" role="menu"></ul>
+ </div>
+ <a href="#" class="btn btn-default btn-sm oe_snippet_move" title="Drag to Move">&amp;nbsp;<i class="fa fa-arrows"></i>&amp;nbsp;</a>
+ <a href="#" class="btn btn-default btn-sm oe_snippet_clone" title="Duplicate Container">&amp;nbsp;<i class="fa fa-files-o"></i>&amp;nbsp; </a>
+ <a href="#" class="btn btn-default btn-sm oe_snippet_remove" title="Remove Block">&amp;nbsp;<i class="fa fa-trash-o"></i>&amp;nbsp; </a>
+ </div>
+ </div>
+ <div class='oe_handles'>
+ <div class='oe_handle n readonly'><div></div></div>
+ <div class='oe_handle e readonly'><div></div></div>
+ <div class='oe_handle w readonly'><div></div></div>
+ <div class='oe_handle size readonly'>
+ <div class="oe_handle_button auto_size">Auto Resize</div>
+ <div class="oe_handle_button size">Resize</div>
+ </div>
+ <div class='oe_handle s readonly'><div></div></div>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="web_editor.snippet_manipulator">
+ <div class='oe_snippet_manipulator'></div>
+ </t>
+
+ <!-- t-field options -->
+
+ <t t-name="web_editor.many2one.button">
+ <div class="dropdown">
+ <a href="#" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" data-hover="dropdown" title="Search Contact"><i class="fa fa-search"></i></a>
+ <ul class="dropdown-menu contact_menu" role="menu">
+ <li><a><input href="#" type="email" placeholder="Search"/></a></li>
+ </ul>
+ </div>
+ </t>
+
+ <t t-name="web_editor.many2one.search">
+ <t t-foreach="contacts" t-as="item">
+ <li><a 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/translator.xml b/addons/web_editor/static/src/xml/translator.xml
new file mode 100644
index 00000000000..5a825d4ad0b
--- /dev/null
+++ b/addons/web_editor/static/src/xml/translator.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<templates id="template" xml:space="preserve">
+
+<t t-name="web_editor.translator">
+ <div id="web_editor-top-edit" class="o_css_editor o_translate navbar navbar-inverse navbar-fixed-top hidden-xs">
+ <form class="navbar-form text-muted">
+ <button type="button" data-action="save" class="btn btn-primary btn-xs">Save</button>
+ <span>or</span>
+ <a href="#" data-action="cancel" class="btn btn-link btn-xs">Discard</a>
+ </form>
+ </div>
+</t>
+
+</templates>
diff --git a/addons/web_editor/tests/__init__.py b/addons/web_editor/tests/__init__.py
new file mode 100644
index 00000000000..ee3fba301a9
--- /dev/null
+++ b/addons/web_editor/tests/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+import test_ui
+import test_converter
diff --git a/addons/web_editor/tests/test_converter.py b/addons/web_editor/tests/test_converter.py
new file mode 100644
index 00000000000..ec8124b66b5
--- /dev/null
+++ b/addons/web_editor/tests/test_converter.py
@@ -0,0 +1,243 @@
+# -*- coding: utf-8 -*-
+import textwrap
+import unittest2
+
+from lxml import etree, html
+from lxml.builder import E
+
+from openerp.tests import common
+from openerp.addons.base.ir import ir_qweb
+from openerp.addons.web_editor.models.ir_qweb import html_to_text
+
+
+class TestHTMLToText(unittest2.TestCase):
+ def test_rawstring(self):
+ self.assertEqual(
+ "foobar",
+ html_to_text(E.div("foobar")))
+
+ def test_br(self):
+ self.assertEqual(
+ "foo\nbar",
+ html_to_text(E.div("foo", E.br(), "bar")))
+
+ self.assertEqual(
+ "foo\n\nbar\nbaz",
+ html_to_text(E.div(
+ "foo", E.br(), E.br(),
+ "bar", E.br(),
+ "baz")))
+
+ def test_p(self):
+ self.assertEqual(
+ "foo\n\nbar\n\nbaz",
+ html_to_text(E.div(
+ "foo",
+ E.p("bar"),
+ "baz")))
+
+ self.assertEqual(
+ "foo",
+ html_to_text(E.div(E.p("foo"))))
+
+ self.assertEqual(
+ "foo\n\nbar",
+ html_to_text(E.div("foo", E.p("bar"))))
+ self.assertEqual(
+ "foo\n\nbar",
+ html_to_text(E.div(E.p("foo"), "bar")))
+
+ self.assertEqual(
+ "foo\n\nbar\n\nbaz",
+ html_to_text(E.div(
+ E.p("foo"),
+ E.p("bar"),
+ E.p("baz"),
+ )))
+
+ def test_div(self):
+ self.assertEqual(
+ "foo\nbar\nbaz",
+ html_to_text(E.div(
+ "foo",
+ E.div("bar"),
+ "baz"
+ )))
+
+ self.assertEqual(
+ "foo",
+ html_to_text(E.div(E.div("foo"))))
+
+ self.assertEqual(
+ "foo\nbar",
+ html_to_text(E.div("foo", E.div("bar"))))
+ self.assertEqual(
+ "foo\nbar",
+ html_to_text(E.div(E.div("foo"), "bar")))
+
+ self.assertEqual(
+ "foo\nbar\nbaz",
+ html_to_text(E.div(
+ "foo",
+ E.div("bar"),
+ E.div("baz")
+ )))
+
+ def test_other_block(self):
+ self.assertEqual(
+ "foo\nbar\nbaz",
+ html_to_text(E.div(
+ "foo",
+ E.section("bar"),
+ "baz"
+ )))
+
+ def test_inline(self):
+ self.assertEqual(
+ "foobarbaz",
+ html_to_text(E.div("foo", E.span("bar"), "baz")))
+
+ def test_whitespace(self):
+ self.assertEqual(
+ "foo bar\nbaz",
+ html_to_text(E.div(
+ "foo\nbar",
+ E.br(),
+ "baz")
+ ))
+
+ self.assertEqual(
+ "foo bar\nbaz",
+ html_to_text(E.div(
+ E.div(E.span("foo"), " bar"),
+ "baz")))
+
+
+class TestConvertBack(common.TransactionCase):
+ def setUp(self):
+ super(TestConvertBack, self).setUp()
+
+ def field_rountrip_result(self, field, value, expected):
+ model = 'web_editor.converter.test'
+ Model = self.registry(model)
+ id = Model.create(
+ self.cr, self.uid, {
+ field: value
+ })
+ [record] = Model.browse(self.cr, self.uid, [id])
+
+ e = etree.Element('span')
+ field_value = 'record.%s' % field
+ e.set('t-field', field_value)
+
+ rendered = self.registry('ir.qweb').render_tag_field(
+ e, {'field': field_value}, '', ir_qweb.QWebContext(self.cr, self.uid, {
+ 'record': record,
+ }, context={'inherit_branding': True}))
+ element = html.fromstring(
+ rendered, parser=html.HTMLParser(encoding='utf-8'))
+
+ converter = self.registry('ir.qweb').get_converter_for(
+ element.get('data-oe-type'))
+
+ value_back = converter.from_html(
+ self.cr, self.uid, model, Model._fields[field], element)
+
+ if isinstance(expected, str):
+ expected = expected.decode('utf-8')
+ self.assertEqual(value_back, expected)
+
+ def field_roundtrip(self, field, value):
+ self.field_rountrip_result(field, value, value)
+
+ def test_integer(self):
+ self.field_roundtrip('integer', 42)
+
+ def test_float(self):
+ self.field_roundtrip('float', 42.567890)
+ self.field_roundtrip('float', 324542.567890)
+
+ def test_numeric(self):
+ self.field_roundtrip('numeric', 42.77)
+
+ def test_char(self):
+ self.field_roundtrip('char', "foo bar")
+
+ self.field_roundtrip('char', "ⒸⓄⓇⒼⒺ")
+
+ def test_selection(self):
+ self.field_roundtrip('selection', 3)
+
+ def test_selection_str(self):
+ self.field_roundtrip('selection_str', 'B')
+
+ def test_text(self):
+ self.field_roundtrip('text', textwrap.dedent("""\
+ You must obey the dance commander
+ Givin' out the order for fun
+ You must obey the dance commander
+ You know that he's the only one
+ Who gives the orders here,
+ Alright
+ Who gives the orders here,
+ Alright
+
+ It would be awesome
+ If we could dance-a
+ It would be awesome, yeah
+ Let's take the chance-a
+ It would be awesome, yeah
+ Let's start the show
+ Because you never know
+ You never know
+ You never know until you go"""))
+
+ def test_m2o(self):
+ """ the M2O field conversion (from html) is markedly different from
+ others as it directly writes into the m2o and returns nothing at all.
+ """
+ model = 'web_editor.converter.test'
+ field = 'many2one'
+
+ Sub = self.registry('web_editor.converter.test.sub')
+ sub_id = Sub.create(self.cr, self.uid, {'name': "Foo"})
+ sub2_id = Sub.create(self.cr, self.uid, {'name': "Bar"})
+
+ Model = self.registry(model)
+ id = Model.create(self.cr, self.uid, {field: sub_id})
+ [record] = Model.browse(self.cr, self.uid, [id])
+
+ e = etree.Element('span')
+ field_value = 'record.%s' % field
+ e.set('t-field', field_value)
+
+ rendered = self.registry('ir.qweb').render_tag_field(
+ e, {'field': field_value}, '', ir_qweb.QWebContext(self.cr, self.uid, {
+ 'record': record,
+ }, context={'inherit_branding': True}))
+
+ element = html.fromstring(rendered, parser=html.HTMLParser(encoding='utf-8'))
+ # emulate edition
+ element.set('data-oe-many2one-id', str(sub2_id))
+ element.text = "New content"
+
+ converter = self.registry('ir.qweb').get_converter_for(
+ element.get('data-oe-type'))
+
+ value_back = converter.from_html(
+ self.cr, self.uid, model, Model._fields[field], element)
+
+ self.assertIsNone(
+ value_back, "the m2o converter should return None to avoid spurious"
+ " or useless writes on the parent record")
+
+ self.assertEqual(
+ Sub.browse(self.cr, self.uid, sub_id).name,
+ "Foo",
+ "element edition can't change directly the m2o record"
+ )
+ self.assertEqual(
+ Model.browse(self.cr, self.uid, id).many2one.name,
+ "Bar",
+ "element edition should have been change the m2o id"
+ )
diff --git a/addons/web_editor/tests/test_ui.py b/addons/web_editor/tests/test_ui.py
new file mode 100644
index 00000000000..c0887201bba
--- /dev/null
+++ b/addons/web_editor/tests/test_ui.py
@@ -0,0 +1,9 @@
+import openerp.tests
+
+
+class TestUi(openerp.tests.HttpCase):
+ def test_01_admin_rte(self):
+ self.phantom_js("/web", "odoo.__DEBUG__.services['web.Tour'].run('rte', 'test')", "odoo.__DEBUG__.services['web.Tour'].tours.rte", login='admin')
+
+ def test_02_admin_rte_inline(self):
+ self.phantom_js("/web", "odoo.__DEBUG__.services['web.Tour'].run('rte_inline', 'test')", "odoo.__DEBUG__.services['web.Tour'].tours.rte", login='admin')
diff --git a/addons/web_editor/views/editor.xml b/addons/web_editor/views/editor.xml
new file mode 100644
index 00000000000..c7d785d58f8
--- /dev/null
+++ b/addons/web_editor/views/editor.xml
@@ -0,0 +1,153 @@
+<?xml version="1.0" encoding="utf-8"?>
+<openerp>
+
+<template id="summernote" name="Summernote">
+ <script type="text/javascript">
+ (function () {
+ "use strict";
+ odoo.define('jquery', function () {return $;});
+
+ var uniqId = 0;
+ odoo.__define__ = window.define;
+ window.define = function (id) {
+ var args = Array.prototype.slice.call(arguments);
+ var factorie = args.pop();
+ var id = args[0];
+
+ if (id instanceof Array) {
+ var name = "summernote_factorie_" + (++uniqId);
+ odoo[name] = factorie;
+
+ var head = '';
+ var fn = 'var fn = odoo.'+name+';\ndelete odoo.'+name+';\n';
+ fn += 'return fn(';
+ for (var k=0; k&lt;id.length; k++) {
+ head += 'var a'+(++uniqId)+' = require("'+id[k]+'");\n';
+ fn += 'a'+uniqId+', ';
+ }
+ fn += 'null);';
+
+ odoo.define(odoo.website_next_define, new Function('require', head + fn));
+ } else {
+ odoo.define(id, factorie);
+ }
+ };
+ })();
+ </script>
+ <script>odoo.website_next_define = 'summernote/core/async';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/core/async.js" />
+ <script>odoo.website_next_define = 'summernote/core/func';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/core/func.js" />
+ <script>odoo.website_next_define = 'summernote/core/agent';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/core/agent.js" />
+ <script>odoo.website_next_define = 'summernote/core/list';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/core/list.js" />
+ <script>odoo.website_next_define = 'summernote/core/dom';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/core/dom.js" />
+ <script>odoo.website_next_define = 'summernote/core/key';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/core/key.js" />
+ <script>odoo.website_next_define = 'summernote/core/range';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/core/range.js" />
+
+ <script>odoo.website_next_define = 'summernote/editing/Bullet';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/editing/Bullet.js" />
+ <script>odoo.website_next_define = 'summernote/editing/History';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/editing/History.js" />
+ <script>odoo.website_next_define = 'summernote/editing/Style';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/editing/Style.js" />
+ <script>odoo.website_next_define = 'summernote/editing/Table';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/editing/Table.js" />
+ <script>odoo.website_next_define = 'summernote/editing/Typing';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/editing/Typing.js" />
+ <script>odoo.website_next_define = 'summernote/editing/Editor';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/editing/Editor.js" />
+
+ <script>odoo.website_next_define = 'summernote/module/Button';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/module/Button.js" />
+ <script>odoo.website_next_define = 'summernote/module/Dialog';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/module/Dialog.js" />
+ <script>odoo.website_next_define = 'summernote/module/Handle';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/module/Handle.js" />
+ <script>odoo.website_next_define = 'summernote/module/Popover';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/module/Popover.js" />
+ <script>odoo.website_next_define = 'summernote/module/Toolbar';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/module/Toolbar.js" />
+
+ <script>odoo.website_next_define = 'summernote/Renderer';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/Renderer.js" />
+ <script>odoo.website_next_define = 'summernote/EventHandler';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/EventHandler.js" />
+
+ <script>odoo.website_next_define = 'summernote/settings';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/settings.js" />
+
+ <script>odoo.website_next_define = 'summernote/summernote';</script>
+ <script type="text/javascript" src="/web_editor/static/lib/summernote/src/js/summernote.js" />
+
+ <script type="text/javascript" src="/web_editor/static/src/js/summernote.js" />
+ <script type="text/javascript">
+ window.define = odoo.__define__;
+ delete odoo.__define__;
+ delete odoo.website_next_define;
+ </script>
+
+ <!--link rel="stylesheet" href="/web_editor/static/lib/summernote/src/less/summernote.less"/-->
+ <link rel="stylesheet" href="/web_editor/static/lib/summernote/summernote.css"/>
+</template>
+
+<template id="editor" name="Editor">
+ <link rel='stylesheet' href='/web_editor/static/src/css/snippets.css'/>
+ <link rel="stylesheet" href='/web_editor/static/src/css/editor.css'/>
+ <script type="text/javascript" src='/web_editor/static/lib/nearest/jquery.nearest.js' />
+ <script type="text/javascript" src="/web_editor/static/src/js/editor.js" />
+ <script type="text/javascript" src="/web_editor/static/src/js/rte.js" />
+ <script type="text/javascript" src="/web_editor/static/src/js/rte.summernote.js" />
+ <script type="text/javascript" src="/web_editor/static/src/js/widgets.js" />
+ <script type="text/javascript" src="/web_editor/static/src/js/snippets.editor.js" />
+ <script type="text/javascript" src="/web_editor/static/src/js/snippets.options.js" />
+ <script type="text/javascript" src="/web_editor/static/src/js/tour.rte.js" />
+ <script type="text/javascript" src="/web_editor/static/src/js/transcoder.js"></script>
+ <script type="text/javascript" src="/web_editor/static/src/js/translator.js"></script>
+</template>
+
+<template id="assets_common" inherit_id="web.assets_common" name="web_editor base assets_common">
+ <xpath expr="." position="inside">
+ <script type="text/javascript" src="/web_editor/static/src/js/base.js" />
+ <script type="text/javascript" src="/web_editor/static/src/js/snippets.animation.js" />
+ </xpath>
+</template>
+
+<template id="assets_editor" name="web_editor assets for editor">
+ <t t-call="web.assets_common"/>
+ <t t-call="web.bootstrap"/>
+ <script type="text/javascript" src="/web/static/lib/select2/select2.js"></script>
+ <link rel="stylesheet" href='/web_editor/static/src/css/web_editor.css'/>
+ <t t-call="web_editor.summernote"/>
+ <t t-call="web_editor.editor"/>
+</template>
+
+<template id="assets_backend" name="web_editor assets for backend" inherit_id="web.assets_backend">
+ <xpath expr="." position="inside">
+ <t t-call="web_editor.summernote"/>
+ <t t-call="web_editor.editor"/>
+ <link rel="stylesheet" href="/web_editor/static/src/css/backend.css"/>
+ <script type="text/javascript" src="/web_editor/static/src/js/backend.js" />
+ </xpath>
+</template>
+
+<!-- color-picker -->
+<!-- HTML class to hide option for one mode : only-text, only-bg -->
+
+<template id="web_editor.colorpicker" name="Color-Picker">
+ <table class="colorpicker">
+ <tr>
+ <td><button class="bg-primary" title="Primary Color"/></td>
+ <td><button class="bg-success" title="Success Color"/></td>
+ <td><button class="bg-info" title="Info Color"/></td>
+ <td><button class="bg-warning" title="Warning Color"/></td>
+ <td><button class="bg-danger" title="Danger Color"/></td>
+ </tr>
+ </table>
+</template>
+
+</openerp>
diff --git a/addons/web_editor/views/iframe.xml b/addons/web_editor/views/iframe.xml
new file mode 100644
index 00000000000..32cbebd30ed
--- /dev/null
+++ b/addons/web_editor/views/iframe.xml
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?>
+<openerp>
+
+<template id="layout" name="Editor layout">&lt;!DOCTYPE html&gt;
+ <html t-att-lang="lang and lang.replace('_', '-')"
+ t-att-data-editable="'1' if editable else None"
+ t-att-data-translatable="'1' if translatable else None"
+ t-att-data-edit_translations="'1' if edit_translations else None"
+ t-att-data-main-object="repr(main_object) if editable else None">
+ <head>
+ <meta charset="utf-8" />
+ <title>Odoo Editor layout</title>
+ <meta name="viewport" content="initial-scale=1"/>
+ <script type="text/javascript">
+ var snippets_url = '<t t-esc="snippets or ''"/>';
+ var callback = window.location.href.match(/callback=([^&amp;=]+)/)[1];
+ </script>
+ <link t-if="inline_mode" rel="stylesheet" href="/web_editor/static/src/css/inline.css" type="text/css"/>
+ <t t-if="not dont_load_assets">
+ <t t-call-assets="web_editor.assets_editor"/>
+ <style>
+ .note-popover .popover { left: 5px !important; }
+ html[data-translatable] .note-popover .popover { left: 150px !important; }
+ </style>
+ <script type="text/javascript" src="/web/static/src/js/framework/session_instance.js"></script>
+ <script type="text/javascript" src="/web_editor/static/src/js/iframe.js"></script>
+ <script t-if="enable_editor and inline_mode" type="text/javascript" src="/web_editor/static/src/js/inline.js"></script>
+ </t>
+ <script type="text/javascript">
+ var snippets_url = '<t t-esc="snippets or ''"/>';
+ var callback = window.location.href.match(/callback=([^&amp;=]+)/)[1];
+ </script>
+ <script>
+ if (window.top.odoo &amp;&amp; !window.top.odoo[callback+"_updown"]) {
+ window.top.odoo[callback+"_updown"] = function (value, fields_values) {
+ var editable = document.getElementsByClassName("o_editable")[0];
+ if (value !== editable.innerHTML) {
+ editable.innerHTML = value;
+ }
+ };
+ }
+ </script>
+ <t t-raw="head or ''"/>
+ </head>
+ <body onload="var fn=window.top.odoo &amp;&amp; window.top.odoo[callback+'_content']; if(fn) {fn();} else {console.warn('Please don\'t open iframe without the odoo backend');}">
+ <div id="wrapwrap">
+ <main>
+ <t t-raw="0"/>
+ </main>
+ </div>
+ <t t-raw="add_html or ''"/>
+ </body>
+ </html>
+</template>
+
+<template id="FieldTextHtml" name="Editor HTML">
+ <t t-call="web_editor.layout">
+ <div id="editable_area" t-att-class="'' if edit_translations else 'o_editable'"></div>
+ </t>
+</template>
+
+</openerp>
diff --git a/addons/web_editor/views/snippets.xml b/addons/web_editor/views/snippets.xml
new file mode 100644
index 00000000000..4499187f6a7
--- /dev/null
+++ b/addons/web_editor/views/snippets.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="utf-8"?>
+<openerp>
+<data>
+<template id="snippets">
+
+
+<ul>
+ <li class="active">
+ <a href="#snippet_structure" class="scroll-link">
+ <span class="fa-stack fa-lg">
+ <i class="fa fa-square fa-stack-2x fa-inverse"></i>
+ </span>
+ </a>
+ </li>
+</ul>
+
+
+<div id="o_scroll">
+ <div id="snippet_structure" class="o_panel">
+ <div class="o_panel_header">
+ <span class="fa-stack">
+ <i class="fa fa-th-large fa-stack-1x"></i>
+ </span> First Panel
+ </div>
+ <div class="o_panel_body">
+
+ </div>
+ </div>
+</div>
+
+
+<div id="snippet_options" class="hidden">
+ <t t-call="web_editor.snippet_options"/>
+</div>
+</template>
+
+<!-- options (data-selector, data-drop-in, data-drop-near, data-js to link js object ) -->
+
+<template id="snippet_options">
+ <!-- media -->
+
+ <div data-js='media'
+ data-selector="img, .media_iframe_video, span.fa, i.fa, .glyphicon">
+ <li data-edition="edition"><a>Change...</a></li>
+ </div>
+
+ <!-- t-field -->
+
+ <div data-js='many2one'
+ data-selector="[data-oe-many2one-model]:not([data-oe-readonly])"
+ data-no-check="true">
+ </div>
+</template>
+
+</data>
+</openerp>