mirror of
https://github.com/bringout/oca-ocb-web.git
synced 2026-04-19 00:12:04 +02:00
replace stale web_editor with html_editor and html_builder for 19.0
web_editor was removed in Odoo 19.0 and replaced by html_editor
and html_builder. The old web_editor was incorrectly included in
the 19.0 vanilla import.
🤖 assisted by claude
This commit is contained in:
parent
4b94f0abc5
commit
f866779561
1513 changed files with 396049 additions and 358525 deletions
|
|
@ -0,0 +1,2 @@
|
|||
from . import models
|
||||
from . import controllers
|
||||
117
odoo-bringout-oca-ocb-html_editor/html_editor/__manifest__.py
Normal file
117
odoo-bringout-oca-ocb-html_editor/html_editor/__manifest__.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
{
|
||||
'name': "HTML Editor",
|
||||
'summary': """
|
||||
A Html Editor component and plugin system
|
||||
""",
|
||||
'description': """
|
||||
Html Editor
|
||||
==========================
|
||||
This addon provides an extensible, maintainable editor.
|
||||
""",
|
||||
|
||||
'author': "odoo",
|
||||
'website': "https://www.odoo.com",
|
||||
'version': '1.0',
|
||||
'category': 'Hidden',
|
||||
'depends': ['base', 'bus', 'web'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'auto_install': True,
|
||||
'assets': {
|
||||
'web._assets_primary_variables': [
|
||||
('after', 'web/static/src/scss/primary_variables.scss', 'html_editor/static/src/scss/html_editor.variables.scss'),
|
||||
],
|
||||
'web.assets_frontend': [
|
||||
('include', 'html_editor.assets_media_dialog'),
|
||||
('include', 'html_editor.assets_readonly'),
|
||||
'html_editor/static/src/public/**/*',
|
||||
'html_editor/static/src/scss/html_editor.common.scss',
|
||||
'html_editor/static/src/scss/html_editor.frontend.scss',
|
||||
'html_editor/static/src/scss/base_style.scss',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
('include', 'html_editor.assets_editor'),
|
||||
'html_editor/static/src/others/dynamic_placeholder_plugin.js',
|
||||
'html_editor/static/src/backend/**/*',
|
||||
'html_editor/static/src/fields/**/*',
|
||||
'html_editor/static/lib/vkbeautify/**/*',
|
||||
'html_editor/static/src/scss/base_style.scss',
|
||||
'html_editor/static/src/scss/html_editor.common.scss',
|
||||
'html_editor/static/src/scss/html_editor.backend.scss',
|
||||
],
|
||||
'html_editor.assets_editor': [
|
||||
'web/static/lib/dompurify/DOMpurify.js',
|
||||
('include', 'html_editor.assets_media_dialog'),
|
||||
('include', 'html_editor.assets_readonly'),
|
||||
'html_editor/static/src/*',
|
||||
'html_editor/static/src/components/history_dialog/**/*',
|
||||
'html_editor/static/src/core/**/*',
|
||||
'html_editor/static/src/main/**/*',
|
||||
'html_editor/static/src/others/collaboration/**/*',
|
||||
'html_editor/static/src/others/embedded_components/**/*',
|
||||
'html_editor/static/src/others/embedded_component*',
|
||||
'html_editor/static/src/others/qweb_picker*',
|
||||
'html_editor/static/src/others/qweb_plugin*',
|
||||
'html_editor/static/src/services/**/*',
|
||||
('remove', 'html_editor/static/src/**/*.dark.scss'),
|
||||
],
|
||||
'html_editor.assets_history_diff': [
|
||||
'html_editor/static/lib/diff2html/diff2html.min.css',
|
||||
'html_editor/static/lib/diff2html/diff2html.min.js',
|
||||
],
|
||||
'html_editor.assets_media_dialog': [
|
||||
# Bundle to use the media dialog in the backend and the frontend
|
||||
'html_editor/static/src/components/switch/**/*',
|
||||
'html_editor/static/src/main/media/media_dialog/**/*',
|
||||
],
|
||||
'html_editor.assets_readonly': [
|
||||
'html_editor/static/src/components/html_viewer/**/*',
|
||||
'html_editor/static/src/local_overlay_container.*',
|
||||
'html_editor/static/src/main/local_overlay.scss',
|
||||
'html_editor/static/src/position_hook.*',
|
||||
'html_editor/static/src/html_migrations/**/*',
|
||||
'html_editor/static/src/main/list/list.scss',
|
||||
'html_editor/static/src/main/media/file.scss',
|
||||
'html_editor/static/src/others/embedded_component_utils.js',
|
||||
'html_editor/static/src/others/embedded_components/core/**/*',
|
||||
'html_editor/static/src/utils/**/*',
|
||||
],
|
||||
"web.assets_web_dark": [
|
||||
'html_editor/static/src/**/*.dark.scss',
|
||||
],
|
||||
'web.assets_tests': [
|
||||
'html_editor/static/tests/tours/**/*',
|
||||
],
|
||||
'web.assets_unit_tests': [
|
||||
'html_editor/static/tests/**/*',
|
||||
],
|
||||
'web.assets_unit_tests_setup': [
|
||||
'html_editor/static/src/public/**/*',
|
||||
],
|
||||
'html_editor.assets_image_cropper': [
|
||||
'html_editor/static/lib/cropperjs/cropper.css',
|
||||
'html_editor/static/lib/cropperjs/cropper.js',
|
||||
'html_editor/static/lib/webgl-image-filter/webgl-image-filter.js',
|
||||
],
|
||||
'web.report_assets_common': [
|
||||
'html_editor/static/src/scss/base_style.scss',
|
||||
'html_editor/static/src/scss/bootstrap_overridden.scss',
|
||||
'html_editor/static/src/scss/html_editor.common.scss',
|
||||
],
|
||||
'web._assets_secondary_variables': [
|
||||
'html_editor/static/src/scss/secondary_variables.scss',
|
||||
],
|
||||
'web._assets_backend_helpers': [
|
||||
'html_editor/static/src/scss/bootstrap_overridden_backend.scss',
|
||||
'html_editor/static/src/scss/bootstrap_overridden.scss',
|
||||
],
|
||||
'web._assets_frontend_helpers': [
|
||||
('prepend', 'html_editor/static/src/scss/bootstrap_overridden.scss'),
|
||||
],
|
||||
'html_editor.assets_prism': [
|
||||
'web/static/lib/prismjs/prism.js',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3'
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
|
|
@ -0,0 +1,735 @@
|
|||
import contextlib
|
||||
import re
|
||||
import uuid
|
||||
from base64 import b64decode, b64encode
|
||||
from datetime import datetime
|
||||
import werkzeug.exceptions
|
||||
import werkzeug.urls
|
||||
import requests
|
||||
from os.path import join as opj
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from odoo import _, http, tools, SUPERUSER_ID
|
||||
from odoo.addons.html_editor.tools import get_video_url_data
|
||||
from odoo.exceptions import UserError, MissingError, AccessError
|
||||
from odoo.http import request
|
||||
from odoo.tools.image import image_process, image_data_uri, binary_to_image, get_webp_size
|
||||
from odoo.tools.mimetypes import guess_mimetype
|
||||
from odoo.tools.misc import file_open
|
||||
from odoo.addons.iap.tools import iap_tools
|
||||
from odoo.addons.mail.tools import link_preview
|
||||
from lxml import html, etree
|
||||
|
||||
from ..models.ir_attachment import SUPPORTED_IMAGE_MIMETYPES
|
||||
|
||||
DEFAULT_LIBRARY_ENDPOINT = 'https://media-api.odoo.com'
|
||||
DEFAULT_OLG_ENDPOINT = 'https://olg.api.odoo.com'
|
||||
|
||||
# Regex definitions to apply speed modification in SVG files
|
||||
# Note : These regex patterns are duplicated on the server side for
|
||||
# background images that are part of a CSS rule "background-image: ...". The
|
||||
# client-side regex patterns are used for images that are part of an
|
||||
# "src" attribute with a base64 encoded svg in the <img> tag. Perhaps we should
|
||||
# consider finding a solution to define them only once? The issue is that the
|
||||
# regex patterns in Python are slightly different from those in JavaScript.
|
||||
|
||||
CSS_ANIMATION_RULE_REGEX = (
|
||||
r"(?P<declaration>animation(-duration)?: .*?)"
|
||||
+ r"(?P<value>(\d+(\.\d+)?)|(\.\d+))"
|
||||
+ r"(?P<unit>ms|s)"
|
||||
+ r"(?P<separator>\s|;|\"|$)"
|
||||
)
|
||||
SVG_DUR_TIMECOUNT_VAL_REGEX = (
|
||||
r"(?P<attribute_name>\sdur=\"\s*)"
|
||||
+ r"(?P<value>(\d+(\.\d+)?)|(\.\d+))"
|
||||
+ r"(?P<unit>h|min|ms|s)?\s*\""
|
||||
)
|
||||
CSS_ANIMATION_RATIO_REGEX = (
|
||||
r"(--animation_ratio: (?P<ratio>\d*(\.\d+)?));"
|
||||
)
|
||||
|
||||
|
||||
def _get_shape_svg(self, module, *segments):
|
||||
shape_path = opj(module, 'static', *segments)
|
||||
try:
|
||||
with file_open(shape_path, 'r', filter_ext=('.svg',)) as file:
|
||||
return file.read()
|
||||
except FileNotFoundError:
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
|
||||
|
||||
def get_existing_attachment(IrAttachment, vals):
|
||||
"""
|
||||
Check if an attachment already exists for the same vals. Return it if
|
||||
so, None otherwise.
|
||||
"""
|
||||
fields = dict(vals)
|
||||
# Falsy res_id defaults to 0 on attachment creation.
|
||||
fields['res_id'] = fields.get('res_id') or 0
|
||||
raw, datas = fields.pop('raw', None), fields.pop('datas', None)
|
||||
domain = [(field, '=', value) for field, value in fields.items()]
|
||||
if fields.get('type') == 'url':
|
||||
if 'url' not in fields:
|
||||
return None
|
||||
domain.append(('checksum', '=', False))
|
||||
else:
|
||||
if not (raw or datas):
|
||||
return None
|
||||
domain.append(('checksum', '=', IrAttachment._compute_checksum(raw or b64decode(datas))))
|
||||
return IrAttachment.search(domain, limit=1) or None
|
||||
|
||||
|
||||
class HTML_Editor(http.Controller):
|
||||
|
||||
def _get_shape_svg(self, module, *segments):
|
||||
shape_path = opj(module, 'static', *segments)
|
||||
try:
|
||||
with file_open(shape_path, 'r', filter_ext=('.svg',)) as file:
|
||||
return file.read()
|
||||
except FileNotFoundError:
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
|
||||
def _update_svg_colors(self, options, svg):
|
||||
user_colors = []
|
||||
svg_options = {}
|
||||
default_palette = {
|
||||
'1': '#3AADAA',
|
||||
'2': '#7C6576',
|
||||
'3': '#F6F6F6',
|
||||
'4': '#FFFFFF',
|
||||
'5': '#383E45',
|
||||
}
|
||||
bundle_css = None
|
||||
regex_hex = r'#[0-9A-F]{6,8}'
|
||||
regex_rgba = r'rgba?\(\d{1,3}, ?\d{1,3}, ?\d{1,3}(?:, ?[0-9.]{1,4})?\)'
|
||||
for key, value in options.items():
|
||||
colorMatch = re.match('^c([1-5])$', key)
|
||||
if colorMatch:
|
||||
css_color_value = value
|
||||
# Check that color is hex or rgb(a) to prevent arbitrary injection
|
||||
if not re.match(r'(?i)^%s$|^%s$' % (regex_hex, regex_rgba), css_color_value.replace(' ', '')):
|
||||
if re.match('^o-color-([1-5])$', css_color_value):
|
||||
if not bundle_css:
|
||||
bundle = 'web.assets_frontend'
|
||||
asset = request.env["ir.qweb"]._get_asset_bundle(bundle)
|
||||
bundle_css = asset.css().index_content
|
||||
color_search = re.search(r'(?i)--%s:\s+(%s|%s)' % (css_color_value, regex_hex, regex_rgba), bundle_css)
|
||||
if not color_search:
|
||||
raise werkzeug.exceptions.BadRequest()
|
||||
css_color_value = color_search.group(1)
|
||||
else:
|
||||
raise werkzeug.exceptions.BadRequest()
|
||||
user_colors.append([tools.html_escape(css_color_value), colorMatch.group(1)])
|
||||
else:
|
||||
svg_options[key] = value
|
||||
|
||||
color_mapping = {default_palette[palette_number]: color for color, palette_number in user_colors}
|
||||
# create a case-insensitive regex to match all the colors to replace, eg: '(?i)(#3AADAA)|(#7C6576)'
|
||||
regex = '(?i)%s' % '|'.join('(%s)' % color for color in color_mapping.keys())
|
||||
|
||||
def subber(match):
|
||||
key = match.group().upper()
|
||||
return color_mapping[key] if key in color_mapping else key
|
||||
return re.sub(regex, subber, svg), svg_options
|
||||
|
||||
def replace_animation_duration(self,
|
||||
shape_animation_speed: float,
|
||||
svg: str):
|
||||
"""
|
||||
Replace animation durations in SVG and CSS with modified values.
|
||||
|
||||
This function takes a speed value and an SVG string containing
|
||||
animations. It uses regular expressions to find and replace the
|
||||
duration values in both CSS animation rules and SVG duration attributes
|
||||
based on the provided speed.
|
||||
|
||||
Parameters:
|
||||
- speed (float): The speed used to calculate the new animation
|
||||
durations.
|
||||
- svg (str): The SVG string containing animations.
|
||||
|
||||
Returns:
|
||||
str: The modified SVG string with updated animation durations.
|
||||
"""
|
||||
ratio = (1 + shape_animation_speed
|
||||
if shape_animation_speed >= 0
|
||||
else 1 / (1 - shape_animation_speed))
|
||||
|
||||
def callback_css_animation_rule(match):
|
||||
# Extracting matched groups.
|
||||
declaration, value, unit, separator = (
|
||||
match.group("declaration"),
|
||||
match.group("value"),
|
||||
match.group("unit"),
|
||||
match.group("separator"),
|
||||
)
|
||||
# Calculating new animation duration based on ratio.
|
||||
value = str(float(value) / (ratio or 1))
|
||||
# Constructing and returning the modified CSS animation rule.
|
||||
return f"{declaration}{value}{unit}{separator}"
|
||||
|
||||
def callback_svg_dur_timecount_val(match):
|
||||
attribute_name, value, unit = (
|
||||
match.group("attribute_name"),
|
||||
match.group("value"),
|
||||
match.group("unit"),
|
||||
)
|
||||
# Calculating new duration based on ratio.
|
||||
value = str(float(value) / (ratio or 1))
|
||||
# Constructing and returning the modified SVG duration attribute.
|
||||
return f'{attribute_name}{value}{unit or "s"}"'
|
||||
|
||||
def callback_css_animation_ratio(match):
|
||||
ratio = match.group("ratio")
|
||||
return f'--animation_ratio: {ratio};'
|
||||
|
||||
# Applying regex substitutions to modify animation speed in the
|
||||
# 'svg' variable.
|
||||
svg = re.sub(
|
||||
CSS_ANIMATION_RULE_REGEX,
|
||||
callback_css_animation_rule,
|
||||
svg
|
||||
)
|
||||
svg = re.sub(
|
||||
SVG_DUR_TIMECOUNT_VAL_REGEX,
|
||||
callback_svg_dur_timecount_val,
|
||||
svg
|
||||
)
|
||||
# Create or modify the css variable --animation_ratio for future
|
||||
# purpose.
|
||||
if re.match(CSS_ANIMATION_RATIO_REGEX, svg):
|
||||
svg = re.sub(
|
||||
CSS_ANIMATION_RATIO_REGEX,
|
||||
callback_css_animation_ratio,
|
||||
svg
|
||||
)
|
||||
else:
|
||||
regex = r"<svg .*>"
|
||||
declaration = f"--animation-ratio: {ratio}"
|
||||
subst = ("\\g<0>\n\t<style>\n\t\t:root { \n\t\t\t" +
|
||||
declaration +
|
||||
";\n\t\t}\n\t</style>")
|
||||
svg = re.sub(regex, subst, svg, flags=re.MULTILINE)
|
||||
return svg
|
||||
|
||||
@http.route('/html_editor/attachment/remove', type='jsonrpc', auth='user', website=True)
|
||||
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
|
||||
"""
|
||||
self._clean_context()
|
||||
Attachment = attachments_to_remove = request.env['ir.attachment']
|
||||
Views = request.env['ir.ui.view']
|
||||
|
||||
# views blocking removal of the attachment
|
||||
removal_blocked_by = {}
|
||||
|
||||
for attachment in Attachment.browse(ids):
|
||||
# in-document URLs are html-escaped, a straight search will not
|
||||
# find them
|
||||
url = tools.html_escape(attachment.local_url)
|
||||
views = Views.search([
|
||||
"|",
|
||||
('arch_db', 'like', '"%s"' % url),
|
||||
('arch_db', 'like', "'%s'" % url)
|
||||
])
|
||||
|
||||
if views:
|
||||
removal_blocked_by[attachment.id] = views.read(['name'])
|
||||
else:
|
||||
attachments_to_remove += attachment
|
||||
if attachments_to_remove:
|
||||
attachments_to_remove.unlink()
|
||||
return removal_blocked_by
|
||||
|
||||
def _clean_context(self):
|
||||
# avoid allowed_company_ids which may erroneously restrict based on website
|
||||
context = dict(request.env.context)
|
||||
context.pop('allowed_company_ids', None)
|
||||
request.update_env(context=context)
|
||||
|
||||
def _attachment_create(self, name='', data=False, url=False, res_id=False, res_model='ir.ui.view'):
|
||||
"""Create and return a new attachment."""
|
||||
IrAttachment = request.env['ir.attachment']
|
||||
|
||||
if name.lower().endswith('.bmp'):
|
||||
# Avoid mismatch between content type and mimetype, see commit msg
|
||||
name = name[:-4]
|
||||
|
||||
if not name and url:
|
||||
name = url.split("/").pop()
|
||||
|
||||
if res_model != 'ir.ui.view' and res_id:
|
||||
res_id = int(res_id)
|
||||
else:
|
||||
res_id = False
|
||||
|
||||
attachment_data = {
|
||||
'name': name,
|
||||
'public': res_model == 'ir.ui.view',
|
||||
'res_id': res_id,
|
||||
'res_model': res_model,
|
||||
}
|
||||
|
||||
if data:
|
||||
attachment_data['raw'] = data
|
||||
if url:
|
||||
attachment_data['url'] = url
|
||||
elif url:
|
||||
attachment_data.update({
|
||||
'type': 'url',
|
||||
'url': url,
|
||||
})
|
||||
# The code issues a HEAD request to retrieve headers from the URL.
|
||||
# This approach is beneficial when the URL doesn't conclude with an
|
||||
# image extension. By verifying the MIME type, the code ensures that
|
||||
# only supported image types are incorporated into the data.
|
||||
response = requests.head(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
mime_type = response.headers.get('content-type')
|
||||
if mime_type in SUPPORTED_IMAGE_MIMETYPES:
|
||||
attachment_data['mimetype'] = mime_type
|
||||
else:
|
||||
raise UserError(_("You need to specify either data or url to create an attachment."))
|
||||
|
||||
# Despite the user having no right to create an attachment, he can still
|
||||
# create an image attachment through some flows
|
||||
if (
|
||||
not request.env.is_admin()
|
||||
and IrAttachment._can_bypass_rights_on_media_dialog(**attachment_data)
|
||||
):
|
||||
attachment = IrAttachment.sudo().create(attachment_data)
|
||||
# When portal users upload an attachment with the wysiwyg widget,
|
||||
# the access token is needed to use the image in the editor. If
|
||||
# the attachment is not public, the user won't be able to generate
|
||||
# the token, so we need to generate it using sudo
|
||||
if not attachment_data['public']:
|
||||
attachment.sudo().generate_access_token()
|
||||
else:
|
||||
attachment = get_existing_attachment(IrAttachment, attachment_data) \
|
||||
or IrAttachment.create(attachment_data)
|
||||
|
||||
return attachment
|
||||
|
||||
@http.route(['/web_editor/get_image_info', '/html_editor/get_image_info'], type='jsonrpc', auth='user', website=True)
|
||||
def get_image_info(self, src=''):
|
||||
"""This route is used to determine the information of an attachment so that
|
||||
it can be used as a base to modify it again (crop/optimization/filters).
|
||||
"""
|
||||
self._clean_context()
|
||||
attachment = None
|
||||
if src.startswith('/web/image'):
|
||||
with contextlib.suppress(werkzeug.exceptions.NotFound, MissingError):
|
||||
_, args = request.env['ir.http']._match(src)
|
||||
record = request.env['ir.binary']._find_record(
|
||||
xmlid=args.get('xmlid'),
|
||||
res_model=args.get('model', 'ir.attachment'),
|
||||
res_id=args.get('id'),
|
||||
)
|
||||
if record._name == 'ir.attachment':
|
||||
attachment = record
|
||||
if not attachment:
|
||||
# Find attachment by url. There can be multiple matches because of default
|
||||
# snippet images referencing the same image in /static/, so we limit to 1
|
||||
attachment = request.env['ir.attachment'].search([
|
||||
'|', ('url', '=like', src), ('url', '=like', '%s?%%' % src),
|
||||
('mimetype', 'in', list(SUPPORTED_IMAGE_MIMETYPES.keys())),
|
||||
], limit=1)
|
||||
if not attachment:
|
||||
return {
|
||||
'attachment': False,
|
||||
'original': False,
|
||||
}
|
||||
return {
|
||||
'attachment': attachment.read(['id'])[0],
|
||||
'original': (attachment.original_id or attachment).read(['id', 'image_src', 'mimetype'])[0],
|
||||
}
|
||||
|
||||
@http.route(['/web_editor/video_url/data', '/html_editor/video_url/data'], type='jsonrpc', auth='user', website=True)
|
||||
def video_url_data(self, video_url, autoplay=False, loop=False,
|
||||
hide_controls=False, hide_fullscreen=False,
|
||||
hide_dm_logo=False, hide_dm_share=False,
|
||||
start_from=False):
|
||||
return get_video_url_data(
|
||||
video_url, autoplay=autoplay, loop=loop,
|
||||
hide_controls=hide_controls, hide_fullscreen=hide_fullscreen,
|
||||
hide_dm_logo=hide_dm_logo, hide_dm_share=hide_dm_share,
|
||||
start_from=start_from
|
||||
)
|
||||
|
||||
@http.route(['/web_editor/attachment/add_data', '/html_editor/attachment/add_data'], type='jsonrpc', auth='user', methods=['POST'], website=True)
|
||||
def add_data(self, name, data, is_image, quality=0, width=0, height=0, res_id=False, res_model='ir.ui.view', **kwargs):
|
||||
data = b64decode(data)
|
||||
if is_image:
|
||||
format_error_msg = _("Uploaded image's format is not supported. Try with: %s", ', '.join(SUPPORTED_IMAGE_MIMETYPES.values()))
|
||||
try:
|
||||
mimetype = guess_mimetype(data)
|
||||
if mimetype not in SUPPORTED_IMAGE_MIMETYPES:
|
||||
return {'error': format_error_msg}
|
||||
if not name:
|
||||
name = '%s-%s%s' % (
|
||||
datetime.now().strftime('%Y%m%d%H%M%S'),
|
||||
str(uuid.uuid4())[:6],
|
||||
SUPPORTED_IMAGE_MIMETYPES[mimetype],
|
||||
)
|
||||
data = image_process(data, size=(width, height), quality=quality, verify_resolution=True)
|
||||
except (ValueError, UserError) as e:
|
||||
# When UserError thrown, browser considers file input an
|
||||
# image but not recognized as such by PIL, eg .webp
|
||||
return {'error': e.args[0]}
|
||||
|
||||
self._clean_context()
|
||||
attachment = self._attachment_create(name=name, data=data, res_id=res_id, res_model=res_model)
|
||||
return attachment._get_media_info()
|
||||
|
||||
@http.route(['/web_editor/attachment/add_url', '/html_editor/attachment/add_url'], type='jsonrpc', auth='user', methods=['POST'], website=True)
|
||||
def add_url(self, url, res_id=False, res_model='ir.ui.view', **kwargs):
|
||||
self._clean_context()
|
||||
attachment = self._attachment_create(url=url, res_id=res_id, res_model=res_model)
|
||||
return attachment._get_media_info()
|
||||
|
||||
@http.route(['/web_editor/modify_image/<model("ir.attachment"):attachment>', '/html_editor/modify_image/<model("ir.attachment"):attachment>'], type="jsonrpc", auth="user", website=True)
|
||||
def modify_image(self, attachment, res_model=None, res_id=None, name=None, data=None, original_id=None, mimetype=None, alt_data=None):
|
||||
"""
|
||||
Creates a modified copy of an attachment and returns its image_src to be
|
||||
inserted into the DOM.
|
||||
"""
|
||||
self._clean_context()
|
||||
attachment = request.env['ir.attachment'].browse(attachment.id)
|
||||
|
||||
fields = {
|
||||
'original_id': attachment.id,
|
||||
'datas': data,
|
||||
'type': 'binary',
|
||||
'res_model': res_model or 'ir.ui.view',
|
||||
'mimetype': mimetype or attachment.mimetype,
|
||||
'name': name or attachment.name,
|
||||
'res_id': 0,
|
||||
}
|
||||
if fields['res_model'] == 'ir.ui.view':
|
||||
fields['res_id'] = 0
|
||||
elif res_id:
|
||||
fields['res_id'] = res_id
|
||||
if fields['mimetype'] == 'image/webp':
|
||||
fields['name'] = re.sub(r'\.(jpe?g|png)$', '.webp', fields['name'], flags=re.I)
|
||||
|
||||
existing_attachment = get_existing_attachment(request.env['ir.attachment'], fields)
|
||||
if existing_attachment and not existing_attachment.url:
|
||||
attachment = existing_attachment
|
||||
else:
|
||||
# Restricted editors can handle attachments related to records to
|
||||
# which they have access.
|
||||
# Would user be able to read fields of original record?
|
||||
if attachment.res_model and attachment.res_id:
|
||||
request.env[attachment.res_model].browse(attachment.res_id).check_access('read')
|
||||
|
||||
# Would user be able to write fields of target record?
|
||||
# Rights check works with res_id=0 because browse(0) returns an
|
||||
# empty record set.
|
||||
request.env[fields['res_model']].browse(fields['res_id']).check_access('write')
|
||||
|
||||
# Sudo and SUPERUSER_ID because restricted editor will not be able
|
||||
# to copy the record and the mimetype will be forced to plain text.
|
||||
attachment = attachment.with_user(SUPERUSER_ID).sudo().copy(fields)
|
||||
attachment = attachment.with_user(request.env.user.id).sudo(False)
|
||||
|
||||
if alt_data:
|
||||
for size, per_type in alt_data.items():
|
||||
reference_id = attachment.id
|
||||
if 'image/webp' in per_type:
|
||||
resized = attachment.create_unique([{
|
||||
'name': attachment.name,
|
||||
'description': 'resize: %s' % size,
|
||||
'datas': per_type['image/webp'],
|
||||
'res_id': reference_id,
|
||||
'res_model': 'ir.attachment',
|
||||
'mimetype': 'image/webp',
|
||||
}])
|
||||
reference_id = resized[0]
|
||||
if 'image/jpeg' in per_type:
|
||||
attachment.create_unique([{
|
||||
'name': re.sub(r'\.webp$', '.jpg', attachment.name, flags=re.I),
|
||||
'description': 'format: jpeg',
|
||||
'datas': per_type['image/jpeg'],
|
||||
'res_id': reference_id,
|
||||
'res_model': 'ir.attachment',
|
||||
'mimetype': 'image/jpeg',
|
||||
}])
|
||||
|
||||
if attachment.url:
|
||||
# Don't keep url if modifying static attachment because static images
|
||||
# are only served from disk and don't fallback to attachments.
|
||||
if re.match(r'^/\w+/static/', attachment.url):
|
||||
attachment.url = None
|
||||
# Uniquify url by adding a path segment with the id before the name.
|
||||
# This allows us to keep the unsplash url format so it still reacts
|
||||
# to the unsplash beacon.
|
||||
else:
|
||||
url_fragments = attachment.url.split('/')
|
||||
url_fragments.insert(-1, str(attachment.id))
|
||||
attachment.url = '/'.join(url_fragments)
|
||||
|
||||
if attachment.public:
|
||||
return attachment.image_src
|
||||
|
||||
attachment.generate_access_token()
|
||||
return '%s?access_token=%s' % (attachment.image_src, attachment.access_token)
|
||||
|
||||
@http.route(['/web_editor/save_library_media', '/html_editor/save_library_media'], type='jsonrpc', auth='user', methods=['POST'])
|
||||
def save_library_media(self, media):
|
||||
"""
|
||||
Saves images from the media library as new attachments, making them
|
||||
dynamic SVGs if needed.
|
||||
media = {
|
||||
<media_id>: {
|
||||
'query': 'space separated search terms',
|
||||
'is_dynamic_svg': True/False,
|
||||
'dynamic_colors': maps color names to their color,
|
||||
}, ...
|
||||
}
|
||||
"""
|
||||
attachments = []
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
library_endpoint = ICP.get_param('html_editor.media_library_endpoint', DEFAULT_LIBRARY_ENDPOINT)
|
||||
|
||||
media_ids = ','.join(media.keys())
|
||||
params = {
|
||||
'dbuuid': ICP.get_param('database.uuid'),
|
||||
'media_ids': media_ids,
|
||||
}
|
||||
response = requests.post('%s/media-library/1/download_urls' % library_endpoint, data=params)
|
||||
if response.status_code != requests.codes.ok:
|
||||
raise Exception(_("ERROR: couldn't get download urls from media library."))
|
||||
|
||||
slug = request.env['ir.http']._slug
|
||||
for id, url in response.json().items():
|
||||
req = requests.get(url)
|
||||
name = '_'.join([media[id]['query'], url.split('/')[-1]])
|
||||
IrAttachment = request.env['ir.attachment']
|
||||
attachment_data = {
|
||||
'name': name,
|
||||
'mimetype': req.headers['content-type'],
|
||||
'public': True,
|
||||
'raw': req.content,
|
||||
'res_model': 'ir.ui.view',
|
||||
'res_id': 0,
|
||||
}
|
||||
attachment = get_existing_attachment(IrAttachment, attachment_data)
|
||||
# Need to bypass security check to write image with mimetype image/svg+xml
|
||||
# ok because svgs come from whitelisted origin
|
||||
if not attachment:
|
||||
attachment = IrAttachment.with_user(SUPERUSER_ID).create(attachment_data)
|
||||
if media[id]['is_dynamic_svg']:
|
||||
colorParams = werkzeug.urls.url_encode(media[id]['dynamic_colors'])
|
||||
attachment['url'] = '/html_editor/shape/illustration/%s?%s' % (slug(attachment), colorParams)
|
||||
attachments.append(attachment._get_media_info())
|
||||
|
||||
return attachments
|
||||
|
||||
@http.route(['/web_editor/shape/<module>/<path:filename>', '/html_editor/shape/<module>/<path:filename>'], type='http', auth="public", website=True)
|
||||
def shape(self, module, filename, **kwargs):
|
||||
"""
|
||||
Returns a color-customized svg (background shape or illustration).
|
||||
"""
|
||||
svg = None
|
||||
if module == 'illustration':
|
||||
unslug = request.env['ir.http']._unslug
|
||||
attachment = request.env['ir.attachment'].sudo().browse(unslug(filename)[1])
|
||||
if (not attachment.exists()
|
||||
or attachment.type != 'binary'
|
||||
or not attachment.public
|
||||
or not attachment.url.startswith(request.httprequest.path)):
|
||||
# Fallback to URL lookup to allow using shapes that were
|
||||
# imported from data files.
|
||||
attachment = request.env['ir.attachment'].sudo().search([
|
||||
('type', '=', 'binary'),
|
||||
('public', '=', True),
|
||||
('url', '=', request.httprequest.path),
|
||||
], limit=1)
|
||||
if not attachment:
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
svg = attachment.raw.decode('utf-8')
|
||||
else:
|
||||
# Used for compatibility
|
||||
if module == 'web_editor':
|
||||
module = 'html_builder'
|
||||
svg = self._get_shape_svg(module, 'shapes', filename)
|
||||
|
||||
svg, options = self._update_svg_colors(kwargs, svg)
|
||||
flip_value = options.get('flip', False)
|
||||
if flip_value == 'x':
|
||||
svg = svg.replace('<svg ', '<svg style="transform: scaleX(-1);" ', 1)
|
||||
elif flip_value == 'y':
|
||||
svg = svg.replace('<svg ', '<svg style="transform: scaleY(-1)" ', 1)
|
||||
elif flip_value == 'xy':
|
||||
svg = svg.replace('<svg ', '<svg style="transform: scale(-1)" ', 1)
|
||||
|
||||
shape_animation_speed = float(options.get('shapeAnimationSpeed', 0.0))
|
||||
if shape_animation_speed != 0.0:
|
||||
svg = self.replace_animation_duration(
|
||||
shape_animation_speed=shape_animation_speed,
|
||||
svg=svg
|
||||
)
|
||||
return request.make_response(svg, [
|
||||
('Content-type', 'image/svg+xml'),
|
||||
('Cache-control', 'max-age=%s' % http.STATIC_CACHE_LONG),
|
||||
])
|
||||
|
||||
@http.route(['/web_editor/image_shape/<string:img_key>/<module>/<path:filename>', '/html_editor/image_shape/<string:img_key>/<module>/<path:filename>'], type='http', auth="public", website=True)
|
||||
def image_shape(self, module, filename, img_key, **kwargs):
|
||||
# Used for compatibility
|
||||
if module == 'web_editor':
|
||||
module = 'html_builder'
|
||||
svg = self._get_shape_svg(module, 'image_shapes', filename)
|
||||
|
||||
record = request.env['ir.binary']._find_record(img_key)
|
||||
stream = request.env['ir.binary']._get_image_stream_from(record)
|
||||
if stream.type == 'url':
|
||||
return stream.get_response()
|
||||
|
||||
image = stream.read()
|
||||
if record.mimetype == "image/webp":
|
||||
width, height = (str(size) for size in get_webp_size(image))
|
||||
else:
|
||||
img = binary_to_image(image)
|
||||
width, height = (str(size) for size in img.size)
|
||||
root = etree.fromstring(svg)
|
||||
|
||||
if root.attrib.get("data-forced-size"):
|
||||
# Adjusts the SVG height to ensure the image fits properly within
|
||||
# the SVG (e.g. for "devices" shapes).
|
||||
svgHeight = float(root.attrib.get("height"))
|
||||
svgWidth = float(root.attrib.get("width"))
|
||||
svgAspectRatio = svgWidth / svgHeight
|
||||
height = str(float(width) / svgAspectRatio)
|
||||
|
||||
root.attrib.update({'width': width, 'height': height})
|
||||
# Update default color palette on shape SVG.
|
||||
svg, _ = self._update_svg_colors(kwargs, etree.tostring(root, pretty_print=True).decode('utf-8'))
|
||||
# Add image in base64 inside the shape.
|
||||
uri = image_data_uri(b64encode(image))
|
||||
svg = svg.replace('<image xlink:href="', '<image xlink:href="%s' % uri)
|
||||
|
||||
return request.make_response(svg, [
|
||||
('Content-type', 'image/svg+xml'),
|
||||
('Cache-control', 'max-age=%s' % http.STATIC_CACHE_LONG),
|
||||
])
|
||||
|
||||
@http.route(["/web_editor/generate_text", "/html_editor/generate_text"], type="jsonrpc", auth="user")
|
||||
def generate_text(self, prompt, conversation_history):
|
||||
try:
|
||||
IrConfigParameter = request.env['ir.config_parameter'].sudo()
|
||||
olg_api_endpoint = IrConfigParameter.get_param('html_editor.olg_api_endpoint', DEFAULT_OLG_ENDPOINT)
|
||||
database_id = IrConfigParameter.get_param('database.uuid')
|
||||
response = iap_tools.iap_jsonrpc(olg_api_endpoint + "/api/olg/1/chat", params={
|
||||
'prompt': prompt,
|
||||
'conversation_history': conversation_history or [],
|
||||
'database_id': database_id,
|
||||
}, timeout=30)
|
||||
if response['status'] == 'success':
|
||||
return response['content']
|
||||
elif response['status'] == 'error_prompt_too_long':
|
||||
raise UserError(_("Sorry, your prompt is too long. Try to say it in fewer words."))
|
||||
elif response['status'] == 'limit_call_reached':
|
||||
raise UserError(_("You have reached the maximum number of requests for this service. Try again later."))
|
||||
else:
|
||||
raise UserError(_("Sorry, we could not generate a response. Please try again later."))
|
||||
except AccessError:
|
||||
raise AccessError(_("Oops, it looks like our AI is unreachable!"))
|
||||
|
||||
@http.route(["/web_editor/get_ice_servers", "/html_editor/get_ice_servers"], type='jsonrpc', auth="user")
|
||||
def get_ice_servers(self):
|
||||
return request.env['mail.ice.server']._get_ice_servers()
|
||||
|
||||
@http.route(["/web_editor/bus_broadcast", "/html_editor/bus_broadcast"], type="jsonrpc", auth="user")
|
||||
def bus_broadcast(self, model_name, field_name, res_id, bus_data):
|
||||
document = request.env[model_name].browse([res_id])
|
||||
|
||||
document.check_access('read')
|
||||
document.check_access('write')
|
||||
if field := document._fields.get(field_name):
|
||||
document._check_field_access(field, 'read')
|
||||
document._check_field_access(field, 'write')
|
||||
|
||||
channel = (request.db, 'editor_collaboration', model_name, field_name, int(res_id))
|
||||
bus_data.update({'model_name': model_name, 'field_name': field_name, 'res_id': res_id})
|
||||
request.env['bus.bus']._sendone(channel, 'editor_collaboration', bus_data)
|
||||
|
||||
@http.route('/html_editor/link_preview_external', type="jsonrpc", auth="public", methods=['POST'])
|
||||
def link_preview_metadata(self, preview_url):
|
||||
link_preview_data = link_preview.get_link_preview_from_url(preview_url)
|
||||
if link_preview_data and link_preview_data.get('og_description'):
|
||||
link_preview_data['og_description'] = html.fromstring(link_preview_data['og_description']).text_content()
|
||||
return link_preview_data
|
||||
|
||||
@http.route('/html_editor/link_preview_internal', type="jsonrpc", auth="user", methods=['POST'])
|
||||
def link_preview_metadata_internal(self, preview_url):
|
||||
try:
|
||||
Actions = request.env['ir.actions.actions']
|
||||
context = dict(request.env.context)
|
||||
parsed_preview_url = urlparse(preview_url)
|
||||
words = parsed_preview_url.path.strip('/').split('/')
|
||||
last_segment = words[-1]
|
||||
|
||||
if not (
|
||||
last_segment.isnumeric()
|
||||
and (
|
||||
parsed_preview_url.path.startswith("/odoo")
|
||||
or parsed_preview_url.path.startswith("/web")
|
||||
or parsed_preview_url.path.startswith("/@/")
|
||||
)
|
||||
):
|
||||
# this could be a frontend or an external page
|
||||
link_preview_data = self.link_preview_metadata(preview_url)
|
||||
result = {}
|
||||
if link_preview_data and link_preview_data.get('og_description'):
|
||||
result['description'] = link_preview_data['og_description']
|
||||
return result
|
||||
|
||||
record_id = int(words.pop())
|
||||
action_name = words.pop()
|
||||
if (action_name.startswith('m-') or '.' in action_name) and action_name in request.env and not request.env[action_name]._abstract:
|
||||
# if path format is `odoo/<model>/<record_id>` so we use `action_name` as model name
|
||||
model_name = action_name.removeprefix('m-')
|
||||
model = request.env[model_name].with_context(context)
|
||||
else:
|
||||
action = Actions.sudo().search([('path', '=', action_name)])
|
||||
if not action:
|
||||
return {'error_msg': _("Action %s not found, link preview is not available, please check your url is correct", action_name)}
|
||||
action_type = action.type
|
||||
if action_type != 'ir.actions.act_window':
|
||||
return {'other_error_msg': _("Action %s is not a window action, link preview is not available", action_name)}
|
||||
action = request.env[action_type].browse(action.id)
|
||||
|
||||
model = request.env[action.res_model].with_context(context)
|
||||
|
||||
record = model.browse(record_id)
|
||||
|
||||
result = {}
|
||||
if 'description' in record:
|
||||
result['description'] = html.fromstring(record.description).text_content() if record.description else ""
|
||||
|
||||
if 'link_preview_name' in record:
|
||||
result['link_preview_name'] = record.link_preview_name
|
||||
elif 'display_name' in record:
|
||||
result['display_name'] = record.display_name
|
||||
|
||||
return result
|
||||
except (MissingError) as e:
|
||||
return {'error_msg': _("Link preview is not available because %s, please check if your url is correct", str(e))}
|
||||
# catch all other exceptions and return the error message to display in the console but not blocking the flow
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {'other_error_msg': str(e)}
|
||||
|
||||
@http.route(['/html_editor/media_library_search'], type='jsonrpc', auth="user", website=True)
|
||||
def media_library_search(self, **params):
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
endpoint = ICP.get_param('html_editor.media_library_endpoint', DEFAULT_LIBRARY_ENDPOINT)
|
||||
params['dbuuid'] = ICP.get_param('database.uuid')
|
||||
response = requests.post('%s/media-library/1/search' % endpoint, data=params, timeout=5)
|
||||
if response.status_code == requests.codes.ok and response.headers['content-type'] == 'application/json':
|
||||
return response.json()
|
||||
else:
|
||||
return {'error': response.status_code}
|
||||
2418
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/ar.po
Normal file
2418
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/ar.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/az.po
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/az.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/bg.po
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/bg.po
Normal file
File diff suppressed because it is too large
Load diff
2422
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/ca.po
Normal file
2422
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/ca.po
Normal file
File diff suppressed because it is too large
Load diff
2394
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/cs.po
Normal file
2394
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/cs.po
Normal file
File diff suppressed because it is too large
Load diff
2398
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/da.po
Normal file
2398
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/da.po
Normal file
File diff suppressed because it is too large
Load diff
2495
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/de.po
Normal file
2495
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/de.po
Normal file
File diff suppressed because it is too large
Load diff
2384
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/el.po
Normal file
2384
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/el.po
Normal file
File diff suppressed because it is too large
Load diff
2431
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/es.po
Normal file
2431
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/es.po
Normal file
File diff suppressed because it is too large
Load diff
2528
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/es_419.po
Normal file
2528
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/es_419.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/et.po
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/et.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/fa.po
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/fa.po
Normal file
File diff suppressed because it is too large
Load diff
2422
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/fi.po
Normal file
2422
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/fi.po
Normal file
File diff suppressed because it is too large
Load diff
2435
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/fr.po
Normal file
2435
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/fr.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/he.po
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/he.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/hi.po
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/hi.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/hr.po
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/hr.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/html_editor.pot
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/html_editor.pot
Normal file
File diff suppressed because it is too large
Load diff
2417
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/hu.po
Normal file
2417
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/hu.po
Normal file
File diff suppressed because it is too large
Load diff
2424
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/id.po
Normal file
2424
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/id.po
Normal file
File diff suppressed because it is too large
Load diff
2426
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/it.po
Normal file
2426
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/it.po
Normal file
File diff suppressed because it is too large
Load diff
2421
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/ja.po
Normal file
2421
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/ja.po
Normal file
File diff suppressed because it is too large
Load diff
2420
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/ko.po
Normal file
2420
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/ko.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/ku.po
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/ku.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/lt.po
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/lt.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/mn.po
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/mn.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/my.po
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/my.po
Normal file
File diff suppressed because it is too large
Load diff
2391
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/nb.po
Normal file
2391
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/nb.po
Normal file
File diff suppressed because it is too large
Load diff
2428
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/nl.po
Normal file
2428
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/nl.po
Normal file
File diff suppressed because it is too large
Load diff
2427
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/pl.po
Normal file
2427
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/pl.po
Normal file
File diff suppressed because it is too large
Load diff
2429
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/pt.po
Normal file
2429
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/pt.po
Normal file
File diff suppressed because it is too large
Load diff
2439
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/pt_BR.po
Normal file
2439
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/pt_BR.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/ro.po
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/ro.po
Normal file
File diff suppressed because it is too large
Load diff
2498
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/ru.po
Normal file
2498
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/ru.po
Normal file
File diff suppressed because it is too large
Load diff
2422
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/sl.po
Normal file
2422
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/sl.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/sr@latin.po
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/sr@latin.po
Normal file
File diff suppressed because it is too large
Load diff
2402
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/sv.po
Normal file
2402
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/sv.po
Normal file
File diff suppressed because it is too large
Load diff
2401
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/th.po
Normal file
2401
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/th.po
Normal file
File diff suppressed because it is too large
Load diff
2416
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/tr.po
Normal file
2416
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/tr.po
Normal file
File diff suppressed because it is too large
Load diff
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/uk.po
Normal file
2353
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/uk.po
Normal file
File diff suppressed because it is too large
Load diff
2420
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/vi.po
Normal file
2420
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/vi.po
Normal file
File diff suppressed because it is too large
Load diff
2407
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/zh_CN.po
Normal file
2407
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/zh_CN.po
Normal file
File diff suppressed because it is too large
Load diff
2420
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/zh_TW.po
Normal file
2420
odoo-bringout-oca-ocb-html_editor/html_editor/i18n/zh_TW.po
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,10 @@
|
|||
from . import ir_attachment
|
||||
from . import ir_http
|
||||
from . import ir_qweb_fields
|
||||
from . import ir_ui_view
|
||||
from . import ir_websocket
|
||||
|
||||
from . import models
|
||||
from . import test_models
|
||||
|
||||
from . import html_field_history_mixin
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import re
|
||||
|
||||
from difflib import SequenceMatcher, unified_diff
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Patch and comparison functions
|
||||
# ------------------------------------------------------------
|
||||
|
||||
|
||||
OPERATION_SEPARATOR = "\n"
|
||||
LINE_SEPARATOR = "<"
|
||||
|
||||
PATCH_OPERATION_LINE_AT = "@"
|
||||
PATCH_OPERATION_CONTENT = ":"
|
||||
|
||||
PATCH_OPERATION_ADD = "+"
|
||||
PATCH_OPERATION_REMOVE = "-"
|
||||
PATCH_OPERATION_REPLACE = "R"
|
||||
|
||||
PATCH_OPERATIONS = dict(
|
||||
insert=PATCH_OPERATION_ADD,
|
||||
delete=PATCH_OPERATION_REMOVE,
|
||||
replace=PATCH_OPERATION_REPLACE,
|
||||
)
|
||||
|
||||
HTML_ATTRIBUTES_TO_REMOVE = ["data-last-history-steps"]
|
||||
HTML_TAG_ISOLATION_REGEX = r"^([^>]*>)(.*)$"
|
||||
ADDITION_COMPARISON_REGEX = r"\1<added>\2</added>"
|
||||
ADDITION_1ST_REPLACE_COMPARISON_REGEX = r"added>\2</added>"
|
||||
DELETION_COMPARISON_REGEX = r"\1<removed>\2</removed>"
|
||||
EMPTY_OPERATION_TAG = r"<(added|removed)><\/(added|removed)>"
|
||||
SAME_TAG_REPLACE_FIXER = r"<\/added><(?:[^\/>]|(?:><))+><removed>"
|
||||
UNNECESSARY_REPLACE_FIXER = (
|
||||
r"<added>([^<](?!<\/added>)*)<\/added>"
|
||||
r"<removed>([^<](?!<\/removed>)*)<\/removed>"
|
||||
)
|
||||
|
||||
|
||||
def apply_patch(initial_content, patch):
|
||||
"""Apply a patch (multiple operations) on a content.
|
||||
Each operation is a string with the following format:
|
||||
<operation_type>@<start_index>[,<end_index>][:<patch_text>*]
|
||||
patch format example:
|
||||
+@4:<p>ab</p><p>cd</p>
|
||||
+@4,15:<p>ef</p><p>gh</p>
|
||||
-@32
|
||||
-@125,129
|
||||
R@523:<b>sdf</b>
|
||||
|
||||
:param string initial_content: the initial content to patch
|
||||
:param string patch: the patch to apply
|
||||
|
||||
:return: string: the patched content
|
||||
"""
|
||||
if not patch:
|
||||
return initial_content
|
||||
|
||||
# Replace break line in initial content to ensure they don't interfere with
|
||||
# operations
|
||||
initial_content = initial_content.replace("\n", "")
|
||||
initial_content = _remove_html_attribute(
|
||||
initial_content, HTML_ATTRIBUTES_TO_REMOVE
|
||||
)
|
||||
|
||||
content = initial_content.split(LINE_SEPARATOR)
|
||||
patch_operations = patch.split(OPERATION_SEPARATOR)
|
||||
# Apply operations in reverse order to preserve the indexes integrity.
|
||||
patch_operations.reverse()
|
||||
|
||||
for operation in patch_operations:
|
||||
metadata, *patch_content_line = operation.split(LINE_SEPARATOR)
|
||||
|
||||
metadata_split = metadata.split(PATCH_OPERATION_LINE_AT)
|
||||
operation_type = metadata_split[0]
|
||||
lines_index_range = metadata_split[1] if len(metadata_split) > 1 else ""
|
||||
# We need to remove PATCH_OPERATION_CONTENT char from lines_index_range.
|
||||
lines_index_range = lines_index_range.split(PATCH_OPERATION_CONTENT)[0]
|
||||
indexes = lines_index_range.split(",")
|
||||
start_index = int(indexes[0])
|
||||
end_index = int(indexes[1]) if len(indexes) > 1 else start_index
|
||||
|
||||
# We need to insert lines from last to the first
|
||||
# to preserve the indexes integrity.
|
||||
patch_content_line.reverse()
|
||||
|
||||
if end_index > start_index:
|
||||
for index in range(end_index, start_index, -1):
|
||||
if operation_type in [
|
||||
PATCH_OPERATION_REMOVE,
|
||||
PATCH_OPERATION_REPLACE,
|
||||
]:
|
||||
del content[index]
|
||||
|
||||
if operation_type in [PATCH_OPERATION_ADD, PATCH_OPERATION_REPLACE]:
|
||||
for line in patch_content_line:
|
||||
content.insert(start_index + 1, line)
|
||||
if operation_type in [PATCH_OPERATION_REMOVE, PATCH_OPERATION_REPLACE]:
|
||||
del content[start_index]
|
||||
|
||||
return LINE_SEPARATOR.join(content)
|
||||
|
||||
|
||||
def generate_comparison(new_content, old_content):
|
||||
"""Compare a content to an older content
|
||||
and generate a comparison html between both content.
|
||||
|
||||
:param string new_content: the current content
|
||||
:param string old_content: the old content
|
||||
|
||||
:return: string: the comparison content
|
||||
"""
|
||||
new_content = _remove_html_attribute(new_content, HTML_ATTRIBUTES_TO_REMOVE)
|
||||
old_content = _remove_html_attribute(old_content, HTML_ATTRIBUTES_TO_REMOVE)
|
||||
|
||||
if new_content == old_content:
|
||||
return new_content
|
||||
|
||||
patch = generate_patch(new_content, old_content)
|
||||
comparison = new_content.split(LINE_SEPARATOR)
|
||||
patch_operations = patch.split(OPERATION_SEPARATOR)
|
||||
# We need to apply operation from last to the first
|
||||
# to preserve the indexes integrity.
|
||||
patch_operations.reverse()
|
||||
|
||||
for operation in patch_operations:
|
||||
metadata, *patch_content_line = operation.split(LINE_SEPARATOR)
|
||||
|
||||
metadata_split = metadata.split(PATCH_OPERATION_LINE_AT)
|
||||
operation_type = metadata_split[0]
|
||||
lines_index_range = metadata_split[1] if len(metadata_split) > 1 else ""
|
||||
lines_index_range = lines_index_range.split(PATCH_OPERATION_CONTENT)[0]
|
||||
indexes = lines_index_range.split(",")
|
||||
start_index = int(indexes[0])
|
||||
end_index = int(indexes[1]) if len(indexes) > 1 else start_index
|
||||
|
||||
# If the operation is a replace, we need to flag the changes that
|
||||
# will generate ghost opening tags if we don't ignore
|
||||
# them.
|
||||
# this can append when:
|
||||
# * A change concerning only html parameters.
|
||||
# <p class="x">a</p> => <p class="y">a</p>
|
||||
# * An addition in a previously empty element opening tag
|
||||
# <p></p> => <p>a</p>
|
||||
if operation_type == PATCH_OPERATION_REPLACE:
|
||||
for i, line in enumerate(patch_content_line):
|
||||
current_index = start_index + i
|
||||
if current_index > end_index:
|
||||
break
|
||||
|
||||
current_line = comparison[current_index]
|
||||
current_line_tag = current_line.split(">")[0]
|
||||
line_tag = line.split(">")[0]
|
||||
if current_line[-1] == ">" and (
|
||||
current_line_tag == line_tag
|
||||
or current_line_tag.split(" ")[0] == line_tag.split(" ")[0]
|
||||
):
|
||||
comparison[start_index + i] = "delete_me>"
|
||||
|
||||
# We need to insert lines from last to the first
|
||||
# to preserve the indexes integrity.
|
||||
patch_content_line.reverse()
|
||||
|
||||
for index in range(end_index, start_index - 1, -1):
|
||||
if operation_type in [
|
||||
PATCH_OPERATION_REMOVE,
|
||||
PATCH_OPERATION_REPLACE,
|
||||
]:
|
||||
deletion_flagged_comparison = re.sub(
|
||||
HTML_TAG_ISOLATION_REGEX,
|
||||
DELETION_COMPARISON_REGEX,
|
||||
comparison[index],
|
||||
)
|
||||
# Only use this line if it doesn't generate an empty
|
||||
# <removed> tag
|
||||
if not re.search(
|
||||
EMPTY_OPERATION_TAG, deletion_flagged_comparison
|
||||
):
|
||||
comparison[index] = deletion_flagged_comparison
|
||||
|
||||
if operation_type == PATCH_OPERATION_ADD:
|
||||
for line in patch_content_line:
|
||||
addition_flagged_line = re.sub(
|
||||
HTML_TAG_ISOLATION_REGEX, ADDITION_COMPARISON_REGEX, line
|
||||
)
|
||||
|
||||
if not re.search(EMPTY_OPERATION_TAG, addition_flagged_line):
|
||||
comparison.insert(start_index + 1, addition_flagged_line)
|
||||
else:
|
||||
comparison.insert(start_index + 1, line)
|
||||
|
||||
if operation_type == PATCH_OPERATION_REPLACE:
|
||||
for line in patch_content_line:
|
||||
addition_flagged_line = re.sub(
|
||||
HTML_TAG_ISOLATION_REGEX, ADDITION_COMPARISON_REGEX, line
|
||||
)
|
||||
if not re.search(EMPTY_OPERATION_TAG, addition_flagged_line):
|
||||
comparison.insert(start_index, addition_flagged_line)
|
||||
elif (
|
||||
line.split(">")[0] != comparison[start_index].split(">")[0]
|
||||
):
|
||||
comparison.insert(start_index, line)
|
||||
|
||||
final_comparison = LINE_SEPARATOR.join(comparison)
|
||||
# We can remove all the opening tags which are located between the end of an
|
||||
# added tag and the start of a removed tag, because this should never happen
|
||||
# as the added and removed tags should always be near each other.
|
||||
# This can happen when the new container tag had a parameter change.
|
||||
final_comparison = re.sub(
|
||||
SAME_TAG_REPLACE_FIXER, "</added><removed>", final_comparison
|
||||
)
|
||||
|
||||
# Remove al the <delete_me> tags
|
||||
final_comparison = final_comparison.replace(r"<delete_me>", "")
|
||||
|
||||
# This fix the issue of unnecessary replace tags.
|
||||
# ex: <added>abc</added><removed>abc</removed> -> abc
|
||||
# This can occur when the new content is the same as the old content and
|
||||
# their container tags are the same but the tags parameters are different
|
||||
for match in re.finditer(UNNECESSARY_REPLACE_FIXER, final_comparison):
|
||||
if match.group(1) == match.group(2):
|
||||
final_comparison = final_comparison.replace(
|
||||
match.group(0), match.group(1)
|
||||
)
|
||||
|
||||
return final_comparison
|
||||
|
||||
|
||||
def _format_line_index(start, end):
|
||||
"""Format the line index to be used in a patch operation.
|
||||
|
||||
:param start: the start index
|
||||
:param end: the end index
|
||||
:return: string
|
||||
"""
|
||||
length = end - start
|
||||
if not length:
|
||||
start -= 1
|
||||
if length <= 1:
|
||||
return "%s%s" % (PATCH_OPERATION_LINE_AT, start)
|
||||
return "%s%s,%s" % (PATCH_OPERATION_LINE_AT, start, start + length - 1)
|
||||
|
||||
|
||||
def _patch_generator(new_content, old_content):
|
||||
"""Generate a patch (multiple operations) between two contents.
|
||||
Each operation is a string with the following format:
|
||||
<operation_type>@<start_index>[,<end_index>][:<patch_text>*]
|
||||
patch format example:
|
||||
+@4:<p>ab</p><p>cd</p>
|
||||
+@4,15:<p>ef</p><p>gh</p>
|
||||
-@32
|
||||
-@125,129
|
||||
R@523:<b>sdf</b>
|
||||
|
||||
:param string new_content: the new content
|
||||
:param string old_content: the old content
|
||||
|
||||
:return: string: the patch containing all the operations to reverse
|
||||
the new content to the old content
|
||||
"""
|
||||
# remove break line in contents to ensure they don't interfere with
|
||||
# operations
|
||||
new_content = new_content.replace("\n", "")
|
||||
old_content = old_content.replace("\n", "")
|
||||
|
||||
new_content_lines = new_content.split(LINE_SEPARATOR)
|
||||
old_content_lines = old_content.split(LINE_SEPARATOR)
|
||||
|
||||
for group in SequenceMatcher(
|
||||
None, new_content_lines, old_content_lines, False
|
||||
).get_grouped_opcodes(0):
|
||||
patch_content_line = []
|
||||
first, last = group[0], group[-1]
|
||||
patch_operation = _format_line_index(first[1], last[2])
|
||||
|
||||
if any(tag in {"replace", "delete"} for tag, _, _, _, _ in group):
|
||||
for tag, _, _, _, _ in group:
|
||||
if tag not in {"insert", "equal", "replace"}:
|
||||
patch_operation = PATCH_OPERATIONS[tag] + patch_operation
|
||||
|
||||
if any(tag in {"replace", "insert"} for tag, _, _, _, _ in group):
|
||||
for tag, _, _, j1, j2 in group:
|
||||
if tag not in {"delete", "equal"}:
|
||||
patch_operation = PATCH_OPERATIONS[tag] + patch_operation
|
||||
for line in old_content_lines[j1:j2]:
|
||||
patch_content_line.append(line)
|
||||
|
||||
if patch_content_line:
|
||||
patch_content = LINE_SEPARATOR + LINE_SEPARATOR.join(
|
||||
patch_content_line
|
||||
)
|
||||
yield str(patch_operation) + PATCH_OPERATION_CONTENT + patch_content
|
||||
else:
|
||||
yield str(patch_operation)
|
||||
|
||||
|
||||
def generate_patch(new_content, old_content):
|
||||
new_content = _remove_html_attribute(new_content, HTML_ATTRIBUTES_TO_REMOVE)
|
||||
old_content = _remove_html_attribute(old_content, HTML_ATTRIBUTES_TO_REMOVE)
|
||||
|
||||
return OPERATION_SEPARATOR.join(
|
||||
list(_patch_generator(new_content, old_content))
|
||||
)
|
||||
|
||||
|
||||
def _remove_html_attribute(html_content, attributes_to_remove):
|
||||
for attribute in attributes_to_remove:
|
||||
html_content = re.sub(
|
||||
r' %s="[^"]*"' % attribute, "", html_content
|
||||
)
|
||||
|
||||
return html_content
|
||||
|
||||
|
||||
def _indent(content):
|
||||
"""Indent the content using BeautifulSoup.
|
||||
|
||||
:param string content: the content to indent
|
||||
|
||||
:return: string: the indented content
|
||||
"""
|
||||
content = "<document>" + _remove_html_attribute(content, HTML_ATTRIBUTES_TO_REMOVE) + "</document>"
|
||||
soup = BeautifulSoup(content, 'html.parser')
|
||||
return soup.prettify()
|
||||
|
||||
|
||||
def generate_unified_diff(new_content, old_content):
|
||||
"""Generate a unified diff between two contents.
|
||||
|
||||
:param string new_content: the current content
|
||||
:param string old_content: the old content
|
||||
|
||||
:return: string: the unified diff content
|
||||
"""
|
||||
new_content = _indent(new_content)
|
||||
old_content = _indent(old_content)
|
||||
|
||||
return OPERATION_SEPARATOR.join(
|
||||
list(unified_diff(
|
||||
old_content.split(OPERATION_SEPARATOR),
|
||||
new_content.split(OPERATION_SEPARATOR),
|
||||
fromfile='old',
|
||||
tofile='new'
|
||||
))
|
||||
)
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from .diff_utils import apply_patch, generate_comparison, generate_patch, generate_unified_diff
|
||||
|
||||
|
||||
class HtmlFieldHistoryMixin(models.AbstractModel):
|
||||
_name = 'html.field.history.mixin'
|
||||
_description = "Field html History"
|
||||
_html_field_history_size_limit = 300
|
||||
|
||||
html_field_history = fields.Json("History data", prefetch=False)
|
||||
|
||||
html_field_history_metadata = fields.Json(
|
||||
"History metadata", compute="_compute_metadata"
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _get_versioned_fields(self):
|
||||
"""This method should be overriden
|
||||
|
||||
:return: List[string]: A list of name of the fields to be versioned
|
||||
"""
|
||||
return []
|
||||
|
||||
@api.depends("html_field_history")
|
||||
def _compute_metadata(self):
|
||||
for rec in self:
|
||||
history_metadata = None
|
||||
if rec.html_field_history:
|
||||
history_metadata = {}
|
||||
for field_name in rec.html_field_history:
|
||||
history_metadata[field_name] = []
|
||||
for revision in rec.html_field_history[field_name]:
|
||||
metadata = revision.copy()
|
||||
metadata.pop("patch")
|
||||
history_metadata[field_name].append(metadata)
|
||||
rec.html_field_history_metadata = history_metadata
|
||||
|
||||
def write(self, vals):
|
||||
rec_db_contents = {}
|
||||
versioned_fields = self._get_versioned_fields()
|
||||
vals_contain_versioned_fields = set(vals).intersection(versioned_fields)
|
||||
|
||||
if vals_contain_versioned_fields:
|
||||
for rec in self:
|
||||
rec_db_contents[rec.id] = {f: rec[f] for f in versioned_fields}
|
||||
|
||||
# Call super().write before generating the patch to be sure we perform
|
||||
# the diff on sanitized data
|
||||
write_result = super().write(vals)
|
||||
|
||||
if not vals_contain_versioned_fields:
|
||||
return write_result
|
||||
|
||||
# allow mutlti record write
|
||||
for rec in self:
|
||||
new_revisions = False
|
||||
fields_data = self.env[rec._name]._fields
|
||||
|
||||
if any(f in vals and not fields_data[f].sanitize for f in versioned_fields):
|
||||
raise ValidationError( # pylint: disable=missing-gettext
|
||||
"Ensure all versioned fields ( %s ) in model %s are declared as sanitize=True"
|
||||
% (str(versioned_fields), rec._name)
|
||||
)
|
||||
|
||||
history_revs = rec.html_field_history or {}
|
||||
|
||||
for field in versioned_fields:
|
||||
new_content = rec[field] or ""
|
||||
|
||||
if field not in history_revs:
|
||||
history_revs[field] = []
|
||||
|
||||
old_content = rec_db_contents[rec.id][field] or ""
|
||||
if new_content != old_content:
|
||||
new_revisions = True
|
||||
patch = generate_patch(new_content, old_content)
|
||||
revision_id = (
|
||||
(history_revs[field][0]["revision_id"] + 1)
|
||||
if history_revs[field]
|
||||
else 1
|
||||
)
|
||||
|
||||
history_revs[field].insert(
|
||||
0,
|
||||
{
|
||||
"patch": patch,
|
||||
"revision_id": revision_id,
|
||||
"create_date": self.env.cr.now().isoformat(),
|
||||
"create_uid": self.env.uid,
|
||||
"create_user_name": self.env.user.name,
|
||||
},
|
||||
)
|
||||
limit = rec._html_field_history_size_limit
|
||||
history_revs[field] = history_revs[field][:limit]
|
||||
# Call super().write again to include the new revision
|
||||
if new_revisions:
|
||||
extra_vals = {"html_field_history": history_revs}
|
||||
write_result = super(HtmlFieldHistoryMixin, rec).write(extra_vals) and write_result
|
||||
|
||||
return write_result
|
||||
|
||||
def html_field_history_get_content_at_revision(self, field_name, revision_id):
|
||||
"""Get the requested field content restored at the revision_id.
|
||||
|
||||
:param str field_name: the name of the field
|
||||
:param int revision_id: id of the last revision to restore
|
||||
|
||||
:return: string: the restored content
|
||||
"""
|
||||
self.ensure_one()
|
||||
revisions = [
|
||||
i
|
||||
for i in self.html_field_history[field_name]
|
||||
if i["revision_id"] >= revision_id
|
||||
]
|
||||
|
||||
content = self[field_name] or ""
|
||||
for revision in revisions:
|
||||
content = apply_patch(content, revision["patch"])
|
||||
|
||||
return content
|
||||
|
||||
def html_field_history_get_comparison_at_revision(self, field_name, revision_id):
|
||||
"""For the requested field,
|
||||
Get a comparison between the current content of the field and the
|
||||
content restored at the requested revision_id.
|
||||
|
||||
:param str field_name: the name of the field
|
||||
:param int revision_id: id of the last revision to compare
|
||||
|
||||
:return: string: the comparison
|
||||
"""
|
||||
self.ensure_one()
|
||||
restored_content = self.html_field_history_get_content_at_revision(
|
||||
field_name, revision_id
|
||||
)
|
||||
|
||||
return generate_comparison(restored_content, self[field_name] or "")
|
||||
|
||||
def html_field_history_get_unified_diff_at_revision(self, field_name, revision_id):
|
||||
"""For the requested field,
|
||||
Get a unified diff between the current content of the field and the
|
||||
content restored at the requested revision_id.
|
||||
|
||||
:param str field_name: the name of the field
|
||||
:param int revision_id: id of the last revision to compare
|
||||
|
||||
:return: string: the unified diff
|
||||
"""
|
||||
self.ensure_one()
|
||||
restored_content = self.html_field_history_get_content_at_revision(
|
||||
field_name, revision_id
|
||||
)
|
||||
|
||||
return generate_unified_diff(self[field_name] or "", restored_content)
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from odoo import api, models, fields
|
||||
from odoo.tools.image import base64_to_image
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
SUPPORTED_IMAGE_MIMETYPES = {
|
||||
'image/gif': '.gif',
|
||||
'image/jpe': '.jpe',
|
||||
'image/jpeg': '.jpeg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/svg+xml': '.svg',
|
||||
'image/webp': '.webp',
|
||||
}
|
||||
|
||||
|
||||
class IrAttachment(models.Model):
|
||||
_inherit = "ir.attachment"
|
||||
|
||||
local_url = fields.Char("Attachment URL", compute='_compute_local_url')
|
||||
image_src = fields.Char(compute='_compute_image_src')
|
||||
image_width = fields.Integer(compute='_compute_image_size')
|
||||
image_height = fields.Integer(compute='_compute_image_size')
|
||||
original_id = fields.Many2one('ir.attachment', string="Original (unoptimized, unresized) attachment", index='btree_not_null')
|
||||
|
||||
def _compute_local_url(self):
|
||||
for attachment in self:
|
||||
if attachment.url:
|
||||
attachment.local_url = attachment.url
|
||||
else:
|
||||
attachment.local_url = '/web/image/%s?unique=%s' % (attachment.id, attachment.checksum)
|
||||
|
||||
@api.depends('mimetype', 'url', 'name')
|
||||
def _compute_image_src(self):
|
||||
for attachment in self:
|
||||
# Only add a src for supported images
|
||||
if not attachment.mimetype or attachment.mimetype.split(';')[0] not in SUPPORTED_IMAGE_MIMETYPES:
|
||||
attachment.image_src = False
|
||||
continue
|
||||
|
||||
if attachment.type == 'url':
|
||||
if attachment.url.startswith('/'):
|
||||
# Local URL
|
||||
attachment.image_src = attachment.url
|
||||
else:
|
||||
name = quote(attachment.name)
|
||||
attachment.image_src = '/web/image/%s-redirect/%s' % (attachment.id, name)
|
||||
else:
|
||||
# Adding unique in URLs for cache-control
|
||||
unique = attachment.checksum[:8]
|
||||
if attachment.url:
|
||||
# For attachments-by-url, unique is used as a cachebuster. They
|
||||
# currently do not leverage max-age headers.
|
||||
separator = '&' if '?' in attachment.url else '?'
|
||||
attachment.image_src = '%s%sunique=%s' % (attachment.url, separator, unique)
|
||||
else:
|
||||
name = quote(attachment.name)
|
||||
attachment.image_src = '/web/image/%s-%s/%s' % (attachment.id, unique, name)
|
||||
|
||||
@api.depends('datas')
|
||||
def _compute_image_size(self):
|
||||
for attachment in self:
|
||||
try:
|
||||
image = base64_to_image(attachment.datas)
|
||||
attachment.image_width = image.width
|
||||
attachment.image_height = image.height
|
||||
except UserError:
|
||||
attachment.image_width = 0
|
||||
attachment.image_height = 0
|
||||
|
||||
def _get_media_info(self):
|
||||
"""Return a dict with the values that we need on the media dialog."""
|
||||
self.ensure_one()
|
||||
return self._read_format(['id', 'name', 'description', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height', 'original_id'])[0]
|
||||
|
||||
def _can_bypass_rights_on_media_dialog(self, **attachment_data):
|
||||
""" This method is meant to be overridden, for instance to allow to
|
||||
create image attachment despite the user not allowed to create
|
||||
attachment, eg:
|
||||
- Portal user uploading an image on the forum (bypass acl)
|
||||
- Non admin user uploading an unsplash image (bypass binary/url check)
|
||||
"""
|
||||
return False
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
from odoo import models
|
||||
from odoo.http import request
|
||||
|
||||
CONTEXT_KEYS = ['editable', 'edit_translations', 'translatable']
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
_inherit = "ir.http"
|
||||
|
||||
@classmethod
|
||||
def _get_editor_context(cls):
|
||||
""" Check for ?editable and stuff in the query-string """
|
||||
return {
|
||||
key: True
|
||||
for key in CONTEXT_KEYS
|
||||
if key in request.httprequest.args and key not in request.env.context
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _pre_dispatch(cls, rule, args):
|
||||
super()._pre_dispatch(rule, args)
|
||||
ctx = cls._get_editor_context()
|
||||
request.update_context(**ctx)
|
||||
|
||||
@classmethod
|
||||
def _get_translation_frontend_modules_name(cls):
|
||||
return ["html_editor", *super()._get_translation_frontend_modules_name()]
|
||||
|
|
@ -0,0 +1,710 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
"""
|
||||
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 Odoo models.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import babel
|
||||
import pytz
|
||||
import requests
|
||||
from lxml import etree, html
|
||||
from markupsafe import Markup, escape_silent
|
||||
from PIL import Image as I
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import _, api, models, fields
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.tools import posix_to_ldml
|
||||
from odoo.tools.json import scriptsafe as json_safe
|
||||
from odoo.tools.misc import file_open, get_lang, babel_locale_parse
|
||||
|
||||
REMOTE_CONNECTION_TIMEOUT = 2.5
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IrQweb(models.AbstractModel):
|
||||
""" IrQweb object for rendering editor stuff
|
||||
"""
|
||||
_inherit = 'ir.qweb'
|
||||
|
||||
def _compile_node(self, el, compile_context, level):
|
||||
snippet_key = compile_context.get('snippet-key')
|
||||
|
||||
template = compile_context['ref_name']
|
||||
sub_call_key = compile_context.get('snippet-sub-call-key')
|
||||
|
||||
# We only add the 'data-snippet' & 'data-name' attrib once when
|
||||
# compiling the root node of the template.
|
||||
if not template or template not in {snippet_key, sub_call_key} or el.getparent() is not None:
|
||||
return super()._compile_node(el, compile_context, level)
|
||||
|
||||
snippet_base_node = el
|
||||
if el.tag == 't':
|
||||
el_children = [child for child in list(el) if isinstance(child.tag, str) and child.tag != 't']
|
||||
if len(el_children) == 1:
|
||||
snippet_base_node = el_children[0]
|
||||
elif not el_children:
|
||||
# If there's not a valid base node we check if the base node is
|
||||
# a t-call to another template. If so the called template's base
|
||||
# node must take the current snippet key.
|
||||
el_children = [child for child in list(el) if isinstance(child.tag, str)]
|
||||
if len(el_children) == 1:
|
||||
sub_call = el_children[0].get('t-call')
|
||||
if sub_call:
|
||||
el_children[0].set('t-options', f"{{'snippet-key': '{snippet_key}', 'snippet-sub-call-key': '{sub_call}'}}")
|
||||
# If it already has a data-snippet it is a saved or an
|
||||
# inherited snippet. Do not override it.
|
||||
if 'data-snippet' not in snippet_base_node.attrib:
|
||||
snippet_base_node.attrib['data-snippet'] = \
|
||||
snippet_key.split('.', 1)[-1]
|
||||
# If it already has a data-name it is a saved or an
|
||||
# inherited snippet. Do not override it.
|
||||
snippet_name = compile_context.get('snippet-name')
|
||||
if snippet_name and 'data-name' not in snippet_base_node.attrib:
|
||||
snippet_base_node.attrib['data-name'] = snippet_name
|
||||
return super()._compile_node(el, compile_context, level)
|
||||
|
||||
def _get_preload_attribute_xmlids(self):
|
||||
return super()._get_preload_attribute_xmlids() + ['t-snippet', 't-snippet-call']
|
||||
|
||||
# compile directives
|
||||
|
||||
def _compile_directive_snippet(self, el, compile_context, indent):
|
||||
key = el.attrib.pop('t-snippet')
|
||||
el.set('t-call', key)
|
||||
snippet_lang = self.env.context.get('snippet_lang')
|
||||
if snippet_lang:
|
||||
el.set('t-lang', f"'{snippet_lang}'")
|
||||
|
||||
el.set('t-options', f"{{'snippet-key': {key!r}}}")
|
||||
view = self.env['ir.ui.view']._get_template_view(key)
|
||||
name = el.attrib.pop('string', view.name)
|
||||
thumbnail = el.attrib.pop('t-thumbnail', "oe-thumbnail")
|
||||
image_preview = el.attrib.pop('t-image-preview', None)
|
||||
# Forbid sanitize contains the specific reason:
|
||||
# - "true": always forbid
|
||||
# - "form": forbid if forms are sanitized
|
||||
forbid_sanitize = el.attrib.pop('t-forbid-sanitize', None)
|
||||
grid_column_span = el.attrib.pop('t-grid-column-span', None)
|
||||
snippet_group = el.attrib.pop('snippet-group', None)
|
||||
group = el.attrib.pop('group', None)
|
||||
label = el.attrib.pop('label', None)
|
||||
div = Markup('<div name="%s" data-oe-type="snippet" data-o-image-preview="%s" data-oe-thumbnail="%s" data-oe-snippet-id="%s" data-oe-snippet-key="%s" data-oe-keywords="%s" %s %s %s %s %s>') % (
|
||||
name,
|
||||
escape_silent(image_preview),
|
||||
thumbnail,
|
||||
view.id,
|
||||
key.split('.')[-1],
|
||||
escape_silent(el.findtext('keywords')),
|
||||
Markup('data-oe-forbid-sanitize="%s"') % forbid_sanitize if forbid_sanitize else '',
|
||||
Markup('data-o-grid-column-span="%s"') % grid_column_span if grid_column_span else '',
|
||||
Markup('data-o-snippet-group="%s"') % snippet_group if snippet_group else '',
|
||||
Markup('data-o-group="%s"') % group if group else '',
|
||||
Markup('data-o-label="%s"') % label if label else '',
|
||||
)
|
||||
self._append_text(div, compile_context)
|
||||
code = self._compile_node(el, compile_context, indent)
|
||||
self._append_text('</div>', compile_context)
|
||||
return code
|
||||
|
||||
def _compile_directive_snippet_call(self, el, compile_context, indent):
|
||||
key = el.attrib.pop('t-snippet-call')
|
||||
snippet_name = el.attrib.pop('string', None)
|
||||
el.set('t-call', key)
|
||||
el.set('t-options', f"{{'snippet-key': {key!r}, 'snippet-name': {snippet_name!r}}}")
|
||||
return self._compile_node(el, compile_context, indent)
|
||||
|
||||
def _compile_directive_install(self, el, compile_context, indent):
|
||||
key = el.attrib.pop('t-install')
|
||||
thumbnail = el.attrib.pop('t-thumbnail', 'oe-thumbnail')
|
||||
image_preview = el.attrib.pop('t-image-preview', None)
|
||||
group = el.attrib.pop('group', None)
|
||||
label = el.attrib.pop('label', None)
|
||||
if self.env.user.has_group('base.group_system'):
|
||||
module = self.env['ir.module.module'].search([('name', '=', key)])
|
||||
if not module or module.state == 'installed':
|
||||
return []
|
||||
name = el.attrib.get('string') or 'Snippet'
|
||||
div = Markup('<div name="%s" data-oe-type="snippet" data-module-id="%s" data-module-display-name="%s" data-o-image-preview="%s" data-oe-thumbnail="%s" %s %s><section/></div>') % (
|
||||
name,
|
||||
module.id,
|
||||
module.display_name,
|
||||
escape_silent(image_preview),
|
||||
thumbnail,
|
||||
Markup('data-o-group="%s"') % group if group else '',
|
||||
Markup('data-o-label="%s"') % label if label else '',
|
||||
)
|
||||
self._append_text(div, compile_context)
|
||||
return []
|
||||
|
||||
def _compile_directive_placeholder(self, el, compile_context, indent):
|
||||
el.set('t-att-placeholder', el.attrib.pop('t-placeholder'))
|
||||
return []
|
||||
|
||||
# order and ignore
|
||||
|
||||
def _directives_eval_order(self):
|
||||
directives = super()._directives_eval_order()
|
||||
# Insert before "att" as those may rely on static attributes like
|
||||
# "string" and "att" clears all of those
|
||||
index = directives.index('att') - 1
|
||||
directives.insert(index, 'placeholder')
|
||||
directives.insert(index, 'snippet')
|
||||
directives.insert(index, 'snippet-call')
|
||||
directives.insert(index, 'install')
|
||||
return directives
|
||||
|
||||
def _get_template_cache_keys(self):
|
||||
return super()._get_template_cache_keys() + ['snippet_lang']
|
||||
|
||||
|
||||
# ------------------------------------------------------
|
||||
# QWeb fields
|
||||
# ------------------------------------------------------
|
||||
|
||||
|
||||
class IrQwebField(models.AbstractModel):
|
||||
_name = 'ir.qweb.field'
|
||||
_description = 'Qweb Field'
|
||||
_inherit = ['ir.qweb.field']
|
||||
|
||||
@api.model
|
||||
def attributes(self, record, field_name, options, values=None):
|
||||
attrs = super().attributes(record, field_name, options, values)
|
||||
field = record._fields[field_name]
|
||||
|
||||
placeholder = options.get('placeholder') or getattr(field, 'placeholder', None)
|
||||
if placeholder:
|
||||
attrs['placeholder'] = placeholder
|
||||
|
||||
if options['translate'] and field.type in ('char', 'text'):
|
||||
lang = record.env.lang or 'en_US'
|
||||
base_lang = record._get_base_lang()
|
||||
if lang == base_lang:
|
||||
attrs['data-oe-translation-state'] = 'translated'
|
||||
else:
|
||||
base_value = record.with_context(lang=base_lang)[field_name]
|
||||
value = record[field_name]
|
||||
attrs['data-oe-translation-state'] = 'translated' if base_value != value else 'to_translate'
|
||||
|
||||
return attrs
|
||||
|
||||
def value_from_string(self, value):
|
||||
return value
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
return self.value_from_string(element.text_content().strip()) or False
|
||||
|
||||
|
||||
class IrQwebFieldInteger(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.integer'
|
||||
_description = 'Qweb Field Integer'
|
||||
_inherit = ['ir.qweb.field.integer']
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
lang = self.user_lang()
|
||||
value = element.text_content().strip()
|
||||
return int(value.replace(lang.thousands_sep or '', ''))
|
||||
|
||||
|
||||
class IrQwebFieldFloat(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.float'
|
||||
_description = 'Qweb Field Float'
|
||||
_inherit = ['ir.qweb.field.float']
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
lang = self.user_lang()
|
||||
value = element.text_content().strip()
|
||||
return float(value.replace(lang.thousands_sep or '', '')
|
||||
.replace(lang.decimal_point, '.'))
|
||||
|
||||
|
||||
class IrQwebFieldMany2one(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.many2one'
|
||||
_description = 'Qweb Field Many to One'
|
||||
_inherit = ['ir.qweb.field.many2one']
|
||||
|
||||
@api.model
|
||||
def attributes(self, record, field_name, options, values=None):
|
||||
field = record._fields[field_name]
|
||||
attrs = super().attributes(record, field_name, options, values)
|
||||
if options.get('inherit_branding'):
|
||||
many2one = record[field_name]
|
||||
if many2one:
|
||||
attrs['data-oe-many2one-id'] = many2one.id
|
||||
attrs['data-oe-many2one-model'] = many2one._name
|
||||
if options.get('null_text'):
|
||||
attrs['data-oe-many2one-allowreset'] = 1
|
||||
if not many2one:
|
||||
attrs['data-oe-many2one-model'] = record._fields[field_name].comodel_name
|
||||
attrs['data-oe-many2one-domain'] = json_safe.dumps(field._description_domain(self.env))
|
||||
return attrs
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
Model = self.env[element.get('data-oe-model')]
|
||||
id = int(element.get('data-oe-id'))
|
||||
M2O = self.env[field.comodel_name]
|
||||
field_name = element.get('data-oe-field')
|
||||
many2one_id = int(element.get('data-oe-many2one-id'))
|
||||
|
||||
allow_reset = element.get('data-oe-many2one-allowreset')
|
||||
if allow_reset and not many2one_id:
|
||||
# Reset the id of the many2one
|
||||
Model.browse(id).write({field_name: False})
|
||||
return None
|
||||
|
||||
record = many2one_id and M2O.browse(many2one_id)
|
||||
if record and record.exists():
|
||||
# save the new id of the many2one
|
||||
Model.browse(id).write({field_name: many2one_id})
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class IrQwebFieldContact(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.contact'
|
||||
_description = 'Qweb Field Contact'
|
||||
_inherit = ['ir.qweb.field.contact']
|
||||
|
||||
@api.model
|
||||
def attributes(self, record, field_name, options, values=None):
|
||||
attrs = super().attributes(record, field_name, options, values)
|
||||
if options.get('inherit_branding'):
|
||||
attrs['data-oe-contact-options'] = json.dumps(options)
|
||||
return attrs
|
||||
|
||||
@api.model
|
||||
def get_record_to_html(self, contact_ids, options=None):
|
||||
""" Helper to call the rendering of contact field. """
|
||||
return self.value_to_html(self.env['res.partner'].search([('id', '=', contact_ids[0])]), options=options)
|
||||
|
||||
|
||||
class IrQwebFieldDate(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.date'
|
||||
_description = 'Qweb Field Date'
|
||||
_inherit = ['ir.qweb.field.date']
|
||||
|
||||
@api.model
|
||||
def attributes(self, record, field_name, options, values=None):
|
||||
attrs = super().attributes(record, field_name, options, values)
|
||||
if options.get('inherit_branding'):
|
||||
attrs['data-oe-original'] = record[field_name]
|
||||
|
||||
if record._fields[field_name].type == 'datetime':
|
||||
attrs = self.env['ir.qweb.field.datetime'].attributes(record, field_name, options, values)
|
||||
attrs['data-oe-type'] = 'datetime'
|
||||
return attrs
|
||||
|
||||
lg = get_lang(self.env, self.env.user.lang)
|
||||
locale = babel_locale_parse(lg.code)
|
||||
babel_format = value_format = posix_to_ldml(lg.date_format, locale=locale)
|
||||
|
||||
if record[field_name]:
|
||||
date = fields.Date.from_string(record[field_name])
|
||||
value_format = babel.dates.format_date(date, format=babel_format, locale=locale)
|
||||
|
||||
attrs['data-oe-original-with-format'] = value_format
|
||||
return attrs
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
value = element.text_content().strip()
|
||||
if not value:
|
||||
return False
|
||||
|
||||
lg = get_lang(self.env, self.env.user.lang)
|
||||
date = datetime.strptime(value, lg.date_format)
|
||||
return fields.Date.to_string(date)
|
||||
|
||||
|
||||
class IrQwebFieldDatetime(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.datetime'
|
||||
_description = 'Qweb Field Datetime'
|
||||
_inherit = ['ir.qweb.field.datetime']
|
||||
|
||||
@api.model
|
||||
def attributes(self, record, field_name, options, values=None):
|
||||
attrs = super().attributes(record, field_name, options, values)
|
||||
|
||||
if options.get('inherit_branding'):
|
||||
value = record[field_name]
|
||||
|
||||
lg = get_lang(self.env, self.env.user.lang)
|
||||
locale = babel_locale_parse(lg.code)
|
||||
babel_format = value_format = posix_to_ldml('%s %s' % (lg.date_format, lg.time_format), locale=locale)
|
||||
tz = record.env.context.get('tz') or self.env.user.tz
|
||||
|
||||
if isinstance(value, str):
|
||||
value = fields.Datetime.from_string(value)
|
||||
|
||||
if value:
|
||||
# convert from UTC (server timezone) to user timezone
|
||||
value = fields.Datetime.context_timestamp(self.with_context(tz=tz), timestamp=value)
|
||||
value_format = babel.dates.format_datetime(value, format=babel_format, locale=locale)
|
||||
value = fields.Datetime.to_string(value)
|
||||
|
||||
attrs['data-oe-original'] = value
|
||||
attrs['data-oe-original-with-format'] = value_format
|
||||
attrs['data-oe-original-tz'] = tz
|
||||
return attrs
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
value = element.text_content().strip()
|
||||
if not value:
|
||||
return False
|
||||
|
||||
# parse from string to datetime
|
||||
lg = get_lang(self.env, self.env.user.lang)
|
||||
try:
|
||||
datetime_format = f'{lg.date_format} {lg.time_format}'
|
||||
dt = datetime.strptime(value, datetime_format)
|
||||
except ValueError:
|
||||
raise ValidationError(_("The datetime %(value)s does not match the format %(format)s", value=value, format=datetime_format))
|
||||
|
||||
# convert back from user's timezone to UTC
|
||||
tz_name = element.attrib.get('data-oe-original-tz') or self.env.context.get('tz') or self.env.user.tz
|
||||
if tz_name:
|
||||
try:
|
||||
user_tz = pytz.timezone(tz_name)
|
||||
utc = pytz.utc
|
||||
|
||||
dt = user_tz.localize(dt).astimezone(utc)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning(
|
||||
"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 fields.Datetime.to_string(dt)
|
||||
|
||||
|
||||
class IrQwebFieldText(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.text'
|
||||
_description = 'Qweb Field Text'
|
||||
_inherit = ['ir.qweb.field.text']
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
return html_to_text(element)
|
||||
|
||||
|
||||
class IrQwebFieldSelection(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.selection'
|
||||
_description = 'Qweb Field Selection'
|
||||
_inherit = ['ir.qweb.field.selection']
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
value = element.text_content().strip()
|
||||
selection = field.get_description(self.env)['selection']
|
||||
for k, v in selection:
|
||||
if value == v:
|
||||
return k
|
||||
|
||||
raise ValueError("No value found for label %s in selection %s" % (
|
||||
value, selection))
|
||||
|
||||
|
||||
class IrQwebFieldHtml(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.html'
|
||||
_description = 'Qweb Field HTML'
|
||||
_inherit = ['ir.qweb.field.html']
|
||||
|
||||
@api.model
|
||||
def attributes(self, record, field_name, options, values=None):
|
||||
attrs = super().attributes(record, field_name, options, values)
|
||||
if options.get('inherit_branding'):
|
||||
field = record._fields[field_name]
|
||||
if field.sanitize:
|
||||
if field.sanitize_overridable:
|
||||
if record.env.user.has_group('base.group_sanitize_override'):
|
||||
# Don't mark the field as 'sanitize' if the sanitize
|
||||
# is defined as overridable and the user has the right
|
||||
# to do so
|
||||
return attrs
|
||||
else:
|
||||
try:
|
||||
field.convert_to_column_insert(record[field_name], record)
|
||||
except UserError:
|
||||
# The field contains element(s) that would be
|
||||
# removed if sanitized. It means that someone who
|
||||
# was part of a group allowing to bypass the
|
||||
# sanitation saved that field previously. Mark the
|
||||
# field as not editable.
|
||||
attrs['data-oe-sanitize-prevent-edition'] = 1
|
||||
return attrs
|
||||
# The field edition is not fully prevented and the sanitation cannot be bypassed
|
||||
attrs['data-oe-sanitize'] = 'no_block' if field.sanitize_attributes else 1 if field.sanitize_form else 'allow_form'
|
||||
|
||||
return attrs
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
content = []
|
||||
if element.text:
|
||||
content.append(element.text)
|
||||
content.extend(html.tostring(child, encoding='unicode')
|
||||
for child in element.iterchildren(tag=etree.Element))
|
||||
return '\n'.join(content)
|
||||
|
||||
|
||||
class IrQwebFieldImage(models.AbstractModel):
|
||||
"""
|
||||
Widget options:
|
||||
|
||||
``class``
|
||||
set as attribute on the generated <img> tag
|
||||
"""
|
||||
_name = 'ir.qweb.field.image'
|
||||
_description = 'Qweb Field Image'
|
||||
_inherit = ['ir.qweb.field.image']
|
||||
|
||||
local_url_re = re.compile(r'^/(?P<module>[^]]+)/static/(?P<rest>.+)$')
|
||||
redirect_url_re = re.compile(r'\/web\/image\/\d+-redirect\/')
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
if element.find('img') is None:
|
||||
return False
|
||||
url = element.find('img').get('src')
|
||||
|
||||
url_object = urls.url_parse(url)
|
||||
if url_object.path.startswith('/web/image'):
|
||||
fragments = url_object.path.split('/')
|
||||
query = url_object.decode_query()
|
||||
url_id = fragments[3].split('-')[0]
|
||||
# ir.attachment image urls: /web/image/<id>[-<checksum>][/...]
|
||||
if url_id.isdigit():
|
||||
model = 'ir.attachment'
|
||||
oid = url_id
|
||||
field = 'datas'
|
||||
# url of binary field on model: /web/image/<model>/<id>/<field>[/...]
|
||||
else:
|
||||
model = query.get('model', fragments[3])
|
||||
oid = query.get('id', fragments[4])
|
||||
field = query.get('field', fragments[5])
|
||||
item = self.env[model].browse(int(oid))
|
||||
if self.redirect_url_re.match(url_object.path):
|
||||
return self.load_remote_url(item.url)
|
||||
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(urls.url_parse(url).path)
|
||||
rest = match.group('rest')
|
||||
|
||||
path = os.path.join(
|
||||
match.group('module'), 'static', rest)
|
||||
|
||||
try:
|
||||
with file_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 base64.b64encode(f.read())
|
||||
except Exception: # noqa: BLE001
|
||||
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 = requests.get(url, timeout=REMOTE_CONNECTION_TIMEOUT)
|
||||
# PIL needs a seekable file-like image so wrap result in IO buffer
|
||||
image = I.open(io.BytesIO(req.content))
|
||||
# force a complete load of the image data to validate it
|
||||
image.load()
|
||||
# We're catching all exceptions because Pillow's exceptions are
|
||||
# directly inheriting from Exception.
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Failed to load remote image %r", url, exc_info=True)
|
||||
return None
|
||||
|
||||
# don't use original data in case weird stuff was smuggled in, with
|
||||
# luck PIL will remove some of it?
|
||||
out = io.BytesIO()
|
||||
image.save(out, image.format)
|
||||
return base64.b64encode(out.getvalue())
|
||||
|
||||
|
||||
class IrQwebFieldMonetary(models.AbstractModel):
|
||||
_inherit = 'ir.qweb.field.monetary'
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
lang = self.user_lang()
|
||||
|
||||
value = element.find('span').text_content().strip()
|
||||
|
||||
return float(value.replace(lang.thousands_sep or '', '')
|
||||
.replace(lang.decimal_point, '.'))
|
||||
|
||||
|
||||
class IrQwebFieldDuration(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.duration'
|
||||
_description = 'Qweb Field Duration'
|
||||
_inherit = ['ir.qweb.field.duration']
|
||||
|
||||
@api.model
|
||||
def attributes(self, record, field_name, options, values=None):
|
||||
attrs = super().attributes(record, field_name, options, values)
|
||||
if options.get('inherit_branding'):
|
||||
attrs['data-oe-original'] = record[field_name]
|
||||
return attrs
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
value = element.text_content().strip()
|
||||
|
||||
# non-localized value
|
||||
return float(value)
|
||||
|
||||
|
||||
class IrQwebFieldRelative(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.relative'
|
||||
_description = 'Qweb Field Relative'
|
||||
_inherit = ['ir.qweb.field.relative']
|
||||
|
||||
# get formatting from ir.qweb.field.relative but edition/save from datetime
|
||||
|
||||
|
||||
class IrQwebFieldQweb(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.qweb'
|
||||
_description = '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 IrQwebFieldHtml 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 = {"p", "h1", "h2", "h3", "h4", "h5", "h6"}
|
||||
# https://developer.mozilla.org/en-US/docs/HTML/Block-level_elements minus p
|
||||
_MISC_BLOCK = {"address", "article", "aside", "audio", "blockquote", "canvas",
|
||||
"dd", "dl", "div", "figcaption", "figure", "footer", "form",
|
||||
"header", "hgroup", "hr", "ol", "output", "pre", "section", "tfoot",
|
||||
"ul", "video"}
|
||||
|
||||
|
||||
def _collapse_whitespace(text):
|
||||
""" Collapses sequences of whitespace characters in ``text`` to a single
|
||||
space
|
||||
"""
|
||||
return re.sub(r'\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 = 0
|
||||
for item in it:
|
||||
if isinstance(item, int):
|
||||
padding = max(padding, item)
|
||||
continue
|
||||
|
||||
if padding:
|
||||
yield '\n' * padding
|
||||
padding = 0
|
||||
|
||||
yield item
|
||||
# leftover padding irrelevant as the output will be stripped
|
||||
|
||||
|
||||
def _wrap(element, output, wrapper=''):
|
||||
""" 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('\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))
|
||||
|
|
@ -0,0 +1,516 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import uuid
|
||||
from lxml import etree, html
|
||||
|
||||
from odoo import api, models, _
|
||||
from odoo.exceptions import ValidationError, MissingError
|
||||
from odoo.fields import Domain
|
||||
from odoo.addons.base.models.ir_ui_view import MOVABLE_BRANDING
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
EDITING_ATTRIBUTES = MOVABLE_BRANDING + [
|
||||
'data-oe-type',
|
||||
'data-oe-expression',
|
||||
'data-oe-translation-id',
|
||||
'data-note-id'
|
||||
]
|
||||
|
||||
|
||||
class IrUiView(models.Model):
|
||||
_inherit = 'ir.ui.view'
|
||||
|
||||
def _get_cleaned_non_editing_attributes(self, attributes):
|
||||
"""
|
||||
Returns a new mapping of attributes -> value without the parts that are
|
||||
not meant to be saved (branding, editing classes, ...). Note that
|
||||
classes are meant to be cleaned on the client side before saving as
|
||||
mostly linked to the related options (so we are not supposed to know
|
||||
which to remove here).
|
||||
|
||||
:param attributes: a mapping of attributes -> value
|
||||
:return: a new mapping of attributes -> value
|
||||
"""
|
||||
attributes = {k: v for k, v in attributes if k not in EDITING_ATTRIBUTES}
|
||||
if 'class' in attributes:
|
||||
classes = attributes['class'].split()
|
||||
attributes['class'] = ' '.join([c for c in classes if c != 'o_editable'])
|
||||
if attributes.get('contenteditable') == 'true':
|
||||
del attributes['contenteditable']
|
||||
return attributes
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Save from html
|
||||
# ------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def extract_embedded_fields(self, arch):
|
||||
return arch.xpath('//*[@data-oe-model != "ir.ui.view"]')
|
||||
|
||||
@api.model
|
||||
def extract_oe_structures(self, arch):
|
||||
return arch.xpath('//*[hasclass("oe_structure")][contains(@id, "oe_structure")]')
|
||||
|
||||
@api.model
|
||||
def get_default_lang_code(self):
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def save_embedded_field(self, el):
|
||||
Model = self.env[el.get('data-oe-model')]
|
||||
field = el.get('data-oe-field')
|
||||
|
||||
model = 'ir.qweb.field.' + el.get('data-oe-type')
|
||||
converter = self.env[model] if model in self.env else self.env['ir.qweb.field']
|
||||
|
||||
try:
|
||||
value = converter.from_html(Model, Model._fields[field], el)
|
||||
if value is not None:
|
||||
# TODO: batch writes?
|
||||
record = Model.browse(int(el.get('data-oe-id')))
|
||||
if not self.env.context.get('lang') and self.get_default_lang_code():
|
||||
record.with_context(lang=self.get_default_lang_code()).write({field: value})
|
||||
else:
|
||||
record.write({field: value})
|
||||
|
||||
if callable(Model._fields[field].translate):
|
||||
self._copy_custom_snippet_translations(record, field)
|
||||
|
||||
except (ValueError, TypeError):
|
||||
raise ValidationError(_(
|
||||
"Invalid field value for %(field_name)s: %(value)s",
|
||||
field_name=Model._fields[field].string,
|
||||
value=el.text_content().strip(),
|
||||
))
|
||||
|
||||
def save_oe_structure(self, el):
|
||||
self.ensure_one()
|
||||
|
||||
if el.get('id') in self.key:
|
||||
# Do not inherit if the oe_structure already has its own inheriting view
|
||||
return False
|
||||
|
||||
arch = etree.Element('data')
|
||||
xpath = etree.Element('xpath', expr="//*[hasclass('oe_structure')][@id='{}']".format(el.get('id')), position="replace")
|
||||
arch.append(xpath)
|
||||
attributes = self._get_cleaned_non_editing_attributes(el.attrib.items())
|
||||
structure = etree.Element(el.tag, attrib=attributes)
|
||||
structure.text = el.text
|
||||
xpath.append(structure)
|
||||
for child in el.iterchildren(tag=etree.Element):
|
||||
structure.append(copy.deepcopy(child))
|
||||
|
||||
vals = {
|
||||
'inherit_id': self.id,
|
||||
'name': '%s (%s)' % (self.name, el.get('id')),
|
||||
'arch': etree.tostring(arch, encoding='unicode'),
|
||||
'key': '%s_%s' % (self.key, el.get('id')),
|
||||
'type': 'qweb',
|
||||
'mode': 'extension',
|
||||
}
|
||||
vals.update(self._save_oe_structure_hook())
|
||||
oe_structure_view = self.env['ir.ui.view'].create(vals)
|
||||
self._copy_custom_snippet_translations(oe_structure_view, 'arch_db')
|
||||
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _copy_custom_snippet_translations(self, record, html_field):
|
||||
""" Given a ``record`` and its HTML ``field``, detect any
|
||||
usage of a custom snippet and copy its translations.
|
||||
"""
|
||||
lang_value = record[html_field]
|
||||
if not lang_value:
|
||||
return
|
||||
|
||||
try:
|
||||
tree = html.fromstring(lang_value)
|
||||
except etree.ParserError as e:
|
||||
raise ValidationError(str(e))
|
||||
|
||||
for custom_snippet_el in tree.xpath('//*[hasclass("s_custom_snippet")]'):
|
||||
custom_snippet_name = custom_snippet_el.get('data-name')
|
||||
custom_snippet_view = self.search([('name', '=', custom_snippet_name)], limit=1)
|
||||
if custom_snippet_view:
|
||||
self._copy_field_terms_translations(custom_snippet_view, 'arch_db', record, html_field)
|
||||
|
||||
@api.model
|
||||
def _copy_field_terms_translations(self, records_from, name_field_from, record_to, name_field_to):
|
||||
""" Copy model terms translations from ``records_from.name_field_from``
|
||||
to ``record_to.name_field_to`` for all activated languages if the term
|
||||
in ``record_to.name_field_to`` is untranslated (the term matches the
|
||||
one in the current language).
|
||||
|
||||
For instance, copy the translations of a
|
||||
``product.template.html_description`` field to a ``ir.ui.view.arch_db``
|
||||
field.
|
||||
|
||||
The method takes care of read and write access of both records/fields.
|
||||
"""
|
||||
record_to.check_access('write')
|
||||
field_from = records_from._fields[name_field_from]
|
||||
field_to = record_to._fields[name_field_to]
|
||||
record_to._check_field_access(field_to, 'write')
|
||||
|
||||
error_callable_msg = "'translate' property of field %r is not callable"
|
||||
if not callable(field_from.translate):
|
||||
raise TypeError(error_callable_msg % field_from)
|
||||
if not callable(field_to.translate):
|
||||
raise TypeError(error_callable_msg % field_to)
|
||||
if not field_to.store:
|
||||
raise ValueError("Field %r is not stored" % field_to)
|
||||
|
||||
# This will also implicitly check for `read` access rights
|
||||
if not record_to[name_field_to] or not any(records_from.mapped(name_field_from)):
|
||||
return
|
||||
|
||||
lang_env = self.env.lang or 'en_US'
|
||||
langs = {lang for lang, _ in self.env['res.lang'].get_installed()}
|
||||
|
||||
# 1. Get translations
|
||||
records_from.flush_model([name_field_from])
|
||||
existing_translation_dictionary = field_to.get_translation_dictionary(
|
||||
record_to[name_field_to],
|
||||
{lang: record_to.with_context(prefetch_langs=True, lang=lang)[name_field_to] for lang in langs if lang != lang_env}
|
||||
)
|
||||
extra_translation_dictionary = {}
|
||||
for record_from in records_from:
|
||||
extra_translation_dictionary.update(field_from.get_translation_dictionary(
|
||||
record_from[name_field_from],
|
||||
{lang: record_from.with_context(prefetch_langs=True, lang=lang)[name_field_from] for lang in langs if lang != lang_env}
|
||||
))
|
||||
for term, extra_translation_values in extra_translation_dictionary.items():
|
||||
existing_translation_values = existing_translation_dictionary.setdefault(term, {})
|
||||
# Update only default translation values that aren't customized by the user.
|
||||
for lang, extra_translation in extra_translation_values.items():
|
||||
if existing_translation_values.get(lang, term) == term:
|
||||
existing_translation_values[lang] = extra_translation
|
||||
translation_dictionary = existing_translation_dictionary
|
||||
|
||||
# The `en_US` jsonb value should always be set, even if english is not
|
||||
# installed. If we don't do this, the custom snippet `arch_db` will only
|
||||
# have a `fr_BE` key but no `en_US` key.
|
||||
langs.add('en_US')
|
||||
|
||||
# 2. Set translations
|
||||
new_value = {
|
||||
lang: field_to.translate(lambda term: translation_dictionary.get(term, {}).get(lang), record_to[name_field_to])
|
||||
for lang in langs
|
||||
}
|
||||
record_to.env.cache.update_raw(record_to, field_to, [new_value], dirty=True)
|
||||
# Call `write` to trigger compute etc (`modified()`)
|
||||
record_to[name_field_to] = new_value[lang_env]
|
||||
|
||||
@api.model
|
||||
def _save_oe_structure_hook(self):
|
||||
return {}
|
||||
|
||||
@api.model
|
||||
def _are_archs_equal(self, arch1, arch2):
|
||||
# Note that comparing the strings would not be ok as attributes order
|
||||
# must not be relevant
|
||||
if arch1.tag != arch2.tag:
|
||||
return False
|
||||
if arch1.text != arch2.text:
|
||||
return False
|
||||
if arch1.tail != arch2.tail:
|
||||
return False
|
||||
if arch1.attrib != arch2.attrib:
|
||||
return False
|
||||
if len(arch1) != len(arch2):
|
||||
return False
|
||||
return all(self._are_archs_equal(arch1, arch2) for arch1, arch2 in zip(arch1, arch2))
|
||||
|
||||
@api.model
|
||||
def _get_allowed_root_attrs(self):
|
||||
return ['style', 'class', 'target', 'href']
|
||||
|
||||
def replace_arch_section(self, section_xpath, replacement, replace_tail=False):
|
||||
# the root of the arch section shouldn't actually be replaced as it's
|
||||
# not really editable itself, only the content truly is editable.
|
||||
self.ensure_one()
|
||||
arch = etree.fromstring(self.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
|
||||
|
||||
# We need to replace some attrib for styles changes on the root element
|
||||
for attribute in self._get_allowed_root_attrs():
|
||||
if attribute in replacement.attrib:
|
||||
root.attrib[attribute] = replacement.attrib[attribute]
|
||||
elif attribute in root.attrib:
|
||||
del root.attrib[attribute]
|
||||
|
||||
# Note: after a standard edition, the tail *must not* be replaced
|
||||
if replace_tail:
|
||||
root.tail = replacement.tail
|
||||
# replace all children
|
||||
del root[:]
|
||||
for child in replacement:
|
||||
root.append(copy.deepcopy(child))
|
||||
|
||||
return arch
|
||||
|
||||
@api.model
|
||||
def to_field_ref(self, el):
|
||||
# filter out meta-information inserted in the document
|
||||
attributes = {k: v for k, v in el.attrib.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
|
||||
|
||||
@api.model
|
||||
def to_empty_oe_structure(self, el):
|
||||
out = html.html_parser.makeelement(el.tag, attrib=el.attrib)
|
||||
out.tail = el.tail
|
||||
return out
|
||||
|
||||
@api.model
|
||||
def _set_noupdate(self):
|
||||
self.sudo().mapped('model_data_id').write({'noupdate': True})
|
||||
|
||||
def save(self, value, xpath=None):
|
||||
""" Update a view section. The view section may embed fields to write
|
||||
|
||||
Note that `self` record might not exist when saving an embed field
|
||||
|
||||
:param str xpath: valid xpath to the tag to replace
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
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(arch_section)
|
||||
return
|
||||
|
||||
for el in self.extract_embedded_fields(arch_section):
|
||||
self.save_embedded_field(el)
|
||||
|
||||
# transform embedded field back to t-field
|
||||
el.getparent().replace(el, self.to_field_ref(el))
|
||||
|
||||
for el in self.extract_oe_structures(arch_section):
|
||||
if self.save_oe_structure(el):
|
||||
# empty oe_structure in parent view
|
||||
empty = self.to_empty_oe_structure(el)
|
||||
if el == arch_section:
|
||||
arch_section = empty
|
||||
else:
|
||||
el.getparent().replace(el, empty)
|
||||
|
||||
new_arch = self.replace_arch_section(xpath, arch_section)
|
||||
old_arch = etree.fromstring(self.arch.encode('utf-8'))
|
||||
if not self._are_archs_equal(old_arch, new_arch):
|
||||
self._set_noupdate()
|
||||
self.write({'arch': etree.tostring(new_arch, encoding='unicode')})
|
||||
self._copy_custom_snippet_translations(self, 'arch_db')
|
||||
|
||||
@api.model
|
||||
def _view_get_inherited_children(self, view):
|
||||
if self.env.context.get('no_primary_children', False):
|
||||
original_hierarchy = self.env.context.get('__views_get_original_hierarchy', [])
|
||||
return view.inherit_children_ids.filtered(lambda extension: extension.mode != 'primary' or extension.id in original_hierarchy)
|
||||
return view.inherit_children_ids
|
||||
|
||||
# Returns all views (called and inherited) related to a view
|
||||
# Used by translation mechanism, SEO and optional templates
|
||||
|
||||
@api.model
|
||||
def _views_get(self, view_id, get_children=True, bundles=False, root=True, visited=None):
|
||||
""" For a given view ``view_id``, should return:
|
||||
* the view itself (starting from its top most parent)
|
||||
* all views inheriting from it, enabled or not
|
||||
- but not the optional children of a non-enabled child
|
||||
* all views called from it (via t-call)
|
||||
|
||||
:returns: recordset of ir.ui.view
|
||||
"""
|
||||
try:
|
||||
if isinstance(view_id, models.BaseModel):
|
||||
view = view_id
|
||||
else:
|
||||
view = self._get_template_view(view_id)
|
||||
except MissingError:
|
||||
_logger.warning("Could not find view object with view_id '%s'", view_id)
|
||||
return self.env['ir.ui.view']
|
||||
|
||||
if visited is None:
|
||||
visited = []
|
||||
original_hierarchy = self.env.context.get('__views_get_original_hierarchy', [])
|
||||
while root and view.inherit_id:
|
||||
original_hierarchy.append(view.id)
|
||||
view = view.inherit_id
|
||||
|
||||
views_to_return = view
|
||||
|
||||
node = etree.fromstring(view.arch)
|
||||
xpath = "//t[@t-call]"
|
||||
if bundles:
|
||||
xpath += "| //t[@t-call-assets]"
|
||||
for child in node.xpath(xpath):
|
||||
try:
|
||||
called_view = self._get_template_view(child.get('t-call', child.get('t-call-assets')))
|
||||
except MissingError:
|
||||
continue
|
||||
if called_view and called_view not in views_to_return and called_view.id not in visited:
|
||||
views_to_return += self._views_get(called_view, get_children=get_children, bundles=bundles, visited=visited + views_to_return.ids)
|
||||
|
||||
if not get_children:
|
||||
return views_to_return
|
||||
|
||||
extensions = self._view_get_inherited_children(view)
|
||||
|
||||
# Keep children in a deterministic order regardless of their applicability
|
||||
for extension in extensions.sorted(key=lambda v: v.id):
|
||||
# only return optional grandchildren if this child is enabled
|
||||
if extension.id not in visited:
|
||||
for ext_view in self._views_get(extension, get_children=extension.active, root=False, visited=visited + views_to_return.ids):
|
||||
if ext_view not in views_to_return:
|
||||
views_to_return += ext_view
|
||||
return views_to_return
|
||||
|
||||
@api.model
|
||||
def get_related_views(self, key, bundles=False):
|
||||
""" Get inherit view's informations of the template ``key``.
|
||||
returns templates info (which can be active or not)
|
||||
``bundles=True`` returns also the asset bundles
|
||||
"""
|
||||
user_groups = set(self.env.user.group_ids)
|
||||
new_context = {
|
||||
**self.env.context,
|
||||
'active_test': False,
|
||||
}
|
||||
new_context.pop('lang', None)
|
||||
View = self.with_context(new_context)
|
||||
views = View._views_get(key, bundles=bundles)
|
||||
return views.filtered(lambda v: not v.group_ids or len(user_groups.intersection(v.group_ids)))
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Snippet saving
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _get_snippet_addition_view_key(self, template_key, key):
|
||||
return '%s.%s' % (template_key, key)
|
||||
|
||||
@api.model
|
||||
def _snippet_save_view_values_hook(self):
|
||||
return {}
|
||||
|
||||
def _find_available_name(self, name, used_names):
|
||||
attempt = 1
|
||||
candidate_name = name
|
||||
while candidate_name in used_names:
|
||||
attempt += 1
|
||||
candidate_name = f"{name} ({attempt})"
|
||||
return candidate_name
|
||||
|
||||
@api.model
|
||||
def save_snippet(self, name, arch, template_key, snippet_key, thumbnail_url):
|
||||
"""
|
||||
Saves a new snippet arch so that it appears with the given name when
|
||||
using the given snippets template.
|
||||
|
||||
:param name: the name of the snippet to save
|
||||
:param arch: the html structure of the snippet to save
|
||||
:param template_key: the key of the view regrouping all snippets in
|
||||
which the snippet to save is meant to appear
|
||||
:param snippet_key: the key (without module part) to identify
|
||||
the snippet from which the snippet to save originates
|
||||
:param thumbnail_url: the url of the thumbnail to use when displaying
|
||||
the snippet to save
|
||||
"""
|
||||
app_name = template_key.split('.')[0]
|
||||
snippet_key = '%s_%s' % (snippet_key, uuid.uuid4().hex)
|
||||
full_snippet_key = '%s.%s' % (app_name, snippet_key)
|
||||
|
||||
# find available name
|
||||
current_website = self.env['website'].browse(self.env.context.get('website_id'))
|
||||
website_domain = Domain(current_website.website_domain())
|
||||
used_names = self.search(Domain('name', '=like', '%s%%' % name) & website_domain).mapped('name')
|
||||
name = self._find_available_name(name, used_names)
|
||||
|
||||
# html to xml to add '/' at the end of self closing tags like br, ...
|
||||
arch_tree = html.fromstring(arch)
|
||||
attributes = self._get_cleaned_non_editing_attributes(arch_tree.attrib.items())
|
||||
for attr in arch_tree.attrib:
|
||||
if attr in attributes:
|
||||
arch_tree.attrib[attr] = attributes[attr]
|
||||
else:
|
||||
del arch_tree.attrib[attr]
|
||||
xml_arch = etree.tostring(arch_tree, encoding='utf-8')
|
||||
new_snippet_view_values = {
|
||||
'name': name,
|
||||
'key': full_snippet_key,
|
||||
'type': 'qweb',
|
||||
'arch': xml_arch,
|
||||
}
|
||||
new_snippet_view_values.update(self._snippet_save_view_values_hook())
|
||||
custom_snippet_view = self.create(new_snippet_view_values)
|
||||
model = self.env.context.get('model')
|
||||
field = self.env.context.get('field')
|
||||
if field == 'arch':
|
||||
# Special case for `arch` which is a kind of related (through a
|
||||
# compute) to `arch_db` but which is hosting XML/HTML content while
|
||||
# being a char field.. Which is then messing around with the
|
||||
# `get_translation_dictionary` call, returning XML instead of
|
||||
# strings
|
||||
field = 'arch_db'
|
||||
res_id = self.env.context.get('resId')
|
||||
if model and field and res_id:
|
||||
self._copy_field_terms_translations(
|
||||
self.env[model].browse(int(res_id)),
|
||||
field,
|
||||
custom_snippet_view,
|
||||
'arch_db',
|
||||
)
|
||||
|
||||
custom_section = self.search([('key', '=', template_key)])
|
||||
snippet_addition_view_values = {
|
||||
'name': name + ' Block',
|
||||
'key': self._get_snippet_addition_view_key(template_key, snippet_key),
|
||||
'inherit_id': custom_section.id,
|
||||
'type': 'qweb',
|
||||
'arch': """
|
||||
<data inherit_id="%s">
|
||||
<xpath expr="//snippets[@id='snippet_custom']" position="inside">
|
||||
<t t-snippet="%s" t-thumbnail="%s"/>
|
||||
</xpath>
|
||||
</data>
|
||||
""" % (template_key, full_snippet_key, thumbnail_url),
|
||||
}
|
||||
snippet_addition_view_values.update(self._snippet_save_view_values_hook())
|
||||
self.create(snippet_addition_view_values)
|
||||
return name
|
||||
|
||||
@api.model
|
||||
def rename_snippet(self, name, view_id, template_key):
|
||||
snippet_view = self.browse(view_id)
|
||||
key = snippet_view.key.split('.')[1]
|
||||
custom_key = self._get_snippet_addition_view_key(template_key, key)
|
||||
snippet_addition_view = self.search([('key', '=', custom_key)])
|
||||
if snippet_addition_view:
|
||||
snippet_addition_view.name = name + ' Block'
|
||||
snippet_view.name = name
|
||||
|
||||
@api.model
|
||||
def delete_snippet(self, view_id, template_key):
|
||||
snippet_view = self.browse(view_id)
|
||||
key = snippet_view.key.split('.')[1]
|
||||
custom_key = self._get_snippet_addition_view_key(template_key, key)
|
||||
snippet_addition_view = self.search([('key', '=', custom_key)])
|
||||
(snippet_addition_view | snippet_view).unlink()
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import re
|
||||
|
||||
from odoo import models
|
||||
from odoo.exceptions import AccessDenied
|
||||
|
||||
|
||||
class IrWebsocket(models.AbstractModel):
|
||||
_inherit = 'ir.websocket'
|
||||
|
||||
def _build_bus_channel_list(self, channels):
|
||||
if self.env.uid:
|
||||
# Do not alter original list.
|
||||
channels = list(channels)
|
||||
for channel in channels:
|
||||
if isinstance(channel, str):
|
||||
match = re.match(r'editor_collaboration:(\w+(?:\.\w+)*):(\w+):(\d+)', channel)
|
||||
if match:
|
||||
model_name = match[1]
|
||||
field_name = match[2]
|
||||
res_id = int(match[3])
|
||||
|
||||
# Verify access to the edition channel.
|
||||
if self.env.user._is_public():
|
||||
raise AccessDenied()
|
||||
|
||||
document = self.env[model_name].browse([res_id])
|
||||
if not document.exists():
|
||||
continue
|
||||
|
||||
document.check_access('read')
|
||||
document.check_access('write')
|
||||
if field := document._fields.get(field_name):
|
||||
document._check_field_access(field, 'read')
|
||||
document._check_field_access(field, 'write')
|
||||
|
||||
channels.append((self.env.registry.db_name, 'editor_collaboration', model_name, field_name, res_id))
|
||||
return super()._build_bus_channel_list(channels)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class Base(models.AbstractModel):
|
||||
_inherit = 'base'
|
||||
|
||||
@api.model
|
||||
def _get_view_field_attributes(self):
|
||||
keys = super()._get_view_field_attributes()
|
||||
keys.append('sanitize')
|
||||
keys.append('sanitize_tags')
|
||||
return keys
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class Html_EditorConverterTest(models.Model):
|
||||
_name = 'html_editor.converter.test'
|
||||
_description = 'Html Editor Converter Test'
|
||||
|
||||
# disable translation export for those brilliant field labels and values
|
||||
_translate = False
|
||||
|
||||
char = fields.Char()
|
||||
integer = fields.Integer()
|
||||
float = fields.Float()
|
||||
numeric = fields.Float(digits=(16, 2))
|
||||
many2one = fields.Many2one('html_editor.converter.test.sub')
|
||||
binary = fields.Binary(attachment=False)
|
||||
date = fields.Date()
|
||||
datetime = fields.Datetime()
|
||||
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="Lorsqu'un pancake prend l'avion à destination de Toronto et "
|
||||
"qu'il fait une escale technique à St Claude, on dit:")
|
||||
html = fields.Html()
|
||||
text = fields.Text()
|
||||
|
||||
|
||||
class Html_EditorConverterTestSub(models.Model):
|
||||
_name = 'html_editor.converter.test.sub'
|
||||
_description = 'Html Editor Converter Subtest'
|
||||
|
||||
name = fields.Char()
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_html_editor_converter_test,access_html_editor_converter_test,model_html_editor_converter_test,base.group_system,1,1,1,1
|
||||
access_html_editor_converter_test_sub,access_html_editor_converter_test_sub,model_html_editor_converter_test_sub,base.group_system,1,1,1,1
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright 2015-present Chen Fengyuan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
/*!
|
||||
* Cropper.js v1.5.5
|
||||
* https://fengyuanchen.github.io/cropperjs
|
||||
*
|
||||
* Copyright 2015-present Chen Fengyuan
|
||||
* Released under the MIT license
|
||||
*
|
||||
* Date: 2019-08-04T02:26:27.232Z
|
||||
*/
|
||||
|
||||
.cropper-container {
|
||||
direction: ltr;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cropper-container img {
|
||||
display: block;
|
||||
height: 100%;
|
||||
image-orientation: 0deg;
|
||||
max-height: none !important;
|
||||
max-width: none !important;
|
||||
min-height: 0 !important;
|
||||
min-width: 0 !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cropper-wrap-box,
|
||||
.cropper-canvas,
|
||||
.cropper-drag-box,
|
||||
.cropper-crop-box,
|
||||
.cropper-modal {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.cropper-wrap-box,
|
||||
.cropper-canvas {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cropper-drag-box {
|
||||
background-color: #fff;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cropper-modal {
|
||||
background-color: #000;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cropper-view-box {
|
||||
display: block;
|
||||
height: 100%;
|
||||
outline: 1px solid #39f;
|
||||
outline-color: rgba(51, 153, 255, 0.75);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cropper-dashed {
|
||||
border: 0 dashed #eee;
|
||||
display: block;
|
||||
opacity: 0.5;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.cropper-dashed.dashed-h {
|
||||
border-bottom-width: 1px;
|
||||
border-top-width: 1px;
|
||||
height: calc(100% / 3);
|
||||
left: 0;
|
||||
top: calc(100% / 3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cropper-dashed.dashed-v {
|
||||
border-left-width: 1px;
|
||||
border-right-width: 1px;
|
||||
height: 100%;
|
||||
left: calc(100% / 3);
|
||||
top: 0;
|
||||
width: calc(100% / 3);
|
||||
}
|
||||
|
||||
.cropper-center {
|
||||
display: block;
|
||||
height: 0;
|
||||
left: 50%;
|
||||
opacity: 0.75;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.cropper-center::before,
|
||||
.cropper-center::after {
|
||||
background-color: #eee;
|
||||
content: ' ';
|
||||
display: block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.cropper-center::before {
|
||||
height: 1px;
|
||||
left: -3px;
|
||||
top: 0;
|
||||
width: 7px;
|
||||
}
|
||||
|
||||
.cropper-center::after {
|
||||
height: 7px;
|
||||
left: 0;
|
||||
top: -3px;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.cropper-face,
|
||||
.cropper-line,
|
||||
.cropper-point {
|
||||
display: block;
|
||||
height: 100%;
|
||||
opacity: 0.1;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cropper-face {
|
||||
background-color: #fff;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.cropper-line {
|
||||
background-color: #39f;
|
||||
}
|
||||
|
||||
.cropper-line.line-e {
|
||||
cursor: ew-resize;
|
||||
right: -3px;
|
||||
top: 0;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.cropper-line.line-n {
|
||||
cursor: ns-resize;
|
||||
height: 5px;
|
||||
left: 0;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
.cropper-line.line-w {
|
||||
cursor: ew-resize;
|
||||
left: -3px;
|
||||
top: 0;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.cropper-line.line-s {
|
||||
bottom: -3px;
|
||||
cursor: ns-resize;
|
||||
height: 5px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.cropper-point {
|
||||
background-color: #39f;
|
||||
height: 5px;
|
||||
opacity: 0.75;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.cropper-point.point-e {
|
||||
cursor: ew-resize;
|
||||
margin-top: -3px;
|
||||
right: -3px;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.cropper-point.point-n {
|
||||
cursor: ns-resize;
|
||||
left: 50%;
|
||||
margin-left: -3px;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
.cropper-point.point-w {
|
||||
cursor: ew-resize;
|
||||
left: -3px;
|
||||
margin-top: -3px;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.cropper-point.point-s {
|
||||
bottom: -3px;
|
||||
cursor: s-resize;
|
||||
left: 50%;
|
||||
margin-left: -3px;
|
||||
}
|
||||
|
||||
.cropper-point.point-ne {
|
||||
cursor: nesw-resize;
|
||||
right: -3px;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
.cropper-point.point-nw {
|
||||
cursor: nwse-resize;
|
||||
left: -3px;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
.cropper-point.point-sw {
|
||||
bottom: -3px;
|
||||
cursor: nesw-resize;
|
||||
left: -3px;
|
||||
}
|
||||
|
||||
.cropper-point.point-se {
|
||||
bottom: -3px;
|
||||
cursor: nwse-resize;
|
||||
height: 20px;
|
||||
opacity: 1;
|
||||
right: -3px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.cropper-point.point-se {
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.cropper-point.point-se {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.cropper-point.point-se {
|
||||
height: 5px;
|
||||
opacity: 0.75;
|
||||
width: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.cropper-point.point-se::before {
|
||||
background-color: #39f;
|
||||
bottom: -50%;
|
||||
content: ' ';
|
||||
display: block;
|
||||
height: 200%;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: -50%;
|
||||
width: 200%;
|
||||
}
|
||||
|
||||
.cropper-invisible {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cropper-bg {
|
||||
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');
|
||||
}
|
||||
|
||||
.cropper-hide {
|
||||
display: block;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.cropper-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cropper-move {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.cropper-crop {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.cropper-disabled .cropper-drag-box,
|
||||
.cropper-disabled .cropper-face,
|
||||
.cropper-disabled .cropper-line,
|
||||
.cropper-disabled .cropper-point {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,14 @@
|
|||
Copyright 2014-2016 Rodrigo Fernandes https://rtfpessoa.github.io/
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
||||
persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
||||
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
1
odoo-bringout-oca-ocb-html_editor/html_editor/static/lib/diff2html/diff2html.min.css
vendored
Normal file
1
odoo-bringout-oca-ocb-html_editor/html_editor/static/lib/diff2html/diff2html.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
odoo-bringout-oca-ocb-html_editor/html_editor/static/lib/diff2html/diff2html.min.js
vendored
Normal file
1
odoo-bringout-oca-ocb-html_editor/html_editor/static/lib/diff2html/diff2html.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,358 @@
|
|||
/**
|
||||
* vkBeautify - javascript plugin to pretty-print or minify text in XML, JSON, CSS and SQL formats.
|
||||
*
|
||||
* Version - 0.99.00.beta
|
||||
* Copyright (c) 2012 Vadim Kiryukhin
|
||||
* vkiryukhin @ gmail.com
|
||||
* http://www.eslinstructor.net/vkbeautify/
|
||||
*
|
||||
* Dual licensed under the MIT and GPL licenses:
|
||||
* http://www.opensource.org/licenses/mit-license.php
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*
|
||||
* Pretty print
|
||||
*
|
||||
* vkbeautify.xml(text [,indent_pattern]);
|
||||
* vkbeautify.json(text [,indent_pattern]);
|
||||
* vkbeautify.css(text [,indent_pattern]);
|
||||
* vkbeautify.sql(text [,indent_pattern]);
|
||||
*
|
||||
* @text - String; text to beatufy;
|
||||
* @indent_pattern - Integer | String;
|
||||
* Integer: number of white spaces;
|
||||
* String: character string to visualize indentation ( can also be a set of white spaces )
|
||||
* Minify
|
||||
*
|
||||
* vkbeautify.xmlmin(text [,preserve_comments]);
|
||||
* vkbeautify.jsonmin(text);
|
||||
* vkbeautify.cssmin(text [,preserve_comments]);
|
||||
* vkbeautify.sqlmin(text);
|
||||
*
|
||||
* @text - String; text to minify;
|
||||
* @preserve_comments - Bool; [optional];
|
||||
* Set this flag to true to prevent removing comments from @text ( minxml and mincss functions only. )
|
||||
*
|
||||
* Examples:
|
||||
* vkbeautify.xml(text); // pretty print XML
|
||||
* vkbeautify.json(text, 4 ); // pretty print JSON
|
||||
* vkbeautify.css(text, '. . . .'); // pretty print CSS
|
||||
* vkbeautify.sql(text, '----'); // pretty print SQL
|
||||
*
|
||||
* vkbeautify.xmlmin(text, true);// minify XML, preserve comments
|
||||
* vkbeautify.jsonmin(text);// minify JSON
|
||||
* vkbeautify.cssmin(text);// minify CSS, remove comments ( default )
|
||||
* vkbeautify.sqlmin(text);// minify SQL
|
||||
*
|
||||
*/
|
||||
|
||||
(function() {
|
||||
|
||||
function createShiftArr(step) {
|
||||
|
||||
var space = ' ';
|
||||
|
||||
if ( isNaN(parseInt(step)) ) { // argument is string
|
||||
space = step;
|
||||
} else { // argument is integer
|
||||
switch(step) {
|
||||
case 1: space = ' '; break;
|
||||
case 2: space = ' '; break;
|
||||
case 3: space = ' '; break;
|
||||
case 4: space = ' '; break;
|
||||
case 5: space = ' '; break;
|
||||
case 6: space = ' '; break;
|
||||
case 7: space = ' '; break;
|
||||
case 8: space = ' '; break;
|
||||
case 9: space = ' '; break;
|
||||
case 10: space = ' '; break;
|
||||
case 11: space = ' '; break;
|
||||
case 12: space = ' '; break;
|
||||
}
|
||||
}
|
||||
|
||||
var shift = ['\n']; // array of shifts
|
||||
for(ix=0;ix<100;ix++){
|
||||
shift.push(shift[ix]+space);
|
||||
}
|
||||
return shift;
|
||||
}
|
||||
|
||||
function vkbeautify(){
|
||||
this.step = ' '; // 4 spaces
|
||||
this.shift = createShiftArr(this.step);
|
||||
};
|
||||
|
||||
vkbeautify.prototype.xml = function(text,step) {
|
||||
|
||||
var ar = text.replace(/>\s{0,}</g,"><")
|
||||
.replace(/</g,"~::~<")
|
||||
.replace(/\s*xmlns\:/g,"~::~xmlns:")
|
||||
.replace(/\s*xmlns\=/g,"~::~xmlns=")
|
||||
.split('~::~'),
|
||||
len = ar.length,
|
||||
inComment = false,
|
||||
deep = 0,
|
||||
str = '',
|
||||
ix = 0,
|
||||
shift = step ? createShiftArr(step) : this.shift;
|
||||
|
||||
for(ix=0;ix<len;ix++) {
|
||||
// start comment or <![CDATA[...]]> or <!DOCTYPE //
|
||||
if(ar[ix].search(/<!/) > -1) {
|
||||
str += shift[deep]+ar[ix];
|
||||
inComment = true;
|
||||
// end comment or <![CDATA[...]]> //
|
||||
if(ar[ix].search(/-->/) > -1 || ar[ix].search(/\]>/) > -1 || ar[ix].search(/!DOCTYPE/) > -1 ) {
|
||||
inComment = false;
|
||||
}
|
||||
} else
|
||||
// end comment or <![CDATA[...]]> //
|
||||
if(ar[ix].search(/-->/) > -1 || ar[ix].search(/\]>/) > -1) {
|
||||
str += ar[ix];
|
||||
inComment = false;
|
||||
} else
|
||||
// <elm></elm> //
|
||||
if( /^<\w/.exec(ar[ix-1]) && /^<\/\w/.exec(ar[ix]) &&
|
||||
/^<[\w:\-\.\,]+/.exec(ar[ix-1]) == /^<\/[\w:\-\.\,]+/.exec(ar[ix])[0].replace('/','')) {
|
||||
str += ar[ix];
|
||||
if(!inComment) deep--;
|
||||
} else
|
||||
// <elm> //
|
||||
if(ar[ix].search(/<\w/) > -1 && ar[ix].search(/<\//) == -1 && ar[ix].search(/\/>/) == -1 ) {
|
||||
str = !inComment ? str += shift[deep++]+ar[ix] : str += ar[ix];
|
||||
} else
|
||||
// <elm>...</elm> //
|
||||
if(ar[ix].search(/<\w/) > -1 && ar[ix].search(/<\//) > -1) {
|
||||
str = !inComment ? str += shift[deep]+ar[ix] : str += ar[ix];
|
||||
} else
|
||||
// </elm> //
|
||||
if(ar[ix].search(/<\//) > -1) {
|
||||
str = !inComment ? str += shift[--deep]+ar[ix] : str += ar[ix];
|
||||
} else
|
||||
// <elm/> //
|
||||
if(ar[ix].search(/\/>/) > -1 ) {
|
||||
str = !inComment ? str += shift[deep]+ar[ix] : str += ar[ix];
|
||||
} else
|
||||
// <? xml ... ?> //
|
||||
if(ar[ix].search(/<\?/) > -1) {
|
||||
str += shift[deep]+ar[ix];
|
||||
} else
|
||||
// xmlns //
|
||||
if( ar[ix].search(/xmlns\:/) > -1 || ar[ix].search(/xmlns\=/) > -1) {
|
||||
str += shift[deep]+ar[ix];
|
||||
}
|
||||
|
||||
else {
|
||||
str += ar[ix];
|
||||
}
|
||||
}
|
||||
|
||||
return (str[0] == '\n') ? str.slice(1) : str;
|
||||
}
|
||||
|
||||
vkbeautify.prototype.json = function(text,step) {
|
||||
|
||||
var step = step ? step : this.step;
|
||||
|
||||
if (typeof JSON === 'undefined' ) return text;
|
||||
|
||||
if ( typeof text === "string" ) return JSON.stringify(JSON.parse(text), null, step);
|
||||
if ( typeof text === "object" ) return JSON.stringify(text, null, step);
|
||||
|
||||
return text; // text is not string nor object
|
||||
}
|
||||
|
||||
vkbeautify.prototype.css = function(text, step) {
|
||||
|
||||
var ar = text.replace(/\s{1,}/g,' ')
|
||||
.replace(/\{/g,"{~::~")
|
||||
.replace(/\}/g,"~::~}~::~")
|
||||
.replace(/\;/g,";~::~")
|
||||
.replace(/\/\*/g,"~::~/*")
|
||||
.replace(/\*\//g,"*/~::~")
|
||||
.replace(/~::~\s{0,}~::~/g,"~::~")
|
||||
.split('~::~'),
|
||||
len = ar.length,
|
||||
deep = 0,
|
||||
str = '',
|
||||
ix = 0,
|
||||
shift = step ? createShiftArr(step) : this.shift;
|
||||
|
||||
for(ix=0;ix<len;ix++) {
|
||||
|
||||
if( /\{/.exec(ar[ix])) {
|
||||
str += shift[deep++]+ar[ix];
|
||||
} else
|
||||
if( /\}/.exec(ar[ix])) {
|
||||
str += shift[--deep]+ar[ix];
|
||||
} else
|
||||
if( /\*\\/.exec(ar[ix])) {
|
||||
str += shift[deep]+ar[ix];
|
||||
}
|
||||
else {
|
||||
str += shift[deep]+ar[ix];
|
||||
}
|
||||
}
|
||||
return str.replace(/^\n{1,}/,'');
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------
|
||||
|
||||
function isSubquery(str, parenthesisLevel) {
|
||||
return parenthesisLevel - (str.replace(/\(/g,'').length - str.replace(/\)/g,'').length )
|
||||
}
|
||||
|
||||
function split_sql(str, tab) {
|
||||
|
||||
return str.replace(/\s{1,}/g," ")
|
||||
|
||||
.replace(/ AND /ig,"~::~"+tab+tab+"AND ")
|
||||
.replace(/ BETWEEN /ig,"~::~"+tab+"BETWEEN ")
|
||||
.replace(/ CASE /ig,"~::~"+tab+"CASE ")
|
||||
.replace(/ ELSE /ig,"~::~"+tab+"ELSE ")
|
||||
.replace(/ END /ig,"~::~"+tab+"END ")
|
||||
.replace(/ FROM /ig,"~::~FROM ")
|
||||
.replace(/ GROUP\s{1,}BY/ig,"~::~GROUP BY ")
|
||||
.replace(/ HAVING /ig,"~::~HAVING ")
|
||||
//.replace(/ SET /ig," SET~::~")
|
||||
.replace(/ IN /ig," IN ")
|
||||
|
||||
.replace(/ JOIN /ig,"~::~JOIN ")
|
||||
.replace(/ CROSS~::~{1,}JOIN /ig,"~::~CROSS JOIN ")
|
||||
.replace(/ INNER~::~{1,}JOIN /ig,"~::~INNER JOIN ")
|
||||
.replace(/ LEFT~::~{1,}JOIN /ig,"~::~LEFT JOIN ")
|
||||
.replace(/ RIGHT~::~{1,}JOIN /ig,"~::~RIGHT JOIN ")
|
||||
|
||||
.replace(/ ON /ig,"~::~"+tab+"ON ")
|
||||
.replace(/ OR /ig,"~::~"+tab+tab+"OR ")
|
||||
.replace(/ ORDER\s{1,}BY/ig,"~::~ORDER BY ")
|
||||
.replace(/ OVER /ig,"~::~"+tab+"OVER ")
|
||||
|
||||
.replace(/\(\s{0,}SELECT /ig,"~::~(SELECT ")
|
||||
.replace(/\)\s{0,}SELECT /ig,")~::~SELECT ")
|
||||
|
||||
.replace(/ THEN /ig," THEN~::~"+tab+"")
|
||||
.replace(/ UNION /ig,"~::~UNION~::~")
|
||||
.replace(/ USING /ig,"~::~USING ")
|
||||
.replace(/ WHEN /ig,"~::~"+tab+"WHEN ")
|
||||
.replace(/ WHERE /ig,"~::~WHERE ")
|
||||
.replace(/ WITH /ig,"~::~WITH ")
|
||||
|
||||
//.replace(/\,\s{0,}\(/ig,",~::~( ")
|
||||
//.replace(/\,/ig,",~::~"+tab+tab+"")
|
||||
|
||||
.replace(/ ALL /ig," ALL ")
|
||||
.replace(/ AS /ig," AS ")
|
||||
.replace(/ ASC /ig," ASC ")
|
||||
.replace(/ DESC /ig," DESC ")
|
||||
.replace(/ DISTINCT /ig," DISTINCT ")
|
||||
.replace(/ EXISTS /ig," EXISTS ")
|
||||
.replace(/ NOT /ig," NOT ")
|
||||
.replace(/ NULL /ig," NULL ")
|
||||
.replace(/ LIKE /ig," LIKE ")
|
||||
.replace(/\s{0,}SELECT /ig,"SELECT ")
|
||||
.replace(/\s{0,}UPDATE /ig,"UPDATE ")
|
||||
.replace(/ SET /ig," SET ")
|
||||
|
||||
.replace(/~::~{1,}/g,"~::~")
|
||||
.split('~::~');
|
||||
}
|
||||
|
||||
vkbeautify.prototype.sql = function(text,step) {
|
||||
|
||||
var ar_by_quote = text.replace(/\s{1,}/g," ")
|
||||
.replace(/\'/ig,"~::~\'")
|
||||
.split('~::~'),
|
||||
len = ar_by_quote.length,
|
||||
ar = [],
|
||||
deep = 0,
|
||||
tab = this.step,//+this.step,
|
||||
inComment = true,
|
||||
inQuote = false,
|
||||
parenthesisLevel = 0,
|
||||
str = '',
|
||||
ix = 0,
|
||||
shift = step ? createShiftArr(step) : this.shift;;
|
||||
|
||||
for(ix=0;ix<len;ix++) {
|
||||
if(ix%2) {
|
||||
ar = ar.concat(ar_by_quote[ix]);
|
||||
} else {
|
||||
ar = ar.concat(split_sql(ar_by_quote[ix], tab) );
|
||||
}
|
||||
}
|
||||
|
||||
len = ar.length;
|
||||
for(ix=0;ix<len;ix++) {
|
||||
|
||||
parenthesisLevel = isSubquery(ar[ix], parenthesisLevel);
|
||||
|
||||
if( /\s{0,}\s{0,}SELECT\s{0,}/.exec(ar[ix])) {
|
||||
ar[ix] = ar[ix].replace(/\,/g,",\n"+tab+tab+"")
|
||||
}
|
||||
|
||||
if( /\s{0,}\s{0,}SET\s{0,}/.exec(ar[ix])) {
|
||||
ar[ix] = ar[ix].replace(/\,/g,",\n"+tab+tab+"")
|
||||
}
|
||||
|
||||
if( /\s{0,}\(\s{0,}SELECT\s{0,}/.exec(ar[ix])) {
|
||||
deep++;
|
||||
str += shift[deep]+ar[ix];
|
||||
} else
|
||||
if( /\'/.exec(ar[ix]) ) {
|
||||
if(parenthesisLevel<1 && deep) {
|
||||
deep--;
|
||||
}
|
||||
str += ar[ix];
|
||||
}
|
||||
else {
|
||||
str += shift[deep]+ar[ix];
|
||||
if(parenthesisLevel<1 && deep) {
|
||||
deep--;
|
||||
}
|
||||
}
|
||||
var junk = 0;
|
||||
}
|
||||
|
||||
str = str.replace(/^\n{1,}/,'').replace(/\n{1,}/g,"\n");
|
||||
return str;
|
||||
}
|
||||
|
||||
|
||||
vkbeautify.prototype.xmlmin = function(text, preserveComments) {
|
||||
|
||||
var str = preserveComments ? text
|
||||
: text.replace(/\<![ \r\n\t]*(--([^\-]|[\r\n]|-[^\-])*--[ \r\n\t]*)\>/g,"")
|
||||
.replace(/[ \r\n\t]{1,}xmlns/g, ' xmlns');
|
||||
return str.replace(/>\s{0,}</g,"><");
|
||||
}
|
||||
|
||||
vkbeautify.prototype.jsonmin = function(text) {
|
||||
|
||||
if (typeof JSON === 'undefined' ) return text;
|
||||
|
||||
return JSON.stringify(JSON.parse(text), null, 0);
|
||||
|
||||
}
|
||||
|
||||
vkbeautify.prototype.cssmin = function(text, preserveComments) {
|
||||
|
||||
var str = preserveComments ? text
|
||||
: text.replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+\//g,"") ;
|
||||
|
||||
return str.replace(/\s{1,}/g,' ')
|
||||
.replace(/\{\s{1,}/g,"{")
|
||||
.replace(/\}\s{1,}/g,"}")
|
||||
.replace(/\;\s{1,}/g,";")
|
||||
.replace(/\/\*\s{1,}/g,"/*")
|
||||
.replace(/\*\/\s{1,}/g,"*/");
|
||||
}
|
||||
|
||||
vkbeautify.prototype.sqlmin = function(text) {
|
||||
return text.replace(/\s{1,}/g," ").replace(/\s{1,}\(/,"(").replace(/\s{1,}\)/,")");
|
||||
}
|
||||
|
||||
window.vkbeautify = new vkbeautify();
|
||||
|
||||
})();
|
||||
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2020 Dominic Szablewski - phoboslab.org
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -0,0 +1,650 @@
|
|||
/*
|
||||
WebGLImageFilter - MIT Licensed
|
||||
|
||||
2013, Dominic Szablewski - phoboslab.org
|
||||
*/
|
||||
|
||||
(function (window) {
|
||||
var WebGLProgram = function (gl, vertexSource, fragmentSource) {
|
||||
var _collect = function (source, prefix, collection) {
|
||||
var r = new RegExp("\\b" + prefix + " \\w+ (\\w+)", "ig");
|
||||
source.replace(r, function (match, name) {
|
||||
collection[name] = 0;
|
||||
return match;
|
||||
});
|
||||
};
|
||||
|
||||
var _compile = function (gl, source, type) {
|
||||
var shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.log(gl.getShaderInfoLog(shader));
|
||||
return null;
|
||||
}
|
||||
return shader;
|
||||
};
|
||||
|
||||
this.uniform = {};
|
||||
this.attribute = {};
|
||||
|
||||
var _vsh = _compile(gl, vertexSource, gl.VERTEX_SHADER);
|
||||
var _fsh = _compile(gl, fragmentSource, gl.FRAGMENT_SHADER);
|
||||
|
||||
this.id = gl.createProgram();
|
||||
gl.attachShader(this.id, _vsh);
|
||||
gl.attachShader(this.id, _fsh);
|
||||
gl.linkProgram(this.id);
|
||||
|
||||
if (!gl.getProgramParameter(this.id, gl.LINK_STATUS)) {
|
||||
console.log(gl.getProgramInfoLog(this.id));
|
||||
}
|
||||
|
||||
gl.useProgram(this.id);
|
||||
|
||||
// Collect attributes
|
||||
_collect(vertexSource, "attribute", this.attribute);
|
||||
for (var a in this.attribute) {
|
||||
this.attribute[a] = gl.getAttribLocation(this.id, a);
|
||||
}
|
||||
|
||||
// Collect uniforms
|
||||
_collect(vertexSource, "uniform", this.uniform);
|
||||
_collect(fragmentSource, "uniform", this.uniform);
|
||||
for (var u in this.uniform) {
|
||||
this.uniform[u] = gl.getUniformLocation(this.id, u);
|
||||
}
|
||||
};
|
||||
|
||||
const identityMatrix = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0];
|
||||
|
||||
const weightedAvg = (a, b, w) => a * w + b * (1 - w);
|
||||
|
||||
var WebGLImageFilter = (window.WebGLImageFilter = function (params) {
|
||||
if (!params) {
|
||||
params = {};
|
||||
}
|
||||
|
||||
var gl = null,
|
||||
_drawCount = 0,
|
||||
_sourceTexture = null,
|
||||
_lastInChain = false,
|
||||
_currentFramebufferIndex = -1,
|
||||
_tempFramebuffers = [null, null],
|
||||
_filterChain = [],
|
||||
_width = -1,
|
||||
_height = -1,
|
||||
_vertexBuffer = null,
|
||||
_currentProgram = null,
|
||||
_canvas = params.canvas || document.createElement("canvas");
|
||||
|
||||
// key is the shader program source, value is the compiled program
|
||||
var _shaderProgramCache = {};
|
||||
|
||||
var gl = _canvas.getContext("webgl") || _canvas.getContext("experimental-webgl");
|
||||
if (!gl) {
|
||||
throw "Couldn't get WebGL context";
|
||||
}
|
||||
|
||||
this.addFilter = function (name) {
|
||||
var args = Array.prototype.slice.call(arguments, 1);
|
||||
var filter = _filter[name];
|
||||
|
||||
_filterChain.push({ func: filter, args: args });
|
||||
};
|
||||
|
||||
this.reset = function () {
|
||||
_filterChain = [];
|
||||
};
|
||||
|
||||
this.apply = function (image) {
|
||||
_resize(image.width, image.height);
|
||||
_drawCount = 0;
|
||||
|
||||
// Create the texture for the input image if we haven't yet
|
||||
if (!_sourceTexture) {
|
||||
_sourceTexture = gl.createTexture();
|
||||
}
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, _sourceTexture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
||||
|
||||
// No filters? Just draw
|
||||
if (_filterChain.length == 0) {
|
||||
var program = _compileShader(SHADER.FRAGMENT_IDENTITY);
|
||||
_draw();
|
||||
return _canvas;
|
||||
}
|
||||
|
||||
for (var i = 0; i < _filterChain.length; i++) {
|
||||
_lastInChain = i == _filterChain.length - 1;
|
||||
var f = _filterChain[i];
|
||||
|
||||
f.func.apply(this, f.args || []);
|
||||
}
|
||||
|
||||
return _canvas;
|
||||
};
|
||||
|
||||
var _resize = function (width, height) {
|
||||
// Same width/height? Nothing to do here
|
||||
if (width == _width && height == _height) {
|
||||
return;
|
||||
}
|
||||
|
||||
_canvas.width = _width = width;
|
||||
_canvas.height = _height = height;
|
||||
|
||||
// Create the context if we don't have it yet
|
||||
if (!_vertexBuffer) {
|
||||
// Create the vertex buffer for the two triangles [x, y, u, v] * 6
|
||||
var vertices = new Float32Array([
|
||||
-1, -1, 0, 1, 1, -1, 1, 1, -1, 1, 0, 0, -1, 1, 0, 0, 1, -1, 1, 1, 1, 1, 1, 0,
|
||||
]);
|
||||
(_vertexBuffer = gl.createBuffer()), gl.bindBuffer(gl.ARRAY_BUFFER, _vertexBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
|
||||
|
||||
// Note sure if this is a good idea; at least it makes texture loading
|
||||
// in Ejecta instant.
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
|
||||
}
|
||||
|
||||
gl.viewport(0, 0, _width, _height);
|
||||
|
||||
// Delete old temp framebuffers
|
||||
_tempFramebuffers = [null, null];
|
||||
};
|
||||
|
||||
var _getTempFramebuffer = function (index) {
|
||||
_tempFramebuffers[index] =
|
||||
_tempFramebuffers[index] || _createFramebufferTexture(_width, _height);
|
||||
|
||||
return _tempFramebuffers[index];
|
||||
};
|
||||
|
||||
var _createFramebufferTexture = function (width, height) {
|
||||
var fbo = gl.createFramebuffer();
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||
|
||||
var renderbuffer = gl.createRenderbuffer();
|
||||
gl.bindRenderbuffer(gl.RENDERBUFFER, renderbuffer);
|
||||
|
||||
var texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
null
|
||||
);
|
||||
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
gl.framebufferTexture2D(
|
||||
gl.FRAMEBUFFER,
|
||||
gl.COLOR_ATTACHMENT0,
|
||||
gl.TEXTURE_2D,
|
||||
texture,
|
||||
0
|
||||
);
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
|
||||
return { fbo: fbo, texture: texture };
|
||||
};
|
||||
|
||||
var _draw = function (flags) {
|
||||
var source = null,
|
||||
target = null,
|
||||
flipY = false;
|
||||
|
||||
// Set up the source
|
||||
if (_drawCount == 0) {
|
||||
// First draw call - use the source texture
|
||||
source = _sourceTexture;
|
||||
} else {
|
||||
// All following draw calls use the temp buffer last drawn to
|
||||
source = _getTempFramebuffer(_currentFramebufferIndex).texture;
|
||||
}
|
||||
_drawCount++;
|
||||
|
||||
// Set up the target
|
||||
if (_lastInChain && !(flags & DRAW.INTERMEDIATE)) {
|
||||
// Last filter in our chain - draw directly to the WebGL Canvas. We may
|
||||
// also have to flip the image vertically now
|
||||
target = null;
|
||||
flipY = _drawCount % 2 == 0;
|
||||
} else {
|
||||
// Intermediate draw call - get a temp buffer to draw to
|
||||
_currentFramebufferIndex = (_currentFramebufferIndex + 1) % 2;
|
||||
target = _getTempFramebuffer(_currentFramebufferIndex).fbo;
|
||||
}
|
||||
|
||||
// Bind the source and target and draw the two triangles
|
||||
gl.bindTexture(gl.TEXTURE_2D, source);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, target);
|
||||
|
||||
gl.uniform1f(_currentProgram.uniform.flipY, flipY ? -1 : 1);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
};
|
||||
|
||||
var _compileShader = function (fragmentSource) {
|
||||
if (_shaderProgramCache[fragmentSource]) {
|
||||
_currentProgram = _shaderProgramCache[fragmentSource];
|
||||
gl.useProgram(_currentProgram.id);
|
||||
return _currentProgram;
|
||||
}
|
||||
|
||||
// Compile shaders
|
||||
_currentProgram = new WebGLProgram(gl, SHADER.VERTEX_IDENTITY, fragmentSource);
|
||||
|
||||
var floatSize = Float32Array.BYTES_PER_ELEMENT;
|
||||
var vertSize = 4 * floatSize;
|
||||
gl.enableVertexAttribArray(_currentProgram.attribute.pos);
|
||||
gl.vertexAttribPointer(
|
||||
_currentProgram.attribute.pos,
|
||||
2,
|
||||
gl.FLOAT,
|
||||
false,
|
||||
vertSize,
|
||||
0 * floatSize
|
||||
);
|
||||
gl.enableVertexAttribArray(_currentProgram.attribute.uv);
|
||||
gl.vertexAttribPointer(
|
||||
_currentProgram.attribute.uv,
|
||||
2,
|
||||
gl.FLOAT,
|
||||
false,
|
||||
vertSize,
|
||||
2 * floatSize
|
||||
);
|
||||
|
||||
_shaderProgramCache[fragmentSource] = _currentProgram;
|
||||
return _currentProgram;
|
||||
};
|
||||
|
||||
var DRAW = { INTERMEDIATE: 1 };
|
||||
|
||||
var SHADER = {};
|
||||
SHADER.VERTEX_IDENTITY = [
|
||||
"precision highp float;",
|
||||
"attribute vec2 pos;",
|
||||
"attribute vec2 uv;",
|
||||
"varying vec2 vUv;",
|
||||
"uniform float flipY;",
|
||||
|
||||
"void main(void) {",
|
||||
"vUv = uv;",
|
||||
"gl_Position = vec4(pos.x, pos.y*flipY, 0.0, 1.);",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
SHADER.FRAGMENT_IDENTITY = [
|
||||
"precision highp float;",
|
||||
"varying vec2 vUv;",
|
||||
"uniform sampler2D texture;",
|
||||
|
||||
"void main(void) {",
|
||||
"gl_FragColor = texture2D(texture, vUv);",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
var _filter = {};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Color Matrix Filter
|
||||
|
||||
_filter.colorMatrix = function (matrix, amount = 1) {
|
||||
matrix = matrix.map((coef, index) => weightedAvg(coef, identityMatrix[index], amount));
|
||||
// Create a Float32 Array and normalize the offset component to 0-1
|
||||
var m = new Float32Array(matrix);
|
||||
m[4] /= 255;
|
||||
m[9] /= 255;
|
||||
m[14] /= 255;
|
||||
m[19] /= 255;
|
||||
|
||||
// Can we ignore the alpha value? Makes things a bit faster.
|
||||
var shader =
|
||||
1 == m[18] &&
|
||||
0 == m[3] &&
|
||||
0 == m[8] &&
|
||||
0 == m[13] &&
|
||||
0 == m[15] &&
|
||||
0 == m[16] &&
|
||||
0 == m[17] &&
|
||||
0 == m[19]
|
||||
? _filter.colorMatrix.SHADER.WITHOUT_ALPHA
|
||||
: _filter.colorMatrix.SHADER.WITH_ALPHA;
|
||||
|
||||
var program = _compileShader(shader);
|
||||
gl.uniform1fv(program.uniform.m, m);
|
||||
_draw();
|
||||
};
|
||||
|
||||
_filter.colorMatrix.SHADER = {};
|
||||
_filter.colorMatrix.SHADER.WITH_ALPHA = [
|
||||
"precision highp float;",
|
||||
"varying vec2 vUv;",
|
||||
"uniform sampler2D texture;",
|
||||
"uniform float m[20];",
|
||||
|
||||
"void main(void) {",
|
||||
"vec4 c = texture2D(texture, vUv);",
|
||||
"gl_FragColor.r = m[0] * c.r + m[1] * c.g + m[2] * c.b + m[3] * c.a + m[4];",
|
||||
"gl_FragColor.g = m[5] * c.r + m[6] * c.g + m[7] * c.b + m[8] * c.a + m[9];",
|
||||
"gl_FragColor.b = m[10] * c.r + m[11] * c.g + m[12] * c.b + m[13] * c.a + m[14];",
|
||||
"gl_FragColor.a = m[15] * c.r + m[16] * c.g + m[17] * c.b + m[18] * c.a + m[19];",
|
||||
"}",
|
||||
].join("\n");
|
||||
_filter.colorMatrix.SHADER.WITHOUT_ALPHA = [
|
||||
"precision highp float;",
|
||||
"varying vec2 vUv;",
|
||||
"uniform sampler2D texture;",
|
||||
"uniform float m[20];",
|
||||
|
||||
"void main(void) {",
|
||||
"vec4 c = texture2D(texture, vUv);",
|
||||
"gl_FragColor.r = m[0] * c.r + m[1] * c.g + m[2] * c.b + m[4];",
|
||||
"gl_FragColor.g = m[5] * c.r + m[6] * c.g + m[7] * c.b + m[9];",
|
||||
"gl_FragColor.b = m[10] * c.r + m[11] * c.g + m[12] * c.b + m[14];",
|
||||
"gl_FragColor.a = c.a;",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
_filter.brightness = function (brightness) {
|
||||
var b = (brightness || 0) + 1;
|
||||
_filter.colorMatrix([b, 0, 0, 0, 0, 0, b, 0, 0, 0, 0, 0, b, 0, 0, 0, 0, 0, 1, 0]);
|
||||
};
|
||||
|
||||
_filter.saturation = function (amount) {
|
||||
var x = ((amount || 0) * 2) / 3 + 1;
|
||||
var y = (x - 1) * -0.5;
|
||||
_filter.colorMatrix([x, y, y, 0, 0, y, x, y, 0, 0, y, y, x, 0, 0, 0, 0, 0, 1, 0]);
|
||||
};
|
||||
|
||||
_filter.desaturate = function () {
|
||||
_filter.saturation(-1);
|
||||
};
|
||||
|
||||
_filter.contrast = function (amount) {
|
||||
var v = (amount || 0) + 1;
|
||||
var o = -128 * (v - 1);
|
||||
|
||||
_filter.colorMatrix([v, 0, 0, 0, o, 0, v, 0, 0, o, 0, 0, v, 0, o, 0, 0, 0, 1, 0]);
|
||||
};
|
||||
|
||||
_filter.negative = function () {
|
||||
_filter.contrast(-2);
|
||||
};
|
||||
|
||||
_filter.hue = function (rotation) {
|
||||
rotation = ((rotation || 0) / 180) * Math.PI;
|
||||
var cos = Math.cos(rotation),
|
||||
sin = Math.sin(rotation),
|
||||
lumR = 0.213,
|
||||
lumG = 0.715,
|
||||
lumB = 0.072;
|
||||
|
||||
_filter.colorMatrix([
|
||||
lumR + cos * (1 - lumR) + sin * -lumR,
|
||||
lumG + cos * -lumG + sin * -lumG,
|
||||
lumB + cos * -lumB + sin * (1 - lumB),
|
||||
0,
|
||||
0,
|
||||
lumR + cos * -lumR + sin * 0.143,
|
||||
lumG + cos * (1 - lumG) + sin * 0.14,
|
||||
lumB + cos * -lumB + sin * -0.283,
|
||||
0,
|
||||
0,
|
||||
lumR + cos * -lumR + sin * -(1 - lumR),
|
||||
lumG + cos * -lumG + sin * lumG,
|
||||
lumB + cos * (1 - lumB) + sin * lumB,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]);
|
||||
};
|
||||
|
||||
_filter.desaturateLuminance = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[
|
||||
0.2764723, 0.929708, 0.0938197, 0, -37.1, 0.2764723, 0.929708, 0.0938197, 0,
|
||||
-37.1, 0.2764723, 0.929708, 0.0938197, 0, -37.1, 0, 0, 0, 1, 0,
|
||||
],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
_filter.sepia = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[
|
||||
0.393, 0.7689999, 0.18899999, 0, 0, 0.349, 0.6859999, 0.16799999, 0, 0, 0.272,
|
||||
0.5339999, 0.13099999, 0, 0, 0, 0, 0, 1, 0,
|
||||
],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
_filter.brownie = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[
|
||||
0.5997023498159715, 0.34553243048391263, -0.2708298674538042, 0,
|
||||
47.43192855600873, -0.037703249837783157, 0.8609577587992641,
|
||||
0.15059552388459913, 0, -36.96841498319127, 0.24113635128153335,
|
||||
-0.07441037908422492, 0.44972182064877153, 0, -7.562075277591283, 0, 0, 0, 1, 0,
|
||||
],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
_filter.vintagePinhole = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[
|
||||
0.6279345635605994, 0.3202183420819367, -0.03965408211312453, 0,
|
||||
9.651285835294123, 0.02578397704808868, 0.6441188644374771, 0.03259127616149294,
|
||||
0, 7.462829176470591, 0.0466055556782719, -0.0851232987247891,
|
||||
0.5241648018700465, 0, 5.159190588235296, 0, 0, 0, 1, 0,
|
||||
],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
_filter.kodachrome = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[
|
||||
1.1285582396593525, -0.3967382283601348, -0.03992559172921793, 0,
|
||||
63.72958762196502, -0.16404339962244616, 1.0835251566291304,
|
||||
-0.05498805115633132, 0, 24.732407896706203, -0.16786010706155763,
|
||||
-0.5603416277695248, 1.6014850761964943, 0, 35.62982807460946, 0, 0, 0, 1, 0,
|
||||
],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
_filter.technicolor = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[
|
||||
1.9125277891456083, -0.8545344976951645, -0.09155508482755585, 0,
|
||||
11.793603434377337, -0.3087833385928097, 1.7658908555458428,
|
||||
-0.10601743074722245, 0, -70.35205161461398, -0.231103377548616,
|
||||
-0.7501899197440212, 1.847597816108189, 0, 30.950940869491138, 0, 0, 0, 1, 0,
|
||||
],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
_filter.polaroid = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[
|
||||
1.438, -0.062, -0.062, 0, 0, -0.122, 1.378, -0.122, 0, 0, -0.016, -0.016, 1.483,
|
||||
0, 0, 0, 0, 0, 1, 0,
|
||||
],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
_filter.shiftToBGR = function (amount) {
|
||||
_filter.colorMatrix(
|
||||
[0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0],
|
||||
amount
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Convolution Filter
|
||||
|
||||
_filter.convolution = function (matrix) {
|
||||
var m = new Float32Array(matrix);
|
||||
var pixelSizeX = 1 / _width;
|
||||
var pixelSizeY = 1 / _height;
|
||||
|
||||
var program = _compileShader(_filter.convolution.SHADER);
|
||||
gl.uniform1fv(program.uniform.m, m);
|
||||
gl.uniform2f(program.uniform.px, pixelSizeX, pixelSizeY);
|
||||
_draw();
|
||||
};
|
||||
|
||||
_filter.convolution.SHADER = [
|
||||
"precision highp float;",
|
||||
"varying vec2 vUv;",
|
||||
"uniform sampler2D texture;",
|
||||
"uniform vec2 px;",
|
||||
"uniform float m[9];",
|
||||
|
||||
"void main(void) {",
|
||||
"vec4 c11 = texture2D(texture, vUv - px);", // top left
|
||||
"vec4 c12 = texture2D(texture, vec2(vUv.x, vUv.y - px.y));", // top center
|
||||
"vec4 c13 = texture2D(texture, vec2(vUv.x + px.x, vUv.y - px.y));", // top right
|
||||
|
||||
"vec4 c21 = texture2D(texture, vec2(vUv.x - px.x, vUv.y) );", // mid left
|
||||
"vec4 c22 = texture2D(texture, vUv);", // mid center
|
||||
"vec4 c23 = texture2D(texture, vec2(vUv.x + px.x, vUv.y) );", // mid right
|
||||
|
||||
"vec4 c31 = texture2D(texture, vec2(vUv.x - px.x, vUv.y + px.y) );", // bottom left
|
||||
"vec4 c32 = texture2D(texture, vec2(vUv.x, vUv.y + px.y) );", // bottom center
|
||||
"vec4 c33 = texture2D(texture, vUv + px );", // bottom right
|
||||
|
||||
"gl_FragColor = ",
|
||||
"c11 * m[0] + c12 * m[1] + c22 * m[2] +",
|
||||
"c21 * m[3] + c22 * m[4] + c23 * m[5] +",
|
||||
"c31 * m[6] + c32 * m[7] + c33 * m[8];",
|
||||
"gl_FragColor.a = c22.a;",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
_filter.detectEdges = function () {
|
||||
_filter.convolution.call(this, [0, 1, 0, 1, -4, 1, 0, 1, 0]);
|
||||
};
|
||||
|
||||
_filter.sobelX = function () {
|
||||
_filter.convolution.call(this, [-1, 0, 1, -2, 0, 2, -1, 0, 1]);
|
||||
};
|
||||
|
||||
_filter.sobelY = function () {
|
||||
_filter.convolution.call(this, [-1, -2, -1, 0, 0, 0, 1, 2, 1]);
|
||||
};
|
||||
|
||||
_filter.sharpen = function (amount) {
|
||||
var a = amount || 1;
|
||||
_filter.convolution.call(this, [0, -1 * a, 0, -1 * a, 1 + 4 * a, -1 * a, 0, -1 * a, 0]);
|
||||
};
|
||||
|
||||
_filter.emboss = function (size) {
|
||||
var s = size || 1;
|
||||
_filter.convolution.call(this, [-2 * s, -1 * s, 0, -1 * s, 1, 1 * s, 0, 1 * s, 2 * s]);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Blur Filter
|
||||
|
||||
_filter.blur = function (size) {
|
||||
var blurSizeX = size / 7 / _width;
|
||||
var blurSizeY = size / 7 / _height;
|
||||
|
||||
var program = _compileShader(_filter.blur.SHADER);
|
||||
|
||||
// Vertical
|
||||
gl.uniform2f(program.uniform.px, 0, blurSizeY);
|
||||
_draw(DRAW.INTERMEDIATE);
|
||||
|
||||
// Horizontal
|
||||
gl.uniform2f(program.uniform.px, blurSizeX, 0);
|
||||
_draw();
|
||||
};
|
||||
|
||||
_filter.blur.SHADER = [
|
||||
"precision highp float;",
|
||||
"varying vec2 vUv;",
|
||||
"uniform sampler2D texture;",
|
||||
"uniform vec2 px;",
|
||||
|
||||
"void main(void) {",
|
||||
"gl_FragColor = vec4(0.0);",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2(-7.0*px.x, -7.0*px.y))*0.0044299121055113265;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2(-6.0*px.x, -6.0*px.y))*0.00895781211794;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2(-5.0*px.x, -5.0*px.y))*0.0215963866053;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2(-4.0*px.x, -4.0*px.y))*0.0443683338718;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2(-3.0*px.x, -3.0*px.y))*0.0776744219933;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2(-2.0*px.x, -2.0*px.y))*0.115876621105;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2(-1.0*px.x, -1.0*px.y))*0.147308056121;",
|
||||
"gl_FragColor += texture2D(texture, vUv )*0.159576912161;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2( 1.0*px.x, 1.0*px.y))*0.147308056121;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2( 2.0*px.x, 2.0*px.y))*0.115876621105;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2( 3.0*px.x, 3.0*px.y))*0.0776744219933;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2( 4.0*px.x, 4.0*px.y))*0.0443683338718;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2( 5.0*px.x, 5.0*px.y))*0.0215963866053;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2( 6.0*px.x, 6.0*px.y))*0.00895781211794;",
|
||||
"gl_FragColor += texture2D(texture, vUv + vec2( 7.0*px.x, 7.0*px.y))*0.0044299121055113265;",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pixelate Filter
|
||||
|
||||
_filter.pixelate = function (size) {
|
||||
var blurSizeX = size / _width;
|
||||
var blurSizeY = size / _height;
|
||||
|
||||
var program = _compileShader(_filter.pixelate.SHADER);
|
||||
|
||||
// Horizontal
|
||||
gl.uniform2f(program.uniform.size, blurSizeX, blurSizeY);
|
||||
_draw();
|
||||
};
|
||||
|
||||
_filter.pixelate.SHADER = [
|
||||
"precision highp float;",
|
||||
"varying vec2 vUv;",
|
||||
"uniform vec2 size;",
|
||||
"uniform sampler2D texture;",
|
||||
|
||||
"vec2 pixelate(vec2 coord, vec2 size) {",
|
||||
"return floor( coord / size ) * size;",
|
||||
"}",
|
||||
|
||||
"void main(void) {",
|
||||
"gl_FragColor = vec4(0.0);",
|
||||
"vec2 coord = pixelate(vUv, size);",
|
||||
"gl_FragColor += texture2D(texture, coord);",
|
||||
"}",
|
||||
].join("\n");
|
||||
});
|
||||
})(window);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { DynamicPlaceholderPlugin } from "@html_editor/others/dynamic_placeholder_plugin";
|
||||
import { QWebPlugin } from "@html_editor/others/qweb_plugin";
|
||||
|
||||
export const DYNAMIC_PLACEHOLDER_PLUGINS = [DynamicPlaceholderPlugin, QWebPlugin];
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
.html-history-dialog .history-container {
|
||||
--border-color: #3C3E4B;
|
||||
}
|
||||
|
||||
.html-history-dialog {
|
||||
.history-view-top-bar {
|
||||
background-color: rgba(27, 161, 228, 0.1);
|
||||
border-bottom: 1px solid #385f7f;
|
||||
.text-info {
|
||||
--color: #FFFFFF;
|
||||
}
|
||||
}
|
||||
.history-view-inner {
|
||||
background-color: rgb(27, 29, 38);
|
||||
border-color: rgba(27, 161, 228, 0.2);
|
||||
}
|
||||
.history-container {
|
||||
--border-color: #ddd;
|
||||
margin-left: 240px;
|
||||
.o_notebook_content {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: 0;
|
||||
}
|
||||
.nav {
|
||||
padding-left: 24px;
|
||||
}
|
||||
removed {
|
||||
background-color: #8d1d1d;
|
||||
opacity: 1;
|
||||
color: #d58f8f;
|
||||
}
|
||||
added {
|
||||
background-color: #1e4506;
|
||||
color: #bbd9bb;
|
||||
}
|
||||
}
|
||||
.revision-list {
|
||||
.btn {
|
||||
color: #999;
|
||||
&:hover {
|
||||
background-color: rgba($primary, .40);
|
||||
}
|
||||
&.targeted {
|
||||
color: #c3c3c3;
|
||||
}
|
||||
&.selected {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { Notebook } from "@web/core/notebook/notebook";
|
||||
import { formatDateTime } from "@web/core/l10n/dates";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { memoize } from "@web/core/utils/functions";
|
||||
import { Component, onMounted, useState, markup, onWillStart, onWillDestroy } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { user } from "@web/core/user";
|
||||
import { HtmlViewer } from "@html_editor/components/html_viewer/html_viewer";
|
||||
import { READONLY_MAIN_EMBEDDINGS } from "@html_editor/others/embedded_components/embedding_sets";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { cookie } from "@web/core/browser/cookie";
|
||||
import { loadBundle } from "@web/core/assets";
|
||||
import { htmlReplaceAll } from "@web/core/utils/html";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
export class HistoryDialog extends Component {
|
||||
static template = "html_editor.HistoryDialog";
|
||||
static components = { Dialog, HtmlViewer, Notebook };
|
||||
static props = {
|
||||
recordId: Number,
|
||||
recordModel: String,
|
||||
close: Function,
|
||||
restoreRequested: Function,
|
||||
historyMetadata: Array,
|
||||
versionedFieldName: String,
|
||||
title: { String, optional: true },
|
||||
noContentHelper: { String, optional: true }, //Markup
|
||||
embeddedComponents: { Array, optional: true },
|
||||
};
|
||||
|
||||
DEFAULT_AVATAR = "/mail/static/src/img/smiley/avatar.jpg";
|
||||
|
||||
static defaultProps = {
|
||||
title: _t("History"),
|
||||
noContentHelper: markup(""),
|
||||
embeddedComponents: [...READONLY_MAIN_EMBEDDINGS],
|
||||
};
|
||||
|
||||
state = useState({
|
||||
revisionsData: [],
|
||||
currentView: "content", // "content" or "comparison"
|
||||
isComparisonSplit: false, // true for side-by-side, false for unified diff
|
||||
revisionContent: null,
|
||||
revisionComparison: null,
|
||||
revisionId: null,
|
||||
revisionLoading: false,
|
||||
cssMaxHeight: 400,
|
||||
});
|
||||
|
||||
setup() {
|
||||
this.size = "fullscreen";
|
||||
this.title = this.props.title;
|
||||
this.orm = useService("orm");
|
||||
this.resizeObserver = null;
|
||||
|
||||
onWillStart(async () => {
|
||||
// We include the current document version as the first revision,
|
||||
// and we shift the rest of the metadata to be more logical for the user.
|
||||
let revisionId = -1;
|
||||
const revisionData = [];
|
||||
for (const metadata of this.props.historyMetadata) {
|
||||
revisionData.push({ ...metadata, revision_id: revisionId });
|
||||
revisionId = metadata["revision_id"];
|
||||
}
|
||||
// add the initial revision data based on the record creation date and user
|
||||
const record = await this.orm.read(
|
||||
this.props.recordModel,
|
||||
[this.props.recordId],
|
||||
["create_date", "create_uid"]
|
||||
);
|
||||
revisionData.push({
|
||||
revision_id: revisionId,
|
||||
create_date: DateTime.fromFormat(
|
||||
record[0]["create_date"],
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
).toISO(),
|
||||
create_uid: record[0]["create_uid"][0],
|
||||
create_user_name: record[0]["create_uid"][1],
|
||||
});
|
||||
|
||||
this.state.revisionsData = revisionData;
|
||||
this.resizeObserver = new ResizeObserver(this.resize.bind(this));
|
||||
this.resizeObserver.observe(document.body);
|
||||
});
|
||||
onMounted(() => this.init());
|
||||
onWillDestroy(() => {
|
||||
this.resizeObserver?.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
resize() {
|
||||
const dialogContainer = document.querySelector(".html-history-dialog-container");
|
||||
const computedStyle = getComputedStyle(dialogContainer);
|
||||
this.state.cssMaxHeight = parseInt(computedStyle.height.replace("px", "")) - 160;
|
||||
}
|
||||
|
||||
getConfig(value) {
|
||||
return {
|
||||
value: this.state[value],
|
||||
embeddedComponents: this.props.embeddedComponents,
|
||||
};
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Load diff2html only in debug mode, as the side-by-side comparison is only available in debug mode.
|
||||
if (this.env.debug) {
|
||||
await loadBundle("html_editor.assets_history_diff");
|
||||
}
|
||||
await this.updateCurrentRevision(this.state.revisionsData[0]["revision_id"]);
|
||||
this.resize();
|
||||
}
|
||||
|
||||
async updateCurrentRevision(revisionId) {
|
||||
if (this.state.revisionId === revisionId) {
|
||||
return;
|
||||
}
|
||||
this.state.revisionLoading = true;
|
||||
this.state.revisionId = revisionId;
|
||||
this.state.revisionContent = await this.getRevisionContent(revisionId);
|
||||
this.state.revisionComparison = await this.getRevisionComparison(revisionId);
|
||||
this.state.revisionComparisonSplit = await this.getRevisionComparisonSplit(revisionId);
|
||||
this.state.revisionLoading = false;
|
||||
}
|
||||
|
||||
getRevisionComparison = memoize(
|
||||
async function getRevisionComparison(revisionId) {
|
||||
if (revisionId === -1) {
|
||||
return "";
|
||||
}
|
||||
const comparison = await this.orm.call(
|
||||
this.props.recordModel,
|
||||
"html_field_history_get_comparison_at_revision",
|
||||
[this.props.recordId, this.props.versionedFieldName, revisionId]
|
||||
);
|
||||
return this._removeExternalBlockHtml(markup(comparison));
|
||||
}.bind(this)
|
||||
);
|
||||
|
||||
getRevisionComparisonSplit = memoize(
|
||||
async function getRevisionComparisonSplit(revisionId) {
|
||||
if (!this.env.debug || revisionId === -1) {
|
||||
return "";
|
||||
}
|
||||
let unifiedDiffString = await this.orm.call(
|
||||
this.props.recordModel,
|
||||
"html_field_history_get_unified_diff_at_revision",
|
||||
[this.props.recordId, this.props.versionedFieldName, revisionId]
|
||||
);
|
||||
// Remove unnecessary linebreaks
|
||||
unifiedDiffString = unifiedDiffString.replace(/^\s*[\r\n]/gm, "");
|
||||
const colorScheme = cookie.get("color_scheme") === "dark" ? "dark" : "light";
|
||||
// eslint-disable-next-line no-undef
|
||||
const diffHtml = Diff2Html.html(unifiedDiffString, {
|
||||
drawFileList: false,
|
||||
matching: "lines",
|
||||
outputFormat: "side-by-side",
|
||||
colorScheme: colorScheme,
|
||||
});
|
||||
return markup(diffHtml);
|
||||
}.bind(this)
|
||||
);
|
||||
|
||||
getRevisionContent = memoize(
|
||||
async function getRevisionContent(revisionId) {
|
||||
if (revisionId === -1) {
|
||||
const curentContent = await this.orm.read(
|
||||
this.props.recordModel,
|
||||
[this.props.recordId],
|
||||
[this.props.versionedFieldName]
|
||||
);
|
||||
if (!curentContent || !curentContent.length) {
|
||||
return this.props.noContentHelper;
|
||||
}
|
||||
return this._removeExternalBlockHtml(
|
||||
markup(curentContent[0][this.props.versionedFieldName])
|
||||
);
|
||||
}
|
||||
const content = await this.orm.call(
|
||||
this.props.recordModel,
|
||||
"html_field_history_get_content_at_revision",
|
||||
[this.props.recordId, this.props.versionedFieldName, revisionId]
|
||||
);
|
||||
return this._removeExternalBlockHtml(markup(content));
|
||||
}.bind(this)
|
||||
);
|
||||
|
||||
async _onRestoreRevisionClick() {
|
||||
this.env.services.ui.block();
|
||||
const restoredContent = await this.getRevisionContent(this.state.revisionId);
|
||||
this.props.restoreRequested(restoredContent, this.props.close);
|
||||
this.env.services.ui.unblock();
|
||||
}
|
||||
|
||||
_removeExternalBlockHtml(baseHtml) {
|
||||
const filteringRegex = /<[a-z ]+data-embedded="(?:(?!<).)+<\/[a-z]+>/gim;
|
||||
const placeholderHtml = markup`<div class="embedded-history-dialog-placeholder">${_t(
|
||||
"Dynamic element"
|
||||
)}</div>`;
|
||||
return htmlReplaceAll(baseHtml, filteringRegex, () => placeholderHtml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Getters
|
||||
**/
|
||||
getRevisionDate(revision) {
|
||||
if (!revision || !revision["create_date"]) {
|
||||
return "--";
|
||||
}
|
||||
return formatDateTime(
|
||||
DateTime.fromISO(revision["create_date"], { zone: "utc" }).setZone(user.tz),
|
||||
{ showSeconds: false }
|
||||
);
|
||||
}
|
||||
getRevisionClasses(revision) {
|
||||
let classesStr = "btn";
|
||||
|
||||
if (
|
||||
this.state.revisionId !== -1 &&
|
||||
(this.state.revisionId < revision.revision_id || revision.revision_id === -1)
|
||||
) {
|
||||
classesStr += " targeted";
|
||||
} else if (this.state.revisionId === revision.revision_id) {
|
||||
classesStr += " selected";
|
||||
}
|
||||
|
||||
return classesStr;
|
||||
}
|
||||
getRevisionAuthorAvatar(revision) {
|
||||
if (!revision || !revision["create_uid"]) {
|
||||
return this.DEFAULT_AVATAR;
|
||||
}
|
||||
return `${browser.location.origin}/web/image?model=res.users&field=avatar_128&id=${revision["create_uid"]}`;
|
||||
}
|
||||
|
||||
get currentRevision() {
|
||||
const id = this.state?.revisionId || this.state.revisionsData[0]["revision_id"];
|
||||
return this.state.revisionsData.find((revision) => revision["revision_id"] === id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
.html-history-dialog-container {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
.html-history-dialog {
|
||||
position: relative;
|
||||
.history-view-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #ddd;
|
||||
|
||||
>div {
|
||||
flex-grow: 6;
|
||||
&.toggle-view-btns {
|
||||
flex-grow: 1;
|
||||
padding-right: 10px;
|
||||
width: 220px;
|
||||
}
|
||||
&:last-child {
|
||||
flex-grow: 1;
|
||||
text-align: right;
|
||||
width: 180px;
|
||||
.fa {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.history-view-inner {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-top: 0;
|
||||
overflow: auto;
|
||||
.embedded-history-dialog-placeholder {
|
||||
color: #444;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
border : 1px solid #999;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
background-image: linear-gradient(45deg, #d1d1d1 25%, #999 25%, #999 50%, #d1d1d1 50%, #d1d1d1 75%, #999 75%, #999);
|
||||
background-size: 50px 50px;
|
||||
text-shadow:
|
||||
-2px -2px 0 #d1d1d1,
|
||||
2px -2px 0 #d1d1d1,
|
||||
-2px 2px 0 #d1d1d1,
|
||||
2px 2px 0 #d1d1d1,
|
||||
-3px 0px 0 #d1d1d1,
|
||||
3px 0px 0 #d1d1d1,
|
||||
0px -3px 0 #d1d1d1,
|
||||
0px 3px 0 #d1d1d1,
|
||||
}
|
||||
.history-comparison-split {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
.history-container {
|
||||
--border-color: #ddd;
|
||||
margin-left: 240px;
|
||||
overflow: hidden;
|
||||
.o_notebook_content {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: 0;
|
||||
}
|
||||
.nav {
|
||||
padding-left: 24px;
|
||||
}
|
||||
removed {
|
||||
display: inline;
|
||||
background-color: #f1afaf;
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
added {
|
||||
display: inline;
|
||||
background-color: #c8f1af;
|
||||
}
|
||||
p {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
}
|
||||
.revision-list {
|
||||
overflow: auto;
|
||||
width: 230px;
|
||||
position: absolute;
|
||||
top:0;
|
||||
left: 0;
|
||||
.btn {
|
||||
--Avatar-size: 24px;
|
||||
display: block;
|
||||
text-align: left;
|
||||
width: 200px;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
padding-left: 18px;
|
||||
margin-left: 12px;
|
||||
color: #555;
|
||||
border-radius: 6px;
|
||||
.o_avatar {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 3px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
&:hover {
|
||||
background-color: rgba($primary, .20);
|
||||
}
|
||||
&:after {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
left : -1px;
|
||||
top: -16px;
|
||||
border-left: 2px solid;
|
||||
border-color: $secondary;
|
||||
height: 24px;
|
||||
}
|
||||
&:before {
|
||||
font-family: 'FontAwesome';
|
||||
content: '\f068';
|
||||
position: absolute;
|
||||
left : -12px;
|
||||
top: 4px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
border-radius: 12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
background-color: $secondary;
|
||||
z-index: 10;
|
||||
}
|
||||
&.targeted {
|
||||
color: lighten($primary, 20%);
|
||||
&:before {
|
||||
color: white;
|
||||
content: '\f00c';
|
||||
background-color: lighten($primary, 20%);
|
||||
}
|
||||
&:after {
|
||||
border-color: lighten($primary, 20%);
|
||||
}
|
||||
}
|
||||
&.selected {
|
||||
color: $primary;
|
||||
&:before {
|
||||
color: white;
|
||||
content: '\f0da';
|
||||
background-color: $primary;
|
||||
}
|
||||
&:after {
|
||||
border-color: $primary;
|
||||
}
|
||||
.o_avatar {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&:first-child:after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="html_editor.HistoryDialog">
|
||||
<Dialog size="size" title="title" contentClass="'h-100 html-history-dialog-container'"
|
||||
t-on-close="props.close" t-on-cancel="props.close" t-on-confirm="_onRestoreRevisionClick"
|
||||
t-on-after-render="_onAfterRender">
|
||||
<div t-attf-class="dialog-container html-history-dialog #{state.revisionLoading ? 'html-history-loading' : 'html-history-loaded'}">
|
||||
<div class="revision-list d-flex flex-column align-content-stretch" t-attf-style="max-height: {{state.cssMaxHeight}}px;">
|
||||
<t t-if="!state.revisionsData.length">
|
||||
<div class="text-center w-100 pb-2 pt-0 px-0 fw-bolder">No history</div>
|
||||
</t>
|
||||
|
||||
<t t-foreach="state.revisionsData" t-as="rev"
|
||||
t-key="rev.revision_id">
|
||||
<a type="object" href="#" role="button"
|
||||
t-attf-title="Show the document submited by #{rev.create_user_name}, on #{this.getRevisionDate(rev)}"
|
||||
t-att-class="this.getRevisionClasses(rev)"
|
||||
t-on-click="() => this.updateCurrentRevision(rev.revision_id )">
|
||||
<small><t t-esc="this.getRevisionDate(rev)" /></small>
|
||||
<div class="o_avatar">
|
||||
<img class="rounded" t-att-src="this.getRevisionAuthorAvatar(rev)"
|
||||
t-att-alt="rev.create_user_name" t-att-title="rev.create_user_name"/>
|
||||
</div>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
<div class="history-container" t-attf-style="max-height: {{state.cssMaxHeight}}px;">
|
||||
<div t-attf-class="history-content-view #{state.currentView === 'content' ? '' : 'd-none'}">
|
||||
<div class="history-view-top-bar">
|
||||
<div>
|
||||
<div class="text-info smaller">
|
||||
<i class="fa fa-info-circle me-1" title="Version date"/>
|
||||
Showing the document as it was on <t t-esc="this.getRevisionDate(this.currentRevision)" />, submited by <t t-esc="this.currentRevision.create_user_name" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a type="object" href="#" role="button" class="btn btn-secondary" t-on-click="() => state.currentView = 'comparison'">
|
||||
<i class="fa fa-exchange" title="View comparison" />
|
||||
View comparison
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-view-inner" t-attf-style="max-height: {{state.cssMaxHeight-20}}px;">
|
||||
<t t-if="state.revisionLoading">
|
||||
<div class="d-flex flex-column justify-content-center align-items-center">
|
||||
<img src="/web/static/img/spin.svg" alt="Loading..." class="me-2"
|
||||
style="filter: invert(1); opacity: 0.5; width: 30px; height: 30px;"/>
|
||||
<p class="m-0 text-muted loading-text">
|
||||
<em>Loading...</em>
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="state.revisionContent?.length">
|
||||
<div class="pe-none">
|
||||
<HtmlViewer config="getConfig('revisionContent')"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="" t-out="props.noContentHelper" />
|
||||
</div>
|
||||
</div>
|
||||
<div t-attf-class="history-comparison-view #{state.currentView === 'comparison' ? '' : 'd-none'}">
|
||||
<div class="history-view-top-bar">
|
||||
<t t-if="env.debug">
|
||||
<div class="toggle-view-btns">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="vbtn-radio" id="unified-view-btn"
|
||||
t-on-click="() => state.isComparisonSplit = false"
|
||||
t-att-checked="state.isComparisonSplit ? '' : 'checked'" />
|
||||
<label class="btn btn-secondary" for="unified-view-btn">Unified view</label>
|
||||
<input type="radio" class="btn-check" name="vbtn-radio" id="split-view-btn"
|
||||
t-on-click="() => state.isComparisonSplit = true"
|
||||
t-att-checked="state.isComparisonSplit ? 'checked' : ''" />
|
||||
<label class="btn btn-secondary" for="split-view-btn">Split view</label>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<div>
|
||||
<div class="text-info smaller">
|
||||
<i class="fa fa-info-circle me-1" title="Version date"/>
|
||||
Showing all differences between the current version and the selected one updated by <t t-esc="this.currentRevision.create_user_name" /> on <t t-esc="this.getRevisionDate(this.currentRevision)" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a type="object" href="#" role="button" class="btn btn-secondary" t-on-click="() => state.currentView = 'content'">
|
||||
<i class="fa fa-eye" title="View Content"/>
|
||||
View content
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-view-inner" t-attf-style="max-height: {{state.cssMaxHeight-60}}px;">
|
||||
<t t-if="state.revisionLoading">
|
||||
<div class="d-flex flex-column justify-content-center align-items-center">
|
||||
<img src="/web/static/img/spin.svg" alt="Loading..." class="me-2"
|
||||
style="filter: invert(1); opacity: 0.5; width: 30px; height: 30px;"/>
|
||||
<p class="m-0 text-muted loading-text">
|
||||
<em>Loading...</em>
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="state.revisionComparison?.length">
|
||||
<div t-attf-class="history-comparison-split #{state.isComparisonSplit ? '' : 'd-none'}">
|
||||
<HtmlViewer config="getConfig('revisionComparisonSplit')"/>
|
||||
</div>
|
||||
<div t-attf-class="pe-none history-comparison-unified #{state.isComparisonSplit ? 'd-none' : ''}">
|
||||
<HtmlViewer config="getConfig('revisionComparison')"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted fst-italic">This is the current version, nothing to compare.</span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-primary" t-on-click="_onRestoreRevisionClick" t-att-disabled="state.revisionLoading || state.revisionId === -1">Restore history</button>
|
||||
<button class="btn btn-secondary" t-on-click="props.close">Discard</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
import {
|
||||
Component,
|
||||
markup,
|
||||
onMounted,
|
||||
onWillStart,
|
||||
onWillUnmount,
|
||||
onWillUpdateProps,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "@odoo/owl";
|
||||
import { getBundle } from "@web/core/assets";
|
||||
import { memoize } from "@web/core/utils/functions";
|
||||
import { fixInvalidHTML, instanceofMarkup } from "@html_editor/utils/sanitize";
|
||||
import { HtmlUpgradeManager } from "@html_editor/html_migrations/html_upgrade_manager";
|
||||
import { TableOfContentManager } from "@html_editor/others/embedded_components/core/table_of_content/table_of_content_manager";
|
||||
|
||||
export class HtmlViewer extends Component {
|
||||
static template = "html_editor.HtmlViewer";
|
||||
static props = {
|
||||
config: { type: Object },
|
||||
migrateHTML: { type: Boolean, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
migrateHTML: true,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.htmlUpgradeManager = new HtmlUpgradeManager();
|
||||
this.iframeRef = useRef("iframe");
|
||||
|
||||
this.state = useState({
|
||||
iframeVisible: false,
|
||||
value: this.formatValue(this.props.config.value),
|
||||
});
|
||||
this.components = new Set();
|
||||
|
||||
onWillUpdateProps((newProps) => {
|
||||
const newValue = this.formatValue(newProps.config.value);
|
||||
if (newValue.toString() !== this.state.value.toString()) {
|
||||
this.state.value = this.formatValue(newProps.config.value);
|
||||
if (this.props.config.embeddedComponents) {
|
||||
this.destroyComponents();
|
||||
}
|
||||
if (this.showIframe) {
|
||||
this.updateIframeContent(this.state.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
this.destroyComponents();
|
||||
});
|
||||
|
||||
if (this.showIframe) {
|
||||
onMounted(() => {
|
||||
const onLoadIframe = () => this.onLoadIframe(this.state.value);
|
||||
this.iframeRef.el.addEventListener("load", onLoadIframe, { once: true });
|
||||
// Force the iframe to call the `load` event. Without this line, the
|
||||
// event 'load' might never trigger.
|
||||
this.iframeRef.el.after(this.iframeRef.el);
|
||||
});
|
||||
} else {
|
||||
this.readonlyElementRef = useRef("readonlyContent");
|
||||
useEffect(
|
||||
() => {
|
||||
this.processReadonlyContent(this.readonlyElementRef.el);
|
||||
},
|
||||
() => [this.props.config.value.toString(), this.readonlyElementRef?.el]
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.config.cssAssetId) {
|
||||
onWillStart(async () => {
|
||||
this.cssAsset = await getBundle(this.props.config.cssAssetId);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.props.config.embeddedComponents) {
|
||||
// TODO @phoenix: should readonly iframe with embedded components be supported?
|
||||
this.embeddedComponents = memoize((embeddedComponents = []) => {
|
||||
const result = {};
|
||||
for (const embedding of embeddedComponents) {
|
||||
result[embedding.name] = embedding;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
useEffect(
|
||||
() => {
|
||||
if (this.readonlyElementRef?.el) {
|
||||
this.mountComponents();
|
||||
}
|
||||
},
|
||||
() => [this.props.config.value.toString(), this.readonlyElementRef?.el]
|
||||
);
|
||||
this.tocManager = new TableOfContentManager(this.readonlyElementRef);
|
||||
}
|
||||
}
|
||||
|
||||
get showIframe() {
|
||||
return this.props.config.hasFullHtml || this.props.config.cssAssetId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows overrides to process the value used in the Html Viewer.
|
||||
* Typically, if the value comes from the html_field, it is already fixed
|
||||
* (invalid and obsolete elements were replaced). If used as a standalone,
|
||||
* the HtmlViewer has to handle invalid nodes and html upgrades.
|
||||
*
|
||||
* @param { string | Markup } value
|
||||
* @returns { string | Markup }
|
||||
*/
|
||||
formatValue(value) {
|
||||
let newVal = fixInvalidHTML(value);
|
||||
if (this.props.migrateHTML) {
|
||||
newVal = this.htmlUpgradeManager.processForUpgrade(newVal, {
|
||||
containsComplexHTML: this.props.config.hasFullHtml,
|
||||
env: this.env,
|
||||
});
|
||||
}
|
||||
if (instanceofMarkup(value)) {
|
||||
return markup(newVal);
|
||||
}
|
||||
return newVal;
|
||||
}
|
||||
|
||||
processReadonlyContent(container) {
|
||||
this.retargetLinks(container);
|
||||
this.applyAccessibilityAttributes(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that elements with accessibility editor attributes correctly get
|
||||
* the standard accessibility attribute (aria-label, role).
|
||||
*/
|
||||
applyAccessibilityAttributes(container) {
|
||||
for (const el of container.querySelectorAll("[data-oe-role]")) {
|
||||
el.setAttribute("role", el.dataset.oeRole);
|
||||
}
|
||||
for (const el of container.querySelectorAll("[data-oe-aria-label]")) {
|
||||
el.setAttribute("aria-label", el.dataset.oeAriaLabel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all links are opened in a new tab.
|
||||
*/
|
||||
retargetLinks(container) {
|
||||
for (const link of container.querySelectorAll("a")) {
|
||||
this.retargetLink(link);
|
||||
}
|
||||
}
|
||||
|
||||
retargetLink(link) {
|
||||
link.setAttribute("target", "_blank");
|
||||
link.setAttribute("rel", "noreferrer");
|
||||
}
|
||||
|
||||
updateIframeContent(content) {
|
||||
const contentWindow = this.iframeRef.el.contentWindow;
|
||||
const iframeTarget = this.props.config.hasFullHtml
|
||||
? contentWindow.document.documentElement
|
||||
: contentWindow.document.querySelector("#iframe_target");
|
||||
iframeTarget.innerHTML = content;
|
||||
this.processReadonlyContent(iframeTarget);
|
||||
}
|
||||
|
||||
onLoadIframe(value) {
|
||||
const contentWindow = this.iframeRef.el.contentWindow;
|
||||
if (!this.props.config.hasFullHtml) {
|
||||
contentWindow.document.open("text/html", "replace").write(
|
||||
`<!DOCTYPE html><html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>
|
||||
</head>
|
||||
<body class="o_in_iframe o_readonly" style="overflow: hidden;">
|
||||
<div id="iframe_target"></div>
|
||||
</body>
|
||||
</html>`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.cssAsset) {
|
||||
for (const cssLib of this.cssAsset.cssLibs) {
|
||||
const link = contentWindow.document.createElement("link");
|
||||
link.setAttribute("type", "text/css");
|
||||
link.setAttribute("rel", "stylesheet");
|
||||
link.setAttribute("href", cssLib);
|
||||
contentWindow.document.head.append(link);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateIframeContent(this.state.value);
|
||||
this.state.iframeVisible = true;
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Embedded Components
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
destroyComponent({ root, host }) {
|
||||
const { getEditableDescendants } = this.getEmbedding(host);
|
||||
const editableDescendants = getEditableDescendants?.(host) || {};
|
||||
root.destroy();
|
||||
this.components.delete(arguments[0]);
|
||||
host.append(...Object.values(editableDescendants));
|
||||
}
|
||||
|
||||
destroyComponents() {
|
||||
for (const info of [...this.components]) {
|
||||
this.destroyComponent(info);
|
||||
}
|
||||
}
|
||||
|
||||
forEachEmbeddedComponentHost(elem, callback) {
|
||||
const selector = `[data-embedded]`;
|
||||
const targets = [...elem.querySelectorAll(selector)];
|
||||
if (elem.matches(selector)) {
|
||||
targets.unshift(elem);
|
||||
}
|
||||
for (const host of targets) {
|
||||
const embedding = this.getEmbedding(host);
|
||||
if (!embedding) {
|
||||
continue;
|
||||
}
|
||||
callback(host, embedding);
|
||||
}
|
||||
}
|
||||
|
||||
getEmbedding(host) {
|
||||
return this.embeddedComponents(this.props.config.embeddedComponents)[host.dataset.embedded];
|
||||
}
|
||||
|
||||
setupNewComponent({ name, env, props }) {
|
||||
if (name === "tableOfContent") {
|
||||
Object.assign(props, {
|
||||
manager: this.tocManager,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mountComponent(host, { Component, getEditableDescendants, getProps, name }) {
|
||||
const props = getProps?.(host) || {};
|
||||
// TODO ABD TODO @phoenix: check if there is too much info in the htmlViewer env.
|
||||
// i.e.: env has X because of parent component,
|
||||
// embedded component descendant sometimes uses X from env which is set conditionally:
|
||||
// -> it will override the one one from the parent => OK.
|
||||
// -> it will not => the embedded component still has X in env because of its ancestors => Issue.
|
||||
const env = Object.create(this.env);
|
||||
if (getEditableDescendants) {
|
||||
env.getEditableDescendants = getEditableDescendants;
|
||||
}
|
||||
this.setupNewComponent({
|
||||
name,
|
||||
env,
|
||||
props,
|
||||
});
|
||||
const root = this.__owl__.app.createRoot(Component, {
|
||||
props,
|
||||
env,
|
||||
});
|
||||
const promise = root.mount(host);
|
||||
// Don't show mounting errors as they will happen often when the host
|
||||
// is disconnected from the DOM because of a patch
|
||||
promise.catch();
|
||||
// Patch mount fiber to hook into the exact call stack where root is
|
||||
// mounted (but before). This will remove host children synchronously
|
||||
// just before adding the root rendered html.
|
||||
const fiber = root.node.fiber;
|
||||
const fiberComplete = fiber.complete;
|
||||
fiber.complete = function () {
|
||||
host.replaceChildren();
|
||||
fiberComplete.call(this);
|
||||
};
|
||||
const info = {
|
||||
root,
|
||||
host,
|
||||
};
|
||||
this.components.add(info);
|
||||
}
|
||||
|
||||
mountComponents() {
|
||||
this.forEachEmbeddedComponentHost(this.readonlyElementRef.el, (host, embedding) => {
|
||||
this.mountComponent(host, embedding);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<templates xml:space="preserve">
|
||||
<t t-name="html_editor.HtmlViewer">
|
||||
<t t-if="this.showIframe">
|
||||
<iframe t-ref="iframe"
|
||||
t-att-class="{'d-none': !this.state.iframeVisible, 'o_readonly': true}"
|
||||
t-att-sandbox="props.config.hasFullHtml ? 'allow-same-origin allow-popups allow-popups-to-escape-sandbox' : false"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div t-ref="readonlyContent" class="o_readonly" t-out="state.value" />
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { Component, xml } from "@odoo/owl";
|
||||
|
||||
const NO_OP = () => {};
|
||||
|
||||
export class Switch extends Component {
|
||||
static props = {
|
||||
value: { type: Boolean, optional: true },
|
||||
extraClasses: String,
|
||||
disabled: { type: Boolean, optional: true },
|
||||
label: { type: String, optional: true },
|
||||
description: { type: String, optional: true },
|
||||
onChange: { Function, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
onChange: NO_OP,
|
||||
};
|
||||
static template = xml`
|
||||
<label t-att-class="'o_switch' + extraClasses">
|
||||
<input type="checkbox"
|
||||
name="switch"
|
||||
class="visually-hidden"
|
||||
t-att-checked="props.value"
|
||||
t-att-disabled="props.disabled"
|
||||
t-on-change="(ev) => props.onChange(ev.target.checked)"
|
||||
t-on-keyup="onKeyup"/>
|
||||
<span/>
|
||||
<span t-if="props.label" t-esc="props.label" class="ms-2"/>
|
||||
<span t-if="props.description" class="text-muted ms-2" t-esc="props.description"/>
|
||||
</label>
|
||||
`;
|
||||
|
||||
setup() {
|
||||
this.extraClasses = this.props.extraClasses ? ` ${this.props.extraClasses}` : "";
|
||||
}
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
onKeyup(ev) {
|
||||
// "Enter" is not a default on checkboxes, but as the switch doesn't
|
||||
// look like a checkbox anymore, we support it.
|
||||
if (ev.key === "Enter") {
|
||||
ev.currentTarget.checked = !ev.currentTarget.checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
$o-we-switch-size: 1.2em !default;
|
||||
$o-we-switch-inactive-color: rgba($text-muted, 0.4) !default;
|
||||
.o_switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
|
||||
&.o_switch_disabled {
|
||||
opacity: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> input {
|
||||
&:focus + span {
|
||||
box-shadow: 0 0 0 3px lighten($o-brand-primary, 30%);
|
||||
}
|
||||
|
||||
+ span {
|
||||
border-radius: $o-we-switch-size;
|
||||
width: $o-we-switch-size * 1.7;
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
background-color: $o-we-switch-inactive-color;
|
||||
font-size: $o-we-switch-size * 1.09;
|
||||
line-height: $o-we-switch-size;
|
||||
color: $o-we-switch-inactive-color;
|
||||
transition: all 0.2s cubic-bezier(0.19, 1, 0.22, 1);
|
||||
|
||||
&:after {
|
||||
content: "\f057"; // fa-times-circle
|
||||
font-family: 'FontAwesome';
|
||||
color: white;
|
||||
transition: all 0.2s cubic-bezier(0.19, 1, 0.22, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&:checked + span {
|
||||
background: $o-brand-primary;
|
||||
|
||||
&:after {
|
||||
content: "\f058"; // fa-check-circle
|
||||
margin-left: ($o-we-switch-size * 1.7) - $o-we-switch-size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.o_switch_danger_success {
|
||||
> input {
|
||||
&:not(:checked) + span {
|
||||
background: $o-we-color-danger;
|
||||
}
|
||||
&:checked + span {
|
||||
background: $o-we-color-success;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
import {
|
||||
containsAnyNonPhrasingContent,
|
||||
getDeepestPosition,
|
||||
isContentEditable,
|
||||
isElement,
|
||||
isEmpty,
|
||||
isMediaElement,
|
||||
isProtected,
|
||||
isProtecting,
|
||||
} from "@html_editor/utils/dom_info";
|
||||
import { Plugin } from "../plugin";
|
||||
import { fillEmpty } from "@html_editor/utils/dom";
|
||||
import {
|
||||
BASE_CONTAINER_CLASS,
|
||||
SUPPORTED_BASE_CONTAINER_NAMES,
|
||||
baseContainerGlobalSelector,
|
||||
createBaseContainer,
|
||||
} from "../utils/base_container";
|
||||
import { withSequence } from "@html_editor/utils/resource";
|
||||
import { selectElements } from "@html_editor/utils/dom_traversal";
|
||||
import { childNodeIndex } from "@html_editor/utils/position";
|
||||
|
||||
/**
|
||||
* @typedef { Object } BaseContainerShared
|
||||
* @property { BaseContainerPlugin['createBaseContainer'] } createBaseContainer
|
||||
* @property { BaseContainerPlugin['getDefaultNodeName'] } getDefaultNodeName
|
||||
* @property { BaseContainerPlugin['isCandidateForBaseContainer'] } isCandidateForBaseContainer
|
||||
*/
|
||||
|
||||
export class BaseContainerPlugin extends Plugin {
|
||||
static id = "baseContainer";
|
||||
static shared = ["createBaseContainer", "getDefaultNodeName", "isCandidateForBaseContainer"];
|
||||
static defaultConfig = {
|
||||
baseContainers: ["P", "DIV"],
|
||||
};
|
||||
static dependencies = ["selection"];
|
||||
/**
|
||||
* Register one of the predicates for `invalid_for_base_container_predicates`
|
||||
* as a property for optimization, see variants of `isCandidateForBaseContainer`.
|
||||
*/
|
||||
hasNonPhrasingContentPredicate = (element) =>
|
||||
element?.nodeType === Node.ELEMENT_NODE && containsAnyNonPhrasingContent(element);
|
||||
/**
|
||||
* The `unsplittable` predicate for `invalid_for_base_container_predicates`
|
||||
* is defined in this file and not in split_plugin because it has to be removed
|
||||
* in a specific case: see `isCandidateForBaseContainerAllowUnsplittable`.
|
||||
*/
|
||||
isUnsplittablePredicate = (element) =>
|
||||
this.getResource("unsplittable_node_predicates").some((fn) => fn(element));
|
||||
resources = {
|
||||
clean_for_save_handlers: this.cleanForSave.bind(this),
|
||||
// `baseContainer` normalization should occur after every other normalization
|
||||
// because a `div` may only have the baseContainer identity if it does not
|
||||
// already have another incompatible identity given by another plugin.
|
||||
normalize_handlers: withSequence(Infinity, this.normalizeDivBaseContainers.bind(this)),
|
||||
delete_handlers: () => {
|
||||
if (this.config.cleanEmptyStructuralContainers === false) {
|
||||
return;
|
||||
}
|
||||
this.cleanEmptyStructuralContainers();
|
||||
},
|
||||
unsplittable_node_predicates: (node) => {
|
||||
if (node.nodeName !== "DIV") {
|
||||
return false;
|
||||
}
|
||||
return !this.isCandidateForBaseContainerAllowUnsplittable(node);
|
||||
},
|
||||
invalid_for_base_container_predicates: [
|
||||
(node) =>
|
||||
!node ||
|
||||
node.nodeType !== Node.ELEMENT_NODE ||
|
||||
!SUPPORTED_BASE_CONTAINER_NAMES.includes(node.tagName) ||
|
||||
isProtected(node) ||
|
||||
isProtecting(node) ||
|
||||
isMediaElement(node),
|
||||
this.isUnsplittablePredicate,
|
||||
this.hasNonPhrasingContentPredicate,
|
||||
],
|
||||
system_classes: [BASE_CONTAINER_CLASS],
|
||||
};
|
||||
|
||||
createBaseContainer(nodeName = this.getDefaultNodeName()) {
|
||||
return createBaseContainer(nodeName, this.document);
|
||||
}
|
||||
|
||||
getDefaultNodeName() {
|
||||
return this.config.baseContainers[0];
|
||||
}
|
||||
|
||||
cleanEmptyStructuralContainers() {
|
||||
const node = this.document.getSelection().anchorNode;
|
||||
|
||||
if (!isElement(node) || !isEmpty(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closestEditable = (n) =>
|
||||
isContentEditable(n.parentElement) ? closestEditable(n.parentElement) : n;
|
||||
|
||||
const isUnsplittable = this.isUnsplittablePredicate(node);
|
||||
const isCandidateForBase = this.isCandidateForBaseContainerAllowUnsplittable(node);
|
||||
|
||||
if (isUnsplittable || !isCandidateForBase) {
|
||||
return;
|
||||
}
|
||||
|
||||
let anchorNode = node.parentElement;
|
||||
if (
|
||||
anchorNode === closestEditable(node) ||
|
||||
!SUPPORTED_BASE_CONTAINER_NAMES.includes(anchorNode.nodeName) ||
|
||||
this.getResource("unremovable_node_predicates").some((p) => p(anchorNode))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmpty(anchorNode)) {
|
||||
fillEmpty(anchorNode);
|
||||
}
|
||||
|
||||
let anchorOffset = childNodeIndex(node);
|
||||
node.remove();
|
||||
|
||||
[anchorNode, anchorOffset] = getDeepestPosition(anchorNode, anchorOffset);
|
||||
this.dependencies.selection.setSelection({
|
||||
anchorNode,
|
||||
anchorOffset,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate if an element is eligible to become a baseContainer (i.e. an
|
||||
* unmarked div which could receive baseContainer attributes to inherit
|
||||
* paragraph-like features).
|
||||
*
|
||||
* This function considers unsplittable and childNodes.
|
||||
*/
|
||||
isCandidateForBaseContainer(element) {
|
||||
return !this.getResource("invalid_for_base_container_predicates").some((fn) => fn(element));
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate if an element would be eligible to become a baseContainer
|
||||
* without considering unsplittable.
|
||||
*
|
||||
* This function is only meant to be used during `unsplittable_node_predicates` to
|
||||
* avoid an infinite loop:
|
||||
* Considering a `DIV`,
|
||||
* - During `unsplittable_node_predicates`, one predicate should return true
|
||||
* if the `DIV` is NOT a baseContainer candidate (Odoo specification),
|
||||
* therefore `invalid_for_base_container_predicates` should be evaluated.
|
||||
* - During `invalid_for_base_container_predicates`, one predicate should
|
||||
* return true if the `DIV` is unsplittable, because a node has to be
|
||||
* splittable to use the featureSet associated with paragraphs.
|
||||
* Each resource has to call the other. To avoid the issue, during
|
||||
* `unsplittable_node_predicates`, the baseContainer predicate will execute
|
||||
* all predicates for `invalid_for_base_container_predicates` except
|
||||
* the one using `unsplittable_node_predicates`, since it is already being
|
||||
* evaluated.
|
||||
*
|
||||
* In simpler terms:
|
||||
* A `DIV` is unsplittable by default;
|
||||
* UNLESS it is eligible to be a baseContainer => it becomes one;
|
||||
* UNLESS it has to be unsplittable for an explicit reason (i.e. has class
|
||||
* oe_unbreakable) => it stays unsplittable.
|
||||
*/
|
||||
isCandidateForBaseContainerAllowUnsplittable(element) {
|
||||
const predicates = new Set(this.getResource("invalid_for_base_container_predicates"));
|
||||
predicates.delete(this.isUnsplittablePredicate);
|
||||
for (const predicate of predicates) {
|
||||
if (predicate(element)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate if an element would be eligible to become a baseContainer
|
||||
* without considering its childNodes.
|
||||
*
|
||||
* This function is only meant to be used internally, to avoid having to
|
||||
* compute childNodes multiple times in more complex operations.
|
||||
*/
|
||||
shallowIsCandidateForBaseContainer(element) {
|
||||
const predicates = new Set(this.getResource("invalid_for_base_container_predicates"));
|
||||
predicates.delete(this.hasNonPhrasingContentPredicate);
|
||||
for (const predicate of predicates) {
|
||||
if (predicate(element)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
cleanForSave({ root }) {
|
||||
for (const baseContainer of selectElements(root, `.${BASE_CONTAINER_CLASS}`)) {
|
||||
baseContainer.classList.remove(BASE_CONTAINER_CLASS);
|
||||
if (baseContainer.classList.length === 0) {
|
||||
baseContainer.removeAttribute("class");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalizeDivBaseContainers(element = this.editable) {
|
||||
const newBaseContainers = [];
|
||||
const divSelector = `div:not(.${BASE_CONTAINER_CLASS})`;
|
||||
const targets = [...element.querySelectorAll(divSelector)];
|
||||
if (element.matches(divSelector)) {
|
||||
targets.unshift(element);
|
||||
}
|
||||
for (const div of targets) {
|
||||
if (
|
||||
// Ensure that newly created `div` baseContainers are never themselves
|
||||
// children of a baseContainer. BaseContainers should always only
|
||||
// contain phrasing content (even `div`), because they could be
|
||||
// converted to an element which can actually only contain phrasing
|
||||
// content. In practice a div should never be a child of a
|
||||
// baseContainer, since a baseContainer should only contain
|
||||
// phrasingContent.
|
||||
!div.parentElement?.matches(baseContainerGlobalSelector) &&
|
||||
this.shallowIsCandidateForBaseContainer(div) &&
|
||||
!containsAnyNonPhrasingContent(div)
|
||||
) {
|
||||
div.classList.add(BASE_CONTAINER_CLASS);
|
||||
newBaseContainers.push(div);
|
||||
fillEmpty(div);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,720 @@
|
|||
import { isTextNode, isParagraphRelatedElement, isEmptyBlock } from "../utils/dom_info";
|
||||
import { Plugin } from "../plugin";
|
||||
import { closestBlock } from "../utils/blocks";
|
||||
import { unwrapContents, wrapInlinesInBlocks, splitTextNode, fillEmpty } from "../utils/dom";
|
||||
import { childNodes, closestElement } from "../utils/dom_traversal";
|
||||
import { parseHTML } from "../utils/html";
|
||||
import {
|
||||
baseContainerGlobalSelector,
|
||||
getBaseContainerSelector,
|
||||
} from "@html_editor/utils/base_container";
|
||||
import { DIRECTIONS } from "../utils/position";
|
||||
import { isHtmlContentSupported } from "./selection_plugin";
|
||||
|
||||
/**
|
||||
* @typedef { import("./selection_plugin").EditorSelection } EditorSelection
|
||||
*/
|
||||
|
||||
const CLIPBOARD_BLACKLISTS = {
|
||||
unwrap: [
|
||||
// These elements' children will be unwrapped.
|
||||
".Apple-interchange-newline",
|
||||
"DIV", // DIV is unwrapped unless eligible to be a baseContainer, see cleanForPaste
|
||||
],
|
||||
remove: ["META", "STYLE", "SCRIPT"], // These elements will be removed along with their children.
|
||||
};
|
||||
export const CLIPBOARD_WHITELISTS = {
|
||||
nodes: [
|
||||
// Style
|
||||
"P",
|
||||
"H1",
|
||||
"H2",
|
||||
"H3",
|
||||
"H4",
|
||||
"H5",
|
||||
"H6",
|
||||
"BLOCKQUOTE",
|
||||
"PRE",
|
||||
// List
|
||||
"UL",
|
||||
"OL",
|
||||
"LI",
|
||||
// Inline style
|
||||
"I",
|
||||
"B",
|
||||
"U",
|
||||
"S",
|
||||
"EM",
|
||||
"FONT",
|
||||
"STRONG",
|
||||
// Table
|
||||
"TABLE",
|
||||
"THEAD",
|
||||
"TH",
|
||||
"TBODY",
|
||||
"TR",
|
||||
"TD",
|
||||
// Miscellaneous
|
||||
"IMG",
|
||||
"BR",
|
||||
"A",
|
||||
".fa",
|
||||
],
|
||||
classes: [
|
||||
// Media
|
||||
/^float-/,
|
||||
"d-block",
|
||||
"mx-auto",
|
||||
"img-fluid",
|
||||
"img-thumbnail",
|
||||
"rounded",
|
||||
"rounded-circle",
|
||||
"table",
|
||||
"table-bordered",
|
||||
/^padding-/,
|
||||
/^shadow/,
|
||||
// Odoo colors
|
||||
/^text-o-/,
|
||||
/^bg-o-/,
|
||||
// Odoo lists
|
||||
"o_checked",
|
||||
"o_checklist",
|
||||
"oe-nested",
|
||||
// Miscellaneous
|
||||
/^btn/,
|
||||
/^fa/,
|
||||
],
|
||||
attributes: ["class", "href", "src", "target"],
|
||||
styledTags: ["SPAN", "B", "STRONG", "I", "S", "U", "FONT", "TD"],
|
||||
};
|
||||
|
||||
const ONLY_LINK_REGEX = /^(https?:\/\/)?([\w-]+\.)+[\w-]+(\/[\w-./?%&=]*)?$/i;
|
||||
|
||||
/**
|
||||
* @typedef {Object} ClipboardShared
|
||||
* @property {ClipboardPlugin['pasteText']} pasteText
|
||||
*/
|
||||
|
||||
export class ClipboardPlugin extends Plugin {
|
||||
static id = "clipboard";
|
||||
static dependencies = [
|
||||
"baseContainer",
|
||||
"dom",
|
||||
"selection",
|
||||
"sanitize",
|
||||
"history",
|
||||
"split",
|
||||
"delete",
|
||||
"lineBreak",
|
||||
];
|
||||
static shared = ["pasteText"];
|
||||
|
||||
setup() {
|
||||
this.addDomListener(this.editable, "copy", this.onCopy);
|
||||
this.addDomListener(this.editable, "cut", this.onCut);
|
||||
this.addDomListener(this.editable, "paste", this.onPaste);
|
||||
this.addDomListener(this.editable, "dragstart", this.onDragStart);
|
||||
this.addDomListener(this.editable, "drop", this.onDrop);
|
||||
}
|
||||
|
||||
onCut(ev) {
|
||||
this.onCopy(ev);
|
||||
this.dependencies.history.stageSelection();
|
||||
this.dependencies.delete.deleteSelection();
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ClipboardEvent} ev
|
||||
*/
|
||||
onCopy(ev) {
|
||||
ev.preventDefault();
|
||||
const selection = this.dependencies.selection.getEditableSelection();
|
||||
let clonedContents = selection.cloneContents();
|
||||
if (!clonedContents.hasChildNodes()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare text content for clipboard.
|
||||
let textContent = selection.textContent();
|
||||
for (const processor of this.getResource("clipboard_text_processors")) {
|
||||
textContent = processor(textContent);
|
||||
}
|
||||
ev.clipboardData.setData("text/plain", textContent);
|
||||
|
||||
// Prepare html content for clipboard.
|
||||
for (const processor of this.getResource("clipboard_content_processors")) {
|
||||
clonedContents = processor(clonedContents, selection) || clonedContents;
|
||||
}
|
||||
this.dependencies.dom.removeSystemProperties(clonedContents);
|
||||
const dataHtmlElement = this.document.createElement("data");
|
||||
dataHtmlElement.append(clonedContents);
|
||||
prependOriginToImages(dataHtmlElement, window.location.origin);
|
||||
const htmlContent = dataHtmlElement.innerHTML;
|
||||
ev.clipboardData.setData("text/html", htmlContent);
|
||||
ev.clipboardData.setData("application/vnd.odoo.odoo-editor", htmlContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle safe pasting of html or plain text into the editor.
|
||||
*/
|
||||
onPaste(ev) {
|
||||
let selection = this.dependencies.selection.getEditableSelection();
|
||||
if (
|
||||
!selection.anchorNode.isConnected ||
|
||||
!closestElement(selection.anchorNode).isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
|
||||
this.dependencies.history.stageSelection();
|
||||
|
||||
this.dispatchTo("before_paste_handlers", selection, ev);
|
||||
// refresh selection after potential changes from `before_paste` handlers
|
||||
selection = this.dependencies.selection.getEditableSelection();
|
||||
|
||||
this.handlePasteUnsupportedHtml(selection, ev.clipboardData) ||
|
||||
this.handlePasteOdooEditorHtml(ev.clipboardData) ||
|
||||
this.handlePasteHtml(selection, ev.clipboardData) ||
|
||||
this.handlePasteText(selection, ev.clipboardData);
|
||||
|
||||
this.dispatchTo("after_paste_handlers", selection);
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
/**
|
||||
* @param {EditorSelection} selection
|
||||
* @param {DataTransfer} clipboardData
|
||||
*/
|
||||
handlePasteUnsupportedHtml(selection, clipboardData) {
|
||||
if (!isHtmlContentSupported(selection)) {
|
||||
const text = clipboardData.getData("text/plain");
|
||||
this.dependencies.dom.insert(text);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {DataTransfer} clipboardData
|
||||
*/
|
||||
handlePasteOdooEditorHtml(clipboardData) {
|
||||
const odooEditorHtml = clipboardData.getData("application/vnd.odoo.odoo-editor");
|
||||
const textContent = clipboardData.getData("text/plain");
|
||||
if (ONLY_LINK_REGEX.test(textContent)) {
|
||||
return false;
|
||||
}
|
||||
if (odooEditorHtml) {
|
||||
const fragment = parseHTML(this.document, odooEditorHtml);
|
||||
this.dependencies.sanitize.sanitize(fragment);
|
||||
if (fragment.hasChildNodes()) {
|
||||
this.dependencies.dom.insert(fragment);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {EditorSelection} selection
|
||||
* @param {DataTransfer} clipboardData
|
||||
*/
|
||||
handlePasteHtml(selection, clipboardData) {
|
||||
const files = this.delegateTo("bypass_paste_image_files")
|
||||
? []
|
||||
: getImageFiles(clipboardData);
|
||||
const clipboardHtml = clipboardData.getData("text/html");
|
||||
const textContent = clipboardData.getData("text/plain");
|
||||
if (ONLY_LINK_REGEX.test(textContent)) {
|
||||
return false;
|
||||
}
|
||||
if (files.length || clipboardHtml) {
|
||||
const clipboardElem = this.prepareClipboardData(clipboardHtml);
|
||||
// @phoenix @todo: should it be handled in table plugin?
|
||||
// When copy pasting a table from the outside, a picture of the
|
||||
// table can be included in the clipboard as an image file. In that
|
||||
// particular case the html table is given a higher priority than
|
||||
// the clipboard picture.
|
||||
if (files.length && !clipboardElem.querySelector("table")) {
|
||||
// @phoenix @todo: should it be handled in image plugin?
|
||||
return this.addImagesFiles(files).then((html) => {
|
||||
this.dependencies.dom.insert(html);
|
||||
this.dependencies.history.addStep();
|
||||
});
|
||||
} else if (clipboardElem.hasChildNodes()) {
|
||||
if (closestElement(selection.anchorNode, "a")) {
|
||||
this.dependencies.dom.insert(clipboardElem.textContent);
|
||||
} else {
|
||||
this.dependencies.dom.insert(clipboardElem);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {EditorSelection} selection
|
||||
* @param {DataTransfer} clipboardData
|
||||
*/
|
||||
handlePasteText(selection, clipboardData) {
|
||||
const text = clipboardData.getData("text/plain");
|
||||
if (this.delegateTo("paste_text_overrides", selection, text)) {
|
||||
return;
|
||||
} else {
|
||||
this.pasteText(text);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
pasteText(text) {
|
||||
const textFragments = text.split(/\r?\n/);
|
||||
let selection = this.dependencies.selection.getEditableSelection();
|
||||
const preEl = closestElement(selection.anchorNode, "PRE");
|
||||
let textIndex = 1;
|
||||
for (const textFragment of textFragments) {
|
||||
let modifiedTextFragment = textFragment;
|
||||
|
||||
// <pre> preserves whitespace by default, so no need for  .
|
||||
if (!preEl) {
|
||||
// Replace consecutive spaces by alternating nbsp.
|
||||
modifiedTextFragment = textFragment.replace(/( {2,})/g, (match) => {
|
||||
let alternateValue = false;
|
||||
return match.replace(/ /g, () => {
|
||||
alternateValue = !alternateValue;
|
||||
const replaceContent = alternateValue ? "\u00A0" : " ";
|
||||
return replaceContent;
|
||||
});
|
||||
});
|
||||
}
|
||||
this.dependencies.dom.insert(modifiedTextFragment);
|
||||
if (textIndex < textFragments.length) {
|
||||
selection = this.dependencies.selection.getEditableSelection();
|
||||
// Break line by inserting new paragraph and
|
||||
// remove current paragraph's bottom margin.
|
||||
const block = closestBlock(selection.anchorNode);
|
||||
if (
|
||||
this.dependencies.split.isUnsplittable(block) ||
|
||||
closestElement(selection.anchorNode).tagName === "PRE"
|
||||
) {
|
||||
this.dependencies.lineBreak.insertLineBreak();
|
||||
} else {
|
||||
const [blockBefore] = this.dependencies.split.splitBlock();
|
||||
if (
|
||||
block &&
|
||||
block.matches(baseContainerGlobalSelector) &&
|
||||
blockBefore &&
|
||||
!blockBefore.matches(getBaseContainerSelector("DIV"))
|
||||
) {
|
||||
// Do something only if blockBefore is not a DIV (which is the no-margin option)
|
||||
// replace blockBefore by a DIV.
|
||||
const div = this.dependencies.baseContainer.createBaseContainer("DIV");
|
||||
const cursors = this.dependencies.selection.preserveSelection();
|
||||
blockBefore.before(div);
|
||||
div.replaceChildren(...childNodes(blockBefore));
|
||||
blockBefore.remove();
|
||||
cursors.remapNode(blockBefore, div).restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
textIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare clipboard data (text/html) for safe pasting into the editor.
|
||||
*
|
||||
* @private
|
||||
* @param {string} clipboardData
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
prepareClipboardData(clipboardData) {
|
||||
const fragment = parseHTML(this.document, clipboardData);
|
||||
this.dependencies.sanitize.sanitize(fragment);
|
||||
const container = this.document.createElement("fake-container");
|
||||
container.append(fragment);
|
||||
|
||||
for (const tableElement of container.querySelectorAll("table")) {
|
||||
tableElement.classList.add("table", "table-bordered", "o_table");
|
||||
}
|
||||
if (this.delegateTo("bypass_paste_image_files")) {
|
||||
for (const imgElement of container.querySelectorAll("img")) {
|
||||
imgElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// todo: should it be in its own plugin ?
|
||||
const progId = container.querySelector('meta[name="ProgId"]');
|
||||
if (progId && progId.content === "Excel.Sheet") {
|
||||
// Microsoft Excel keeps table style in a <style> tag with custom
|
||||
// classes. The following lines parse that style and apply it to the
|
||||
// style attribute of <td> tags with matching classes.
|
||||
const xlStylesheet = container.querySelector("style");
|
||||
const xlNodes = container.querySelectorAll("[class*=xl],[class*=font]");
|
||||
for (const xlNode of xlNodes) {
|
||||
for (const xlClass of xlNode.classList) {
|
||||
// Regex captures a CSS rule definition for that xlClass.
|
||||
const xlStyle = xlStylesheet.textContent
|
||||
.match(`.${xlClass}[^{]*{(?<xlStyle>[^}]*)}`)
|
||||
.groups.xlStyle.replace("background:", "background-color:");
|
||||
xlNode.setAttribute("style", xlNode.style.cssText + ";" + xlStyle);
|
||||
}
|
||||
}
|
||||
}
|
||||
const childContent = childNodes(container);
|
||||
for (const child of childContent) {
|
||||
this.cleanForPaste(child);
|
||||
}
|
||||
// Identify the closest baseContainer from the selection. This will
|
||||
// determine which baseContainer will be used by default for the
|
||||
// clipboard content if it has to be modified.
|
||||
const selection = this.dependencies.selection.getEditableSelection();
|
||||
const closestBaseContainer =
|
||||
selection.anchorNode &&
|
||||
closestElement(selection.anchorNode, baseContainerGlobalSelector);
|
||||
// Force inline nodes at the root of the container into separate `baseContainers`
|
||||
// elements. This is a tradeoff to ensure some features that rely on
|
||||
// nodes having a parent (e.g. convert to list, title, etc.) can work
|
||||
// properly on such nodes without having to actually handle that
|
||||
// particular case in all of those functions. In fact, this case cannot
|
||||
// happen on a new document created using this editor, but will happen
|
||||
// instantly when editing a document that was created from Etherpad.
|
||||
wrapInlinesInBlocks(container, {
|
||||
baseContainerNodeName:
|
||||
closestBaseContainer?.nodeName ||
|
||||
this.dependencies.baseContainer.getDefaultNodeName(),
|
||||
});
|
||||
const result = this.document.createDocumentFragment();
|
||||
result.replaceChildren(...childNodes(container));
|
||||
|
||||
// Split elements containing <br> into separate elements for each line.
|
||||
const brs = result.querySelectorAll("br");
|
||||
for (const br of brs) {
|
||||
const block = closestBlock(br);
|
||||
if (
|
||||
(isParagraphRelatedElement(block) ||
|
||||
this.dependencies.baseContainer.isCandidateForBaseContainer(block)) &&
|
||||
block.nodeName !== "PRE"
|
||||
) {
|
||||
// A linebreak at the beginning of a block is an empty line.
|
||||
const isEmptyLine = block.firstChild.nodeName === "BR";
|
||||
// Split blocks around it until only the BR remains in the
|
||||
// block.
|
||||
const remainingBrContainer = this.dependencies.split.splitAroundUntil(br, block);
|
||||
// Remove the container unless it represented an empty line.
|
||||
if (!isEmptyLine) {
|
||||
remainingBrContainer.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Clean a node for safely pasting. Cleaning an element involves unwrapping
|
||||
* its contents if it's an illegal (blacklisted or not whitelisted) element,
|
||||
* or removing its illegal attributes and classes.
|
||||
*
|
||||
* @param {Node} node
|
||||
*/
|
||||
cleanForPaste(node) {
|
||||
if (
|
||||
!this.isWhitelisted(node) ||
|
||||
this.isBlacklisted(node) ||
|
||||
// Google Docs have their html inside a B tag with custom id.
|
||||
(node.id && node.id.startsWith("docs-internal-guid"))
|
||||
) {
|
||||
if (!node.matches || node.matches(CLIPBOARD_BLACKLISTS.remove.join(","))) {
|
||||
node.remove();
|
||||
} else {
|
||||
let childrenNodes;
|
||||
if (node.nodeName === "DIV") {
|
||||
if (!node.hasChildNodes()) {
|
||||
node.remove();
|
||||
return;
|
||||
} else if (this.dependencies.baseContainer.isCandidateForBaseContainer(node)) {
|
||||
const whiteSpace = node.style?.whiteSpace;
|
||||
if (whiteSpace && !["normal", "nowrap"].includes(whiteSpace)) {
|
||||
node.innerHTML = node.innerHTML.replace(/\n/g, "<br>");
|
||||
}
|
||||
const baseContainer = this.dependencies.baseContainer.createBaseContainer();
|
||||
const dir = node.getAttribute("dir");
|
||||
if (dir) {
|
||||
baseContainer.setAttribute("dir", dir);
|
||||
}
|
||||
baseContainer.append(...node.childNodes);
|
||||
|
||||
node.replaceWith(baseContainer);
|
||||
childrenNodes = childNodes(baseContainer);
|
||||
} else {
|
||||
childrenNodes = unwrapContents(node);
|
||||
}
|
||||
} else {
|
||||
// Unwrap the illegal node's contents.
|
||||
childrenNodes = unwrapContents(node);
|
||||
}
|
||||
for (const child of childrenNodes) {
|
||||
this.cleanForPaste(child);
|
||||
}
|
||||
}
|
||||
} else if (node.nodeType !== Node.TEXT_NODE) {
|
||||
if (node.nodeName === "THEAD") {
|
||||
const tbody = node.nextElementSibling;
|
||||
if (tbody) {
|
||||
// If a <tbody> already exists, move all rows from
|
||||
// <thead> into the start of <tbody>.
|
||||
tbody.prepend(...node.children);
|
||||
node.remove();
|
||||
node = tbody;
|
||||
} else {
|
||||
// Otherwise, replace the <thead> with <tbody>
|
||||
node = this.dependencies.dom.setTagName(node, "TBODY");
|
||||
}
|
||||
} else if (["TD", "TH"].includes(node.nodeName)) {
|
||||
// Insert base container into empty TD.
|
||||
if (isEmptyBlock(node)) {
|
||||
const baseContainer = this.dependencies.baseContainer.createBaseContainer();
|
||||
fillEmpty(baseContainer);
|
||||
node.replaceChildren(baseContainer);
|
||||
}
|
||||
|
||||
if (node.hasAttribute("bgcolor") && !node.style["background-color"]) {
|
||||
node.style["background-color"] = node.getAttribute("bgcolor");
|
||||
}
|
||||
} else if (node.nodeName === "FONT") {
|
||||
// FONT tags have some style information in custom attributes,
|
||||
// this maps them to the style attribute.
|
||||
if (node.hasAttribute("color") && !node.style["color"]) {
|
||||
node.style["color"] = node.getAttribute("color");
|
||||
}
|
||||
if (node.hasAttribute("size") && !node.style["font-size"]) {
|
||||
// FONT size uses non-standard numeric values.
|
||||
node.style["font-size"] = +node.getAttribute("size") + 10 + "pt";
|
||||
}
|
||||
} else if (
|
||||
["S", "U"].includes(node.nodeName) &&
|
||||
childNodes(node).length === 1 &&
|
||||
node.firstChild.nodeName === "FONT"
|
||||
) {
|
||||
// S and U tags sometimes contain FONT tags. We prefer the
|
||||
// strike to adopt the style of the text, so we invert them.
|
||||
const fontNode = node.firstChild;
|
||||
node.before(fontNode);
|
||||
node.replaceChildren(...childNodes(fontNode));
|
||||
fontNode.appendChild(node);
|
||||
} else if (
|
||||
node.nodeName === "IMG" &&
|
||||
node.getAttribute("aria-roledescription") === "checkbox"
|
||||
) {
|
||||
const checklist = node.closest("ul");
|
||||
const closestLi = node.closest("li");
|
||||
if (checklist) {
|
||||
checklist.classList.add("o_checklist");
|
||||
if (node.getAttribute("alt") === "checked") {
|
||||
closestLi.classList.add("o_checked");
|
||||
}
|
||||
node.remove();
|
||||
node = checklist;
|
||||
}
|
||||
}
|
||||
// Remove all illegal attributes and classes from the node, then
|
||||
// clean its children.
|
||||
for (const attribute of [...node.attributes]) {
|
||||
// Keep allowed styles on nodes with allowed tags.
|
||||
// todo: should the whitelist be a resource?
|
||||
if (
|
||||
CLIPBOARD_WHITELISTS.styledTags.includes(node.nodeName) &&
|
||||
attribute.name === "style"
|
||||
) {
|
||||
node.removeAttribute(attribute.name);
|
||||
if (["SPAN", "FONT"].includes(node.tagName)) {
|
||||
for (const unwrappedNode of unwrapContents(node)) {
|
||||
this.cleanForPaste(unwrappedNode);
|
||||
}
|
||||
}
|
||||
} else if (!this.isWhitelisted(attribute)) {
|
||||
node.removeAttribute(attribute.name);
|
||||
}
|
||||
}
|
||||
for (const klass of [...node.classList]) {
|
||||
if (!this.isWhitelisted(klass)) {
|
||||
node.classList.remove(klass);
|
||||
}
|
||||
}
|
||||
for (const child of childNodes(node)) {
|
||||
this.cleanForPaste(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Return true if the given attribute, class or node is whitelisted for
|
||||
* pasting, false otherwise.
|
||||
*
|
||||
* @private
|
||||
* @param {Attr | string | Node} item
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isWhitelisted(item) {
|
||||
if (item.nodeType === Node.ATTRIBUTE_NODE) {
|
||||
return CLIPBOARD_WHITELISTS.attributes.includes(item.name);
|
||||
} else if (typeof item === "string") {
|
||||
return CLIPBOARD_WHITELISTS.classes.some((okClass) =>
|
||||
okClass instanceof RegExp ? okClass.test(item) : okClass === item
|
||||
);
|
||||
} else {
|
||||
return isTextNode(item) || item.matches?.(CLIPBOARD_WHITELISTS.nodes.join(","));
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Return true if the given node is blacklisted for pasting, false
|
||||
* otherwise.
|
||||
*
|
||||
* @private
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isBlacklisted(node) {
|
||||
return (
|
||||
!isTextNode(node) &&
|
||||
node.matches([].concat(...Object.values(CLIPBOARD_BLACKLISTS)).join(","))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DragEvent} ev
|
||||
*/
|
||||
onDragStart(ev) {
|
||||
if (ev.target.nodeName === "IMG") {
|
||||
this.dragImage = ev.target instanceof HTMLElement && ev.target;
|
||||
ev.dataTransfer.setData(
|
||||
"application/vnd.odoo.odoo-editor-node",
|
||||
this.dragImage.outerHTML
|
||||
);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Handle safe dropping of html into the editor.
|
||||
*
|
||||
* @param {DragEvent} ev
|
||||
*/
|
||||
async onDrop(ev) {
|
||||
ev.preventDefault();
|
||||
const selection = this.dependencies.selection.getEditableSelection();
|
||||
if (!isHtmlContentSupported(selection)) {
|
||||
return;
|
||||
}
|
||||
const nodeToSplit =
|
||||
selection.direction === DIRECTIONS.RIGHT ? selection.focusNode : selection.anchorNode;
|
||||
const offsetToSplit =
|
||||
selection.direction === DIRECTIONS.RIGHT
|
||||
? selection.focusOffset
|
||||
: selection.anchorOffset;
|
||||
if (nodeToSplit.nodeType === Node.TEXT_NODE && !selection.isCollapsed) {
|
||||
const selectionToRestore = this.dependencies.selection.preserveSelection();
|
||||
// Split the text node beforehand to ensure the insertion offset
|
||||
// remains correct after deleting the selection.
|
||||
splitTextNode(nodeToSplit, offsetToSplit, DIRECTIONS.LEFT);
|
||||
selectionToRestore.restore();
|
||||
}
|
||||
|
||||
const dataTransfer = (ev.originalEvent || ev).dataTransfer;
|
||||
const imageNodeHTML = ev.dataTransfer.getData("application/vnd.odoo.odoo-editor-node");
|
||||
const image =
|
||||
imageNodeHTML &&
|
||||
this.dragImage &&
|
||||
imageNodeHTML === this.dragImage.outerHTML &&
|
||||
this.dragImage;
|
||||
|
||||
const fileTransferItems = getImageFiles(dataTransfer);
|
||||
const htmlTransferItem = [...dataTransfer.items].find((item) => item.type === "text/html");
|
||||
if (image || fileTransferItems.length || htmlTransferItem) {
|
||||
if (this.document.caretPositionFromPoint) {
|
||||
const range = this.document.caretPositionFromPoint(ev.clientX, ev.clientY);
|
||||
this.dependencies.delete.deleteSelection();
|
||||
this.dependencies.selection.setSelection({
|
||||
anchorNode: range.offsetNode,
|
||||
anchorOffset: range.offset,
|
||||
});
|
||||
} else if (this.document.caretRangeFromPoint) {
|
||||
const range = this.document.caretRangeFromPoint(ev.clientX, ev.clientY);
|
||||
this.dependencies.delete.deleteSelection();
|
||||
this.dependencies.selection.setSelection({
|
||||
anchorNode: range.startContainer,
|
||||
anchorOffset: range.startOffset,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (image) {
|
||||
const fragment = this.document.createDocumentFragment();
|
||||
fragment.append(image);
|
||||
this.dependencies.dom.insert(fragment);
|
||||
this.dependencies.history.addStep();
|
||||
} else if (fileTransferItems.length) {
|
||||
const html = await this.addImagesFiles(fileTransferItems);
|
||||
this.dependencies.dom.insert(html);
|
||||
this.dependencies.history.addStep();
|
||||
} else if (htmlTransferItem) {
|
||||
htmlTransferItem.getAsString((pastedText) => {
|
||||
this.dependencies.dom.insert(this.prepareClipboardData(pastedText));
|
||||
this.dependencies.history.addStep();
|
||||
});
|
||||
}
|
||||
}
|
||||
// @phoenix @todo: move to image or image paste plugin?
|
||||
/**
|
||||
* Add images inside the editable at the current selection.
|
||||
*
|
||||
* @param {File[]} imageFiles
|
||||
*/
|
||||
async addImagesFiles(imageFiles) {
|
||||
const promises = [];
|
||||
for (const imageFile of imageFiles) {
|
||||
const imageNode = this.document.createElement("img");
|
||||
imageNode.classList.add("img-fluid");
|
||||
this.dispatchTo("added_image_handlers", imageNode);
|
||||
imageNode.dataset.fileName = imageFile.name;
|
||||
promises.push(
|
||||
getImageUrl(imageFile).then((url) => {
|
||||
imageNode.src = url;
|
||||
return imageNode;
|
||||
})
|
||||
);
|
||||
}
|
||||
const nodes = await Promise.all(promises);
|
||||
const fragment = this.document.createDocumentFragment();
|
||||
fragment.append(...nodes);
|
||||
return fragment;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DataTransfer} dataTransfer
|
||||
*/
|
||||
function getImageFiles(dataTransfer) {
|
||||
return [...dataTransfer.items]
|
||||
.filter((item) => item.kind === "file" && item.type.includes("image/"))
|
||||
.map((item) => item.getAsFile());
|
||||
}
|
||||
/**
|
||||
* @param {File} file
|
||||
*/
|
||||
function getImageUrl(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
reader.onloadend = (e) => {
|
||||
if (reader.error) {
|
||||
return reject(reader.error);
|
||||
}
|
||||
resolve(e.target.result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add origin to relative img src.
|
||||
* @param {string} origin
|
||||
*/
|
||||
function prependOriginToImages(doc, origin) {
|
||||
doc.querySelectorAll("img").forEach((img) => {
|
||||
const src = img.getAttribute("src");
|
||||
if (src && !/^(http|\/\/|data:)/.test(src)) {
|
||||
img.src = origin + (src.startsWith("/") ? src : "/" + src);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { isProtected } from "@html_editor/utils/dom_info";
|
||||
import { Plugin } from "../plugin";
|
||||
import { descendants } from "../utils/dom_traversal";
|
||||
|
||||
export class CommentPlugin extends Plugin {
|
||||
static id = "comment";
|
||||
resources = {
|
||||
normalize_handlers: this.removeComment.bind(this),
|
||||
};
|
||||
|
||||
removeComment(node) {
|
||||
for (const el of [node, ...descendants(node)]) {
|
||||
if (el.nodeType === Node.COMMENT_NODE && !isProtected(el)) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { isArtificialVoidElement } from "@html_editor/core/selection_plugin";
|
||||
import { Plugin } from "@html_editor/plugin";
|
||||
import { selectElements } from "@html_editor/utils/dom_traversal";
|
||||
import { withSequence } from "@html_editor/utils/resource";
|
||||
|
||||
/**
|
||||
* This plugin is responsible for setting the contenteditable attribute on some
|
||||
* elements.
|
||||
*
|
||||
* The force_editable_selector and force_not_editable_selector resources allow
|
||||
* other plugins to easily add editable or non editable elements.
|
||||
*/
|
||||
|
||||
export class ContentEditablePlugin extends Plugin {
|
||||
static id = "contentEditablePlugin";
|
||||
resources = {
|
||||
normalize_handlers: withSequence(5, this.normalize.bind(this)),
|
||||
clean_for_save_handlers: withSequence(Infinity, this.cleanForSave.bind(this)),
|
||||
};
|
||||
|
||||
normalize(root) {
|
||||
const toDisableSelector = this.getResource("force_not_editable_selector").join(",");
|
||||
const toDisableEls = toDisableSelector ? [...selectElements(root, toDisableSelector)] : [];
|
||||
for (const toDisable of toDisableEls) {
|
||||
toDisable.setAttribute("contenteditable", "false");
|
||||
}
|
||||
const toEnableSelector = this.getResource("force_editable_selector").join(",");
|
||||
let filteredContentEditableEls = toEnableSelector
|
||||
? [...selectElements(root, toEnableSelector)]
|
||||
: [];
|
||||
for (const fn of this.getResource("filter_contenteditable_handlers")) {
|
||||
filteredContentEditableEls = [...fn(filteredContentEditableEls)];
|
||||
}
|
||||
const extraContentEditableEls = [];
|
||||
for (const fn of this.getResource("extra_contenteditable_handlers")) {
|
||||
extraContentEditableEls.push(...fn(filteredContentEditableEls));
|
||||
}
|
||||
for (const contentEditableEl of [
|
||||
...filteredContentEditableEls,
|
||||
...extraContentEditableEls,
|
||||
]) {
|
||||
if (!contentEditableEl.isContentEditable) {
|
||||
if (
|
||||
isArtificialVoidElement(contentEditableEl) ||
|
||||
contentEditableEl.nodeName === "IMG"
|
||||
) {
|
||||
contentEditableEl.classList.add("o_editable_media");
|
||||
continue;
|
||||
}
|
||||
if (!contentEditableEl.matches(toDisableSelector)) {
|
||||
contentEditableEl.setAttribute("contenteditable", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanForSave({ root }) {
|
||||
const toRemoveSelector = this.getResource("contenteditable_to_remove_selector").join(",");
|
||||
const contenteditableEls = toRemoveSelector
|
||||
? [...selectElements(root, toRemoveSelector)]
|
||||
: [];
|
||||
for (const contenteditableEl of contenteditableEls) {
|
||||
contenteditableEl.removeAttribute("contenteditable");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,35 @@
|
|||
import { Plugin } from "../plugin";
|
||||
|
||||
/**
|
||||
* @typedef {typeof import("@odoo/owl").Component} Component
|
||||
* @typedef {import("@web/core/dialog/dialog_service").DialogServiceInterfaceAddOptions} DialogServiceInterfaceAddOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DialogShared
|
||||
* @property {DialogPlugin['addDialog']} addDialog
|
||||
*/
|
||||
|
||||
export class DialogPlugin extends Plugin {
|
||||
static id = "dialog";
|
||||
static dependencies = ["selection"];
|
||||
static shared = ["addDialog"];
|
||||
|
||||
/**
|
||||
* @param {Component} DialogClass
|
||||
* @param {Object} props
|
||||
* @param {DialogServiceInterfaceAddOptions} options
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
addDialog(DialogClass, props, options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
this.services.dialog.add(DialogClass, props, {
|
||||
onClose: () => {
|
||||
this.dependencies.selection.focusEditable();
|
||||
resolve();
|
||||
},
|
||||
...options,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,654 @@
|
|||
import { Plugin } from "../plugin";
|
||||
import { closestBlock, isBlock } from "../utils/blocks";
|
||||
import {
|
||||
cleanTrailingBR,
|
||||
fillEmpty,
|
||||
fillShrunkPhrasingParent,
|
||||
makeContentsInline,
|
||||
removeClass,
|
||||
removeStyle,
|
||||
splitTextNode,
|
||||
unwrapContents,
|
||||
wrapInlinesInBlocks,
|
||||
} from "../utils/dom";
|
||||
import {
|
||||
allowsParagraphRelatedElements,
|
||||
getDeepestPosition,
|
||||
isContentEditable,
|
||||
isContentEditableAncestor,
|
||||
isEmptyBlock,
|
||||
isListElement,
|
||||
isListItemElement,
|
||||
isParagraphRelatedElement,
|
||||
isProtecting,
|
||||
isProtected,
|
||||
isSelfClosingElement,
|
||||
isShrunkBlock,
|
||||
isTangible,
|
||||
isUnprotecting,
|
||||
listElementSelector,
|
||||
isEditorTab,
|
||||
} from "../utils/dom_info";
|
||||
import {
|
||||
childNodes,
|
||||
children,
|
||||
closestElement,
|
||||
descendants,
|
||||
firstLeaf,
|
||||
lastLeaf,
|
||||
} from "../utils/dom_traversal";
|
||||
import { FONT_SIZE_CLASSES, TEXT_STYLE_CLASSES } from "../utils/formatting";
|
||||
import { DIRECTIONS, childNodeIndex, nodeSize, rightPos } from "../utils/position";
|
||||
import { normalizeCursorPosition } from "@html_editor/utils/selection";
|
||||
import { baseContainerGlobalSelector } from "@html_editor/utils/base_container";
|
||||
import { isHtmlContentSupported } from "@html_editor/core/selection_plugin";
|
||||
|
||||
/**
|
||||
* Get distinct connected parents of nodes
|
||||
*
|
||||
* @param {Iterable} nodes
|
||||
* @returns {Set}
|
||||
*/
|
||||
function getConnectedParents(nodes) {
|
||||
const parents = new Set();
|
||||
for (const node of nodes) {
|
||||
if (node.isConnected && node.parentElement) {
|
||||
parents.add(node.parentElement);
|
||||
}
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} DomShared
|
||||
* @property { DomPlugin['insert'] } insert
|
||||
* @property { DomPlugin['copyAttributes'] } copyAttributes
|
||||
* @property { DomPlugin['canSetBlock'] } canSetBlock
|
||||
* @property { DomPlugin['setBlock'] } setBlock
|
||||
* @property { DomPlugin['setTagName'] } setTagName
|
||||
* @property { DomPlugin['removeSystemProperties'] } removeSystemProperties
|
||||
*/
|
||||
|
||||
export class DomPlugin extends Plugin {
|
||||
static id = "dom";
|
||||
static dependencies = ["baseContainer", "selection", "history", "split", "delete", "lineBreak"];
|
||||
static shared = [
|
||||
"insert",
|
||||
"copyAttributes",
|
||||
"canSetBlock",
|
||||
"setBlock",
|
||||
"setTagName",
|
||||
"removeSystemProperties",
|
||||
];
|
||||
resources = {
|
||||
user_commands: [
|
||||
{
|
||||
id: "insertFontAwesome",
|
||||
run: this.insertFontAwesome.bind(this),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "setTag",
|
||||
run: this.setBlock.bind(this),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
],
|
||||
/** Handlers */
|
||||
clean_for_save_handlers: ({ root }) => {
|
||||
this.removeEmptyClassAndStyleAttributes(root);
|
||||
},
|
||||
clipboard_content_processors: this.removeEmptyClassAndStyleAttributes.bind(this),
|
||||
functional_empty_node_predicates: [isSelfClosingElement, isEditorTab],
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.systemClasses = this.getResource("system_classes");
|
||||
this.systemAttributes = this.getResource("system_attributes");
|
||||
this.systemStyleProperties = this.getResource("system_style_properties");
|
||||
this.systemPropertiesSelector = [
|
||||
...this.systemClasses.map((className) => `.${className}`),
|
||||
...this.systemAttributes.map((attr) => `[${attr}]`),
|
||||
...this.systemStyleProperties.map((prop) => `[style*="${prop}"]`),
|
||||
].join(",");
|
||||
}
|
||||
|
||||
// Shared
|
||||
|
||||
/**
|
||||
* @param {string | DocumentFragment | Element | null} content
|
||||
*/
|
||||
insert(content) {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
let selection = this.dependencies.selection.getEditableSelection();
|
||||
if (!selection.isCollapsed) {
|
||||
this.dependencies.delete.deleteSelection();
|
||||
selection = this.dependencies.selection.getEditableSelection();
|
||||
}
|
||||
|
||||
let container = this.document.createElement("fake-element");
|
||||
const containerFirstChild = this.document.createElement("fake-element-fc");
|
||||
const containerLastChild = this.document.createElement("fake-element-lc");
|
||||
if (typeof content === "string") {
|
||||
container.textContent = content;
|
||||
} else {
|
||||
if (content.nodeType === Node.ELEMENT_NODE) {
|
||||
this.dispatchTo("normalize_handlers", content);
|
||||
} else {
|
||||
for (const child of children(content)) {
|
||||
this.dispatchTo("normalize_handlers", child);
|
||||
}
|
||||
}
|
||||
container.replaceChildren(content);
|
||||
}
|
||||
|
||||
const block = closestBlock(selection.anchorNode);
|
||||
for (const cb of this.getResource("before_insert_processors")) {
|
||||
container = cb(container, block);
|
||||
}
|
||||
selection = this.dependencies.selection.getEditableSelection();
|
||||
|
||||
let startNode;
|
||||
let insertBefore = false;
|
||||
if (selection.startContainer.nodeType === Node.TEXT_NODE) {
|
||||
insertBefore = !selection.startOffset;
|
||||
splitTextNode(selection.startContainer, selection.startOffset, DIRECTIONS.LEFT);
|
||||
startNode = selection.startContainer;
|
||||
}
|
||||
|
||||
const allInsertedNodes = [];
|
||||
// In case the html inserted starts with a list and will be inserted within
|
||||
// a list, unwrap the list elements from the list.
|
||||
const hasSingleChild = nodeSize(container) === 1;
|
||||
if (
|
||||
closestElement(selection.anchorNode, listElementSelector) &&
|
||||
isListElement(container.firstChild)
|
||||
) {
|
||||
unwrapContents(container.firstChild);
|
||||
}
|
||||
// Similarly if the html inserted ends with a list.
|
||||
if (
|
||||
closestElement(selection.focusNode, listElementSelector) &&
|
||||
isListElement(container.lastChild) &&
|
||||
!hasSingleChild
|
||||
) {
|
||||
unwrapContents(container.lastChild);
|
||||
}
|
||||
|
||||
startNode = startNode || this.dependencies.selection.getEditableSelection().anchorNode;
|
||||
|
||||
const shouldUnwrap = (node) =>
|
||||
(isParagraphRelatedElement(node) || isListItemElement(node)) &&
|
||||
!isEmptyBlock(block) &&
|
||||
!isEmptyBlock(node) &&
|
||||
(isContentEditable(node) ||
|
||||
(!node.isConnected && !closestElement(node, "[contenteditable]"))) &&
|
||||
!this.dependencies.split.isUnsplittable(node) &&
|
||||
(node.nodeName === block.nodeName ||
|
||||
(this.dependencies.baseContainer.isCandidateForBaseContainer(node) &&
|
||||
this.dependencies.baseContainer.isCandidateForBaseContainer(block)) ||
|
||||
block.nodeName === "PRE" ||
|
||||
(block.nodeName === "DIV" && this.dependencies.split.isUnsplittable(block))) &&
|
||||
// If the selection anchorNode is the editable itself, the content
|
||||
// should not be unwrapped.
|
||||
!this.isEditionBoundary(selection.anchorNode);
|
||||
|
||||
// Empty block must contain a br element to allow cursor placement.
|
||||
if (
|
||||
container.lastElementChild &&
|
||||
isBlock(container.lastElementChild) &&
|
||||
!container.lastElementChild.hasChildNodes()
|
||||
) {
|
||||
fillEmpty(container.lastElementChild);
|
||||
}
|
||||
|
||||
// In case the html inserted is all contained in a single root <p> or <li>
|
||||
// tag, we take the all content of the <p> or <li> and avoid inserting the
|
||||
// <p> or <li>.
|
||||
if (
|
||||
container.childElementCount === 1 &&
|
||||
(this.dependencies.baseContainer.isCandidateForBaseContainer(container.firstChild) ||
|
||||
shouldUnwrap(container.firstChild))
|
||||
) {
|
||||
const nodeToUnwrap = container.firstElementChild;
|
||||
container.replaceChildren(...childNodes(nodeToUnwrap));
|
||||
} else if (container.childElementCount > 1) {
|
||||
const isSelectionAtStart =
|
||||
firstLeaf(block) === selection.anchorNode && selection.anchorOffset === 0;
|
||||
const isSelectionAtEnd =
|
||||
lastLeaf(block) === selection.focusNode &&
|
||||
selection.focusOffset === nodeSize(selection.focusNode);
|
||||
// Grab the content of the first child block and isolate it.
|
||||
if (shouldUnwrap(container.firstChild) && !isSelectionAtStart) {
|
||||
// Unwrap the deepest nested first <li> element in the
|
||||
// container to extract and paste the text content of the list.
|
||||
if (isListItemElement(container.firstChild)) {
|
||||
const deepestBlock = closestBlock(firstLeaf(container.firstChild));
|
||||
this.dependencies.split.splitAroundUntil(deepestBlock, container.firstChild);
|
||||
container.firstElementChild.replaceChildren(...childNodes(deepestBlock));
|
||||
}
|
||||
containerFirstChild.replaceChildren(...childNodes(container.firstElementChild));
|
||||
container.firstElementChild.remove();
|
||||
}
|
||||
// Grab the content of the last child block and isolate it.
|
||||
if (shouldUnwrap(container.lastChild) && !isSelectionAtEnd) {
|
||||
// Unwrap the deepest nested last <li> element in the container
|
||||
// to extract and paste the text content of the list.
|
||||
if (isListItemElement(container.lastChild)) {
|
||||
const deepestBlock = closestBlock(lastLeaf(container.lastChild));
|
||||
this.dependencies.split.splitAroundUntil(deepestBlock, container.lastChild);
|
||||
container.lastElementChild.replaceChildren(...childNodes(deepestBlock));
|
||||
}
|
||||
containerLastChild.replaceChildren(...childNodes(container.lastElementChild));
|
||||
container.lastElementChild.remove();
|
||||
}
|
||||
}
|
||||
|
||||
if (startNode.nodeType === Node.ELEMENT_NODE) {
|
||||
if (selection.anchorOffset === 0) {
|
||||
const textNode = this.document.createTextNode("");
|
||||
if (isSelfClosingElement(startNode)) {
|
||||
startNode.parentNode.insertBefore(textNode, startNode);
|
||||
} else {
|
||||
startNode.prepend(textNode);
|
||||
}
|
||||
startNode = textNode;
|
||||
allInsertedNodes.push(textNode);
|
||||
} else {
|
||||
startNode = childNodes(startNode).at(selection.anchorOffset - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have isolated block content, first we split the current focus
|
||||
// element if it's a block then we insert the content in the right places.
|
||||
let currentNode = startNode;
|
||||
const _insertAt = (reference, nodes, insertBefore) => {
|
||||
for (const child of insertBefore ? nodes.reverse() : nodes) {
|
||||
reference[insertBefore ? "before" : "after"](child);
|
||||
reference = child;
|
||||
}
|
||||
};
|
||||
const lastInsertedNodes = childNodes(containerLastChild);
|
||||
if (containerLastChild.hasChildNodes()) {
|
||||
const toInsert = childNodes(containerLastChild); // Prevent mutation
|
||||
_insertAt(currentNode, [...toInsert], insertBefore);
|
||||
currentNode = insertBefore ? toInsert[0] : currentNode;
|
||||
toInsert[toInsert.length - 1];
|
||||
}
|
||||
const firstInsertedNodes = childNodes(containerFirstChild);
|
||||
if (containerFirstChild.hasChildNodes()) {
|
||||
const toInsert = childNodes(containerFirstChild); // Prevent mutation
|
||||
_insertAt(currentNode, [...toInsert], insertBefore);
|
||||
currentNode = toInsert[toInsert.length - 1];
|
||||
insertBefore = false;
|
||||
}
|
||||
allInsertedNodes.push(...firstInsertedNodes);
|
||||
|
||||
// If all the Html have been isolated, We force a split of the parent element
|
||||
// to have the need new line in the final result
|
||||
if (!container.hasChildNodes()) {
|
||||
if (this.dependencies.split.isUnsplittable(closestBlock(currentNode.nextSibling))) {
|
||||
this.dependencies.lineBreak.insertLineBreakNode({
|
||||
targetNode: currentNode.nextSibling,
|
||||
targetOffset: 0,
|
||||
});
|
||||
} else {
|
||||
// If we arrive here, the o_enter index should always be 0.
|
||||
const parent = currentNode.nextSibling.parentElement;
|
||||
const index = childNodes(parent).indexOf(currentNode.nextSibling);
|
||||
this.dependencies.split.splitBlockNode({
|
||||
targetNode: parent,
|
||||
targetOffset: index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let nodeToInsert;
|
||||
let doesCurrentNodeAllowsP = allowsParagraphRelatedElements(currentNode);
|
||||
const candidatesForRemoval = [];
|
||||
const insertedNodes = childNodes(container);
|
||||
while ((nodeToInsert = container.firstChild)) {
|
||||
if (isBlock(nodeToInsert) && !doesCurrentNodeAllowsP) {
|
||||
// Split blocks at the edges if inserting new blocks (preventing
|
||||
// <p><p>text</p></p> or <li><li>text</li></li> scenarios).
|
||||
while (
|
||||
!this.isEditionBoundary(currentNode.parentElement) &&
|
||||
(!allowsParagraphRelatedElements(currentNode.parentElement) ||
|
||||
(isListItemElement(currentNode.parentElement) &&
|
||||
!this.dependencies.split.isUnsplittable(nodeToInsert)))
|
||||
) {
|
||||
if (this.dependencies.split.isUnsplittable(currentNode.parentElement)) {
|
||||
// If we have to insert an unsplittable element, we cannot afford to
|
||||
// unwrap it we need to search for a more suitable spot to put it
|
||||
if (this.dependencies.split.isUnsplittable(nodeToInsert)) {
|
||||
currentNode = currentNode.parentElement;
|
||||
doesCurrentNodeAllowsP = allowsParagraphRelatedElements(currentNode);
|
||||
continue;
|
||||
} else {
|
||||
makeContentsInline(container);
|
||||
nodeToInsert = container.firstChild;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let offset = childNodeIndex(currentNode);
|
||||
if (!insertBefore) {
|
||||
offset += 1;
|
||||
}
|
||||
if (offset) {
|
||||
const [left, right] = this.dependencies.split.splitElement(
|
||||
currentNode.parentElement,
|
||||
offset
|
||||
);
|
||||
currentNode = insertBefore ? right : left;
|
||||
const otherNode = insertBefore ? left : right;
|
||||
if (isBlock(otherNode)) {
|
||||
fillShrunkPhrasingParent(otherNode);
|
||||
}
|
||||
// After the content insertion, the right-part of a
|
||||
// split is evaluated for removal, if it is unnecessary
|
||||
// (to guarantee a paragraph-related element
|
||||
// after the last unsplittable inserted element).
|
||||
candidatesForRemoval.push(right);
|
||||
} else {
|
||||
if (isBlock(currentNode)) {
|
||||
fillShrunkPhrasingParent(currentNode);
|
||||
}
|
||||
currentNode = currentNode.parentElement;
|
||||
}
|
||||
doesCurrentNodeAllowsP = allowsParagraphRelatedElements(currentNode);
|
||||
}
|
||||
if (
|
||||
isListItemElement(currentNode.parentElement) &&
|
||||
isBlock(nodeToInsert) &&
|
||||
this.dependencies.split.isUnsplittable(nodeToInsert)
|
||||
) {
|
||||
const br = document.createElement("br");
|
||||
currentNode[
|
||||
isEmptyBlock(currentNode) || !isTangible(currentNode) ? "before" : "after"
|
||||
](br);
|
||||
}
|
||||
}
|
||||
// Ensure that all adjacent paragraph elements are converted to
|
||||
// <li> when inserting in a list.
|
||||
const container = closestBlock(currentNode);
|
||||
for (const processor of this.getResource("node_to_insert_processors")) {
|
||||
nodeToInsert = processor({ nodeToInsert, container });
|
||||
}
|
||||
if (insertBefore) {
|
||||
currentNode.before(nodeToInsert);
|
||||
insertBefore = false;
|
||||
} else {
|
||||
currentNode.after(nodeToInsert);
|
||||
}
|
||||
allInsertedNodes.push(nodeToInsert);
|
||||
if (currentNode.tagName !== "BR" && isShrunkBlock(currentNode)) {
|
||||
currentNode.remove();
|
||||
}
|
||||
currentNode = nodeToInsert;
|
||||
}
|
||||
allInsertedNodes.push(...lastInsertedNodes);
|
||||
this.getResource("after_insert_handlers").forEach((handler) => handler(allInsertedNodes));
|
||||
let insertedNodesParents = getConnectedParents(allInsertedNodes);
|
||||
for (const parent of insertedNodesParents) {
|
||||
if (
|
||||
!this.config.allowInlineAtRoot &&
|
||||
this.isEditionBoundary(parent) &&
|
||||
allowsParagraphRelatedElements(parent)
|
||||
) {
|
||||
// Ensure that edition boundaries do not have inline content.
|
||||
wrapInlinesInBlocks(parent, {
|
||||
baseContainerNodeName: this.dependencies.baseContainer.getDefaultNodeName(),
|
||||
});
|
||||
}
|
||||
}
|
||||
insertedNodesParents = getConnectedParents(allInsertedNodes);
|
||||
for (const parent of insertedNodesParents) {
|
||||
if (
|
||||
!isProtecting(parent) &&
|
||||
!(isProtected(parent) && !isUnprotecting(parent)) &&
|
||||
parent.isContentEditable
|
||||
) {
|
||||
cleanTrailingBR(parent, [
|
||||
(node) => {
|
||||
// Don't remove the last BR in cases where the
|
||||
// previous sibling is an unsplittable block
|
||||
// (i.e. a table, a non-editable div, ...)
|
||||
// to allow placing the cursor after that unsplittable
|
||||
// element. This can be removed when the cursor
|
||||
// is properly handled around these elements.
|
||||
const previousSibling = node.previousSibling;
|
||||
return (
|
||||
previousSibling &&
|
||||
isBlock(previousSibling) &&
|
||||
this.dependencies.split.isUnsplittable(previousSibling)
|
||||
);
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
for (const candidateForRemoval of candidatesForRemoval) {
|
||||
// Ensure that a paragraph related element is present after the last
|
||||
// unsplittable inserted element
|
||||
if (
|
||||
candidateForRemoval.isConnected &&
|
||||
(isParagraphRelatedElement(candidateForRemoval) ||
|
||||
isListItemElement(candidateForRemoval)) &&
|
||||
candidateForRemoval.parentElement.isContentEditable &&
|
||||
isEmptyBlock(candidateForRemoval) &&
|
||||
((candidateForRemoval.previousElementSibling &&
|
||||
!this.dependencies.split.isUnsplittable(
|
||||
candidateForRemoval.previousElementSibling
|
||||
)) ||
|
||||
(candidateForRemoval.nextElementSibling &&
|
||||
!this.dependencies.split.isUnsplittable(
|
||||
candidateForRemoval.nextElementSibling
|
||||
)))
|
||||
) {
|
||||
candidateForRemoval.remove();
|
||||
}
|
||||
}
|
||||
for (const insertedNode of allInsertedNodes.reverse()) {
|
||||
if (insertedNode.isConnected) {
|
||||
currentNode = insertedNode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let lastPosition =
|
||||
isParagraphRelatedElement(currentNode) ||
|
||||
isListItemElement(currentNode) ||
|
||||
isListElement(currentNode)
|
||||
? rightPos(lastLeaf(currentNode))
|
||||
: rightPos(currentNode);
|
||||
lastPosition = normalizeCursorPosition(lastPosition[0], lastPosition[1], "right");
|
||||
|
||||
if (!this.config.allowInlineAtRoot && this.isEditionBoundary(lastPosition[0])) {
|
||||
// Correct the position if it happens to be in the editable root.
|
||||
lastPosition = getDeepestPosition(...lastPosition);
|
||||
}
|
||||
this.dependencies.selection.setSelection(
|
||||
{ anchorNode: lastPosition[0], anchorOffset: lastPosition[1] },
|
||||
{ normalize: false }
|
||||
);
|
||||
return firstInsertedNodes.concat(insertedNodes).concat(lastInsertedNodes);
|
||||
}
|
||||
|
||||
isEditionBoundary(node) {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
if (node === this.editable) {
|
||||
return true;
|
||||
}
|
||||
return isContentEditableAncestor(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} source
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
copyAttributes(source, target) {
|
||||
if (source?.nodeType !== Node.ELEMENT_NODE || target?.nodeType !== Node.ELEMENT_NODE) {
|
||||
return;
|
||||
}
|
||||
const ignoredAttrs = new Set(this.getResource("system_attributes"));
|
||||
const ignoredClasses = new Set(this.getResource("system_classes"));
|
||||
for (const attr of source.attributes) {
|
||||
if (ignoredAttrs.has(attr.name)) {
|
||||
continue;
|
||||
}
|
||||
if (attr.name !== "class" || ignoredClasses.size === 0) {
|
||||
target.setAttribute(attr.name, attr.value);
|
||||
} else {
|
||||
const classes = [...source.classList];
|
||||
for (const className of classes) {
|
||||
if (!ignoredClasses.has(className)) {
|
||||
target.classList.add(className);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic method to change an element tagName.
|
||||
* It is a technical function which only modifies a tag and its attributes.
|
||||
* It does not modify descendants nor handle the cursor.
|
||||
* @see setBlock for the more thorough command.
|
||||
*
|
||||
* @param {HTMLElement} el
|
||||
* @param {string} newTagName
|
||||
*/
|
||||
setTagName(el, newTagName) {
|
||||
const document = el.ownerDocument;
|
||||
if (el.tagName === newTagName) {
|
||||
return el;
|
||||
}
|
||||
const newEl = document.createElement(newTagName);
|
||||
const content = childNodes(el);
|
||||
if (isListItemElement(el)) {
|
||||
el.append(newEl);
|
||||
newEl.replaceChildren(...content);
|
||||
} else {
|
||||
if (el.parentElement) {
|
||||
el.before(newEl);
|
||||
}
|
||||
this.copyAttributes(el, newEl);
|
||||
newEl.replaceChildren(...content);
|
||||
el.remove();
|
||||
}
|
||||
return newEl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove system-specific classes, attributes, and style properties from a
|
||||
* fragment or an element.
|
||||
*
|
||||
* @param {DocumentFragment|HTMLElement} root
|
||||
*/
|
||||
removeSystemProperties(root) {
|
||||
const clean = (element) => {
|
||||
removeClass(element, ...this.systemClasses);
|
||||
this.systemAttributes.forEach((attr) => element.removeAttribute(attr));
|
||||
removeStyle(element, ...this.systemStyleProperties);
|
||||
};
|
||||
if (root.matches?.(this.systemPropertiesSelector)) {
|
||||
clean(root);
|
||||
}
|
||||
for (const element of root.querySelectorAll(this.systemPropertiesSelector)) {
|
||||
clean(element);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// commands
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
insertFontAwesome({ faClass = "fa fa-star" } = {}) {
|
||||
const fontAwesomeNode = document.createElement("i");
|
||||
fontAwesomeNode.className = faClass;
|
||||
this.insert(fontAwesomeNode);
|
||||
this.dependencies.history.addStep();
|
||||
const [anchorNode, anchorOffset] = rightPos(fontAwesomeNode);
|
||||
this.dependencies.selection.setSelection({ anchorNode, anchorOffset });
|
||||
}
|
||||
|
||||
getBlocksToSet() {
|
||||
const targetedBlocks = [...this.dependencies.selection.getTargetedBlocks()];
|
||||
return targetedBlocks.filter(
|
||||
(block) =>
|
||||
!descendants(block).some((descendant) => targetedBlocks.includes(descendant)) &&
|
||||
block.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
canSetBlock() {
|
||||
return this.getBlocksToSet().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} param0
|
||||
* @param {string} param0.tagName
|
||||
* @param {string} [param0.extraClass]
|
||||
*/
|
||||
setBlock({ tagName, extraClass = "" }) {
|
||||
let newCandidate = this.document.createElement(tagName.toUpperCase());
|
||||
if (extraClass) {
|
||||
newCandidate.classList.add(extraClass);
|
||||
}
|
||||
if (this.dependencies.baseContainer.isCandidateForBaseContainer(newCandidate)) {
|
||||
const baseContainer = this.dependencies.baseContainer.createBaseContainer(
|
||||
newCandidate.nodeName
|
||||
);
|
||||
this.copyAttributes(newCandidate, baseContainer);
|
||||
newCandidate = baseContainer;
|
||||
}
|
||||
const cursors = this.dependencies.selection.preserveSelection();
|
||||
const newEls = [];
|
||||
for (const block of this.getBlocksToSet()) {
|
||||
if (
|
||||
isParagraphRelatedElement(block) ||
|
||||
isListItemElement(block) ||
|
||||
block.nodeName === "BLOCKQUOTE"
|
||||
) {
|
||||
if (newCandidate.matches(baseContainerGlobalSelector) && isListItemElement(block)) {
|
||||
continue;
|
||||
}
|
||||
const newEl = this.setTagName(block, tagName);
|
||||
cursors.remapNode(block, newEl);
|
||||
// We want to be able to edit the case `<h2 class="h3">`
|
||||
// but in that case, we want to display "Header 2" and
|
||||
// not "Header 3" as it is more important to display
|
||||
// the semantic tag being used (especially for h1 ones).
|
||||
// This is why those are not in `TEXT_STYLE_CLASSES`.
|
||||
const headingClasses = ["h1", "h2", "h3", "h4", "h5", "h6"];
|
||||
removeClass(newEl, ...FONT_SIZE_CLASSES, ...TEXT_STYLE_CLASSES, ...headingClasses);
|
||||
delete newEl.style.fontSize;
|
||||
if (extraClass) {
|
||||
newEl.classList.add(extraClass);
|
||||
}
|
||||
newEls.push(newEl);
|
||||
} else {
|
||||
// eg do not change a <div> into a h1: insert the h1
|
||||
// into it instead.
|
||||
newCandidate.append(...childNodes(block));
|
||||
block.append(newCandidate);
|
||||
cursors.remapNode(block, newCandidate);
|
||||
}
|
||||
}
|
||||
cursors.restore();
|
||||
this.dispatchTo("set_tag_handlers", newEls);
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
|
||||
removeEmptyClassAndStyleAttributes(root) {
|
||||
for (const node of [root, ...descendants(root)]) {
|
||||
if (node.classList && !node.classList.length) {
|
||||
node.removeAttribute("class");
|
||||
}
|
||||
if (node.style && !node.style.length) {
|
||||
node.removeAttribute("style");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import {
|
||||
htmlEditorVersions,
|
||||
stripVersion,
|
||||
VERSION_SELECTOR,
|
||||
} from "@html_editor/html_migrations/html_migrations_utils";
|
||||
import { Plugin } from "@html_editor/plugin";
|
||||
|
||||
export class EditorVersionPlugin extends Plugin {
|
||||
static id = "editorVersion";
|
||||
resources = {
|
||||
clean_for_save_handlers: this.cleanForSave.bind(this),
|
||||
normalize_handlers: this.normalize.bind(this),
|
||||
};
|
||||
|
||||
normalize(element) {
|
||||
if (element.matches(VERSION_SELECTOR) && element !== this.editable) {
|
||||
delete element.dataset.oeVersion;
|
||||
}
|
||||
stripVersion(element);
|
||||
}
|
||||
|
||||
cleanForSave({ root }) {
|
||||
const VERSIONS = htmlEditorVersions();
|
||||
const firstChild = root.firstElementChild;
|
||||
const version = VERSIONS.at(-1);
|
||||
if (firstChild && version) {
|
||||
firstChild.dataset.oeVersion = version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,708 @@
|
|||
import { prepareUpdate } from "@html_editor/utils/dom_state";
|
||||
import { withSequence } from "@html_editor/utils/resource";
|
||||
import { callbacksForCursorUpdate } from "@html_editor/utils/selection";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Plugin } from "../plugin";
|
||||
import { closestBlock, isBlock } from "../utils/blocks";
|
||||
import { cleanTextNode, fillEmpty, removeClass, splitTextNode, unwrapContents } from "../utils/dom";
|
||||
import {
|
||||
areSimilarElements,
|
||||
isContentEditable,
|
||||
isElement,
|
||||
isEmptyBlock,
|
||||
isEmptyTextNode,
|
||||
isParagraphRelatedElement,
|
||||
isSelfClosingElement,
|
||||
isTextNode,
|
||||
isVisibleTextNode,
|
||||
isZwnbsp,
|
||||
isZWS,
|
||||
previousLeaf,
|
||||
} from "../utils/dom_info";
|
||||
import { isFakeLineBreak } from "../utils/dom_state";
|
||||
import {
|
||||
childNodes,
|
||||
closestElement,
|
||||
descendants,
|
||||
findFurthest,
|
||||
selectElements,
|
||||
} from "../utils/dom_traversal";
|
||||
import { formatsSpecs, FORMATTABLE_TAGS } from "../utils/formatting";
|
||||
import { boundariesIn, boundariesOut, DIRECTIONS, leftPos, rightPos } from "../utils/position";
|
||||
import { isHtmlContentSupported } from "@html_editor/core/selection_plugin";
|
||||
|
||||
const allWhitespaceRegex = /^[\s\u200b]*$/;
|
||||
|
||||
function isFormatted(formatPlugin, format) {
|
||||
return (sel, nodes) => formatPlugin.isSelectionFormat(format, nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} FormatShared
|
||||
* @property { FormatPlugin['isSelectionFormat'] } isSelectionFormat
|
||||
* @property { FormatPlugin['insertAndSelectZws'] } insertAndSelectZws
|
||||
* @property { FormatPlugin['mergeAdjacentInlines'] } mergeAdjacentInlines
|
||||
* @property { FormatPlugin['formatSelection'] } formatSelection
|
||||
*/
|
||||
|
||||
export class FormatPlugin extends Plugin {
|
||||
static id = "format";
|
||||
static dependencies = ["selection", "history", "input", "split"];
|
||||
// TODO ABD: refactor to handle Knowledge comments inside this plugin without sharing mergeAdjacentInlines.
|
||||
static shared = [
|
||||
"isSelectionFormat",
|
||||
"insertAndSelectZws",
|
||||
"mergeAdjacentInlines",
|
||||
"formatSelection",
|
||||
];
|
||||
resources = {
|
||||
user_commands: [
|
||||
{
|
||||
id: "formatBold",
|
||||
description: _t("Toggle bold"),
|
||||
icon: "fa-bold",
|
||||
run: this.formatSelection.bind(this, "bold"),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "formatItalic",
|
||||
description: _t("Toggle italic"),
|
||||
icon: "fa-italic",
|
||||
run: this.formatSelection.bind(this, "italic"),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "formatUnderline",
|
||||
description: _t("Toggle underline"),
|
||||
icon: "fa-underline",
|
||||
run: this.formatSelection.bind(this, "underline"),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "formatStrikethrough",
|
||||
description: _t("Toggle strikethrough"),
|
||||
icon: "fa-strikethrough",
|
||||
run: this.formatSelection.bind(this, "strikeThrough"),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "formatFontSize",
|
||||
run: ({ size }) =>
|
||||
this.formatSelection("fontSize", {
|
||||
applyStyle: true,
|
||||
formatProps: { size },
|
||||
}),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "formatFontSizeClassName",
|
||||
run: ({ className }) =>
|
||||
this.formatSelection("setFontSizeClassName", {
|
||||
applyStyle: true,
|
||||
formatProps: { className },
|
||||
}),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
{
|
||||
id: "removeFormat",
|
||||
description: (sel, nodes) =>
|
||||
nodes && this.hasAnyFormat(nodes)
|
||||
? _t("Remove Format")
|
||||
: _t("Selection has no format"),
|
||||
icon: "fa-eraser",
|
||||
run: this.removeAllFormats.bind(this),
|
||||
isAvailable: isHtmlContentSupported,
|
||||
},
|
||||
],
|
||||
shortcuts: [
|
||||
{ hotkey: "control+b", commandId: "formatBold" },
|
||||
{ hotkey: "control+i", commandId: "formatItalic" },
|
||||
{ hotkey: "control+u", commandId: "formatUnderline" },
|
||||
{ hotkey: "control+5", commandId: "formatStrikethrough" },
|
||||
{ hotkey: "control+space", commandId: "removeFormat" },
|
||||
],
|
||||
toolbar_groups: withSequence(20, { id: "decoration" }),
|
||||
toolbar_items: [
|
||||
{
|
||||
id: "bold",
|
||||
groupId: "decoration",
|
||||
namespaces: ["compact", "expanded"],
|
||||
commandId: "formatBold",
|
||||
isActive: isFormatted(this, "bold"),
|
||||
},
|
||||
{
|
||||
id: "italic",
|
||||
groupId: "decoration",
|
||||
namespaces: ["compact", "expanded"],
|
||||
commandId: "formatItalic",
|
||||
isActive: isFormatted(this, "italic"),
|
||||
},
|
||||
{
|
||||
id: "underline",
|
||||
groupId: "decoration",
|
||||
namespaces: ["compact", "expanded"],
|
||||
commandId: "formatUnderline",
|
||||
isActive: isFormatted(this, "underline"),
|
||||
},
|
||||
{
|
||||
id: "strikethrough",
|
||||
groupId: "decoration",
|
||||
commandId: "formatStrikethrough",
|
||||
isActive: isFormatted(this, "strikeThrough"),
|
||||
},
|
||||
withSequence(20, {
|
||||
id: "remove_format",
|
||||
groupId: "decoration",
|
||||
commandId: "removeFormat",
|
||||
isDisabled: (sel, nodes) => !this.hasAnyFormat(nodes),
|
||||
}),
|
||||
],
|
||||
/** Handlers */
|
||||
beforeinput_handlers: withSequence(20, this.onBeforeInput.bind(this)),
|
||||
clean_for_save_handlers: this.cleanForSave.bind(this),
|
||||
normalize_handlers: this.normalize.bind(this),
|
||||
selectionchange_handlers: this.removeEmptyInlineElement.bind(this),
|
||||
set_tag_handlers: this.removeFontSizeFormat.bind(this),
|
||||
before_insert_processors: this.unwrapEmptyFormat.bind(this),
|
||||
|
||||
intangible_char_for_keyboard_navigation_predicates: (_, char) => char === "\u200b",
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string[]} formats
|
||||
* @param {Node[]} targetedNodes
|
||||
*/
|
||||
removeFormats(formats, targetedNodes) {
|
||||
for (const format of formats) {
|
||||
if (
|
||||
!formatsSpecs[format].removeStyle ||
|
||||
!this.hasSelectionFormat(format, targetedNodes)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
this.formatSelection(format, { applyStyle: false, removeFormat: true });
|
||||
}
|
||||
}
|
||||
|
||||
unwrapEmptyFormat(insertedNode, block) {
|
||||
const anchorNode = this.dependencies.selection.getEditableSelection().anchorNode;
|
||||
if (!block.contains(anchorNode)) {
|
||||
return insertedNode;
|
||||
}
|
||||
const emptyZWS = closestElement(anchorNode, "[data-oe-zws-empty-inline]");
|
||||
if (
|
||||
!emptyZWS ||
|
||||
!emptyZWS.parentElement.isContentEditable ||
|
||||
this.getResource("unremovable_node_predicates").some((p) => p(emptyZWS))
|
||||
) {
|
||||
return insertedNode;
|
||||
}
|
||||
const cursors = this.dependencies.selection.preserveSelection();
|
||||
cursors.update(callbacksForCursorUpdate.remove(emptyZWS));
|
||||
emptyZWS.remove();
|
||||
cursors.restore();
|
||||
return insertedNode;
|
||||
}
|
||||
|
||||
removeAllFormats() {
|
||||
const targetedNodes = this.dependencies.selection.getTargetedNodes();
|
||||
this.removeFormats(Object.keys(formatsSpecs), targetedNodes);
|
||||
this.dispatchTo("remove_all_formats_handlers");
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
|
||||
removeFontSizeFormat(els) {
|
||||
if (els.every((el) => isParagraphRelatedElement(el))) {
|
||||
const targetedNodes = this.dependencies.selection.getTargetedNodes();
|
||||
this.removeFormats(["fontSize", "setFontSizeClassName"], targetedNodes);
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the current selection on the editable contains a formated
|
||||
* node
|
||||
*
|
||||
* @param {String} format 'bold'|'italic'|'underline'|'strikeThrough'|'switchDirection'
|
||||
* @param {Node[]} [targetedNodes]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasSelectionFormat(format, targetedNodes = this.dependencies.selection.getTargetedNodes()) {
|
||||
const targetedTextNodes = targetedNodes.filter(isTextNode);
|
||||
const isFormatted = formatsSpecs[format].isFormatted;
|
||||
return targetedTextNodes.some((n) => isFormatted(n, { editable: this.editable }));
|
||||
}
|
||||
/**
|
||||
* Return true if the current selection on the editable appears as the given
|
||||
* format. The selection is considered to appear as that format if every
|
||||
* text node in it appears as that format.
|
||||
*
|
||||
* @param {String} format 'bold'|'italic'|'underline'|'strikeThrough'|'switchDirection'
|
||||
* @param {Node[]} [targetedNodes]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSelectionFormat(format, targetedNodes = this.dependencies.selection.getTargetedNodes()) {
|
||||
const targetedTextNodes = targetedNodes.filter(isTextNode);
|
||||
const isFormatted = formatsSpecs[format].isFormatted;
|
||||
return (
|
||||
targetedTextNodes.length &&
|
||||
targetedTextNodes.every(
|
||||
(node) =>
|
||||
isZwnbsp(node) ||
|
||||
isEmptyTextNode(node) ||
|
||||
isFormatted(node, { editable: this.editable })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
hasAnyFormat(targetedNodes) {
|
||||
for (const format of Object.keys(formatsSpecs)) {
|
||||
if (
|
||||
formatsSpecs[format].removeStyle &&
|
||||
this.hasSelectionFormat(format, targetedNodes)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return targetedNodes.some((node) =>
|
||||
this.getResource("has_format_predicates").some((predicate) => predicate(node))
|
||||
);
|
||||
}
|
||||
|
||||
formatSelection(formatName, options) {
|
||||
this.dispatchTo("format_selection_handlers", formatName, options);
|
||||
if (this._formatSelection(formatName, options) && !options?.removeFormat) {
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
}
|
||||
|
||||
// @todo phoenix: refactor this method.
|
||||
_formatSelection(formatName, { applyStyle, formatProps } = {}) {
|
||||
this.dependencies.selection.selectAroundNonEditable();
|
||||
// note: does it work if selection is in opposite direction?
|
||||
const selection = this.dependencies.split.splitSelection();
|
||||
if (typeof applyStyle === "undefined") {
|
||||
applyStyle = !this.isSelectionFormat(formatName);
|
||||
}
|
||||
|
||||
let zws;
|
||||
if (selection.isCollapsed) {
|
||||
if (isTextNode(selection.anchorNode) && selection.anchorNode.textContent === "\u200b") {
|
||||
zws = selection.anchorNode;
|
||||
this.dependencies.selection.setSelection({
|
||||
anchorNode: zws,
|
||||
anchorOffset: 0,
|
||||
focusNode: zws,
|
||||
focusOffset: 1,
|
||||
});
|
||||
} else {
|
||||
zws = this.insertAndSelectZws();
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTextNodes = /** @type { Text[] } **/ (
|
||||
this.dependencies.selection
|
||||
.getTargetedNodes()
|
||||
.filter(
|
||||
(n) =>
|
||||
this.dependencies.selection.areNodeContentsFullySelected(n) &&
|
||||
((isTextNode(n) && (isVisibleTextNode(n) || isZWS(n))) ||
|
||||
(n.nodeName === "BR" &&
|
||||
(isFakeLineBreak(n) ||
|
||||
previousLeaf(n, closestBlock(n))?.nodeName === "BR"))) &&
|
||||
isContentEditable(n)
|
||||
)
|
||||
);
|
||||
const unformattedTextNodes = selectedTextNodes.filter((n) => {
|
||||
const listItem = closestElement(n, "li");
|
||||
if (listItem && this.dependencies.selection.areNodeContentsFullySelected(listItem)) {
|
||||
const hasFontSizeStyle =
|
||||
formatName === "setFontSizeClassName"
|
||||
? listItem.classList.contains(formatProps?.className)
|
||||
: listItem.style.fontSize;
|
||||
return !hasFontSizeStyle;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const tagetedFieldNodes = new Set(
|
||||
this.dependencies.selection
|
||||
.getTargetedNodes()
|
||||
.map((n) => closestElement(n, "*[t-field],*[t-out],*[t-esc]"))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const formatSpec = formatsSpecs[formatName];
|
||||
for (const node of unformattedTextNodes) {
|
||||
const inlineAncestors = [];
|
||||
/** @type { Node } */
|
||||
let currentNode = node;
|
||||
let parentNode = node.parentElement;
|
||||
|
||||
// Remove the format on all inline ancestors until a block or an element
|
||||
// with a class that is not indicated as splittable.
|
||||
const isClassListSplittable = (classList) =>
|
||||
[...classList].every((className) =>
|
||||
this.getResource("format_class_predicates").some((cb) => cb(className))
|
||||
);
|
||||
|
||||
while (
|
||||
parentNode &&
|
||||
!isBlock(parentNode) &&
|
||||
!this.dependencies.split.isUnsplittable(parentNode) &&
|
||||
(parentNode.classList.length === 0 || isClassListSplittable(parentNode.classList))
|
||||
) {
|
||||
const isUselessZws =
|
||||
parentNode.tagName === "SPAN" &&
|
||||
parentNode.hasAttribute("data-oe-zws-empty-inline") &&
|
||||
parentNode.getAttributeNames().length === 1;
|
||||
|
||||
if (isUselessZws) {
|
||||
unwrapContents(parentNode);
|
||||
} else {
|
||||
const newLastAncestorInlineFormat = this.dependencies.split.splitAroundUntil(
|
||||
currentNode,
|
||||
parentNode
|
||||
);
|
||||
removeFormat(newLastAncestorInlineFormat, formatSpec);
|
||||
if (["setFontSizeClassName", "fontSize"].includes(formatName) && applyStyle) {
|
||||
removeClass(newLastAncestorInlineFormat, "o_default_font_size");
|
||||
}
|
||||
if (newLastAncestorInlineFormat.isConnected) {
|
||||
inlineAncestors.push(newLastAncestorInlineFormat);
|
||||
currentNode = newLastAncestorInlineFormat;
|
||||
}
|
||||
}
|
||||
|
||||
parentNode = currentNode.parentElement;
|
||||
}
|
||||
|
||||
const firstBlockOrClassHasFormat = formatSpec.isFormatted(parentNode, formatProps);
|
||||
if (firstBlockOrClassHasFormat && !applyStyle) {
|
||||
formatSpec.addNeutralStyle &&
|
||||
formatSpec.addNeutralStyle(getOrCreateSpan(node, inlineAncestors));
|
||||
} else if (
|
||||
(!firstBlockOrClassHasFormat || parentNode.nodeName === "LI") &&
|
||||
applyStyle
|
||||
) {
|
||||
const tag = formatSpec.tagName && this.document.createElement(formatSpec.tagName);
|
||||
if (tag) {
|
||||
node.after(tag);
|
||||
tag.append(node);
|
||||
|
||||
if (!formatSpec.isFormatted(tag, formatProps)) {
|
||||
tag.after(node);
|
||||
tag.remove();
|
||||
formatSpec.addStyle(getOrCreateSpan(node, inlineAncestors), formatProps);
|
||||
}
|
||||
} else if (formatName !== "fontSize" || formatProps.size !== undefined) {
|
||||
formatSpec.addStyle(getOrCreateSpan(node, inlineAncestors), formatProps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const targetedFieldNode of tagetedFieldNodes) {
|
||||
if (applyStyle) {
|
||||
formatSpec.addStyle(targetedFieldNode, formatProps);
|
||||
} else {
|
||||
formatSpec.removeStyle(targetedFieldNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (zws) {
|
||||
const siblings = [...zws.parentElement.childNodes];
|
||||
if (
|
||||
!isBlock(zws.parentElement) &&
|
||||
unformattedTextNodes.includes(siblings[0]) &&
|
||||
unformattedTextNodes.includes(siblings[siblings.length - 1])
|
||||
) {
|
||||
zws.parentElement.setAttribute("data-oe-zws-empty-inline", "");
|
||||
} else {
|
||||
const span = this.document.createElement("span");
|
||||
span.setAttribute("data-oe-zws-empty-inline", "");
|
||||
zws.before(span);
|
||||
span.append(zws);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
unformattedTextNodes.length === 1 &&
|
||||
unformattedTextNodes[0] &&
|
||||
unformattedTextNodes[0].textContent === "\u200B"
|
||||
) {
|
||||
this.dependencies.selection.setCursorStart(unformattedTextNodes[0]);
|
||||
} else if (selectedTextNodes.length) {
|
||||
const firstNode = selectedTextNodes[0];
|
||||
const lastNode = selectedTextNodes[selectedTextNodes.length - 1];
|
||||
let newSelection;
|
||||
if (selection.direction === DIRECTIONS.RIGHT) {
|
||||
newSelection = {
|
||||
anchorNode: firstNode,
|
||||
anchorOffset: 0,
|
||||
focusNode: lastNode,
|
||||
focusOffset: lastNode.length,
|
||||
};
|
||||
} else {
|
||||
newSelection = {
|
||||
anchorNode: lastNode,
|
||||
anchorOffset: lastNode.length,
|
||||
focusNode: firstNode,
|
||||
focusOffset: 0,
|
||||
};
|
||||
}
|
||||
this.dependencies.selection.setSelection(newSelection, { normalize: false });
|
||||
return true;
|
||||
}
|
||||
if (tagetedFieldNodes.size > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
normalize(root) {
|
||||
for (const el of selectElements(root, "[data-oe-zws-empty-inline]")) {
|
||||
if (!allWhitespaceRegex.test(el.textContent)) {
|
||||
// The element has some meaningful text. Remove the ZWS in it.
|
||||
delete el.dataset.oeZwsEmptyInline;
|
||||
this.cleanZWS(el);
|
||||
if (
|
||||
el.tagName === "SPAN" &&
|
||||
el.getAttributeNames().length === 0 &&
|
||||
el.classList.length === 0
|
||||
) {
|
||||
// Useless span, unwrap it.
|
||||
unwrapContents(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.mergeAdjacentInlines(root);
|
||||
}
|
||||
|
||||
cleanForSave({ root, preserveSelection = false } = {}) {
|
||||
for (const element of root.querySelectorAll("[data-oe-zws-empty-inline]")) {
|
||||
let currentElement = element.parentElement;
|
||||
this.cleanElement(element, { preserveSelection });
|
||||
while (
|
||||
currentElement &&
|
||||
!isBlock(currentElement) &&
|
||||
!currentElement.childNodes.length
|
||||
) {
|
||||
const parentElement = currentElement.parentElement;
|
||||
currentElement.remove();
|
||||
currentElement = parentElement;
|
||||
}
|
||||
if (currentElement && isBlock(currentElement)) {
|
||||
fillEmpty(currentElement);
|
||||
}
|
||||
}
|
||||
this.mergeAdjacentInlines(root, { preserveSelection });
|
||||
}
|
||||
|
||||
removeEmptyInlineElement(selectionData) {
|
||||
const { anchorNode } = selectionData.editableSelection;
|
||||
const blockEl = closestBlock(anchorNode);
|
||||
const inlineElement = findFurthest(
|
||||
closestElement(anchorNode),
|
||||
blockEl,
|
||||
(e) => !isBlock(e) && e.textContent === "\u200b"
|
||||
);
|
||||
if (
|
||||
this.lastEmptyInlineElement?.isConnected &&
|
||||
this.lastEmptyInlineElement !== inlineElement
|
||||
) {
|
||||
// Remove last empty inline element.
|
||||
this.cleanElement(this.lastEmptyInlineElement, { preserveSelection: true });
|
||||
}
|
||||
// Skip if current block is empty.
|
||||
if (inlineElement && !isEmptyBlock(blockEl)) {
|
||||
this.lastEmptyInlineElement = inlineElement;
|
||||
} else {
|
||||
this.lastEmptyInlineElement = null;
|
||||
}
|
||||
}
|
||||
|
||||
cleanElement(element, { preserveSelection }) {
|
||||
delete element.dataset.oeZwsEmptyInline;
|
||||
if (!allWhitespaceRegex.test(element.textContent)) {
|
||||
// The element has some meaningful text. Remove the ZWS in it.
|
||||
this.cleanZWS(element, { preserveSelection });
|
||||
return;
|
||||
}
|
||||
if (this.getResource("unremovable_node_predicates").some((p) => p(element))) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
![...element.classList].every((c) =>
|
||||
this.getResource("format_class_predicates").some((p) => p(c))
|
||||
)
|
||||
) {
|
||||
// Original comment from web_editor:
|
||||
// We only remove the empty element if it has no class, to ensure we
|
||||
// don't break visual styles (in that case, its ZWS was kept to
|
||||
// ensure the cursor can be placed in it).
|
||||
return;
|
||||
}
|
||||
const restore = prepareUpdate(...leftPos(element), ...rightPos(element));
|
||||
element.remove();
|
||||
restore();
|
||||
}
|
||||
|
||||
cleanZWS(element, { preserveSelection = true } = {}) {
|
||||
const textNodes = descendants(element).filter(isTextNode);
|
||||
const cursors = preserveSelection ? this.dependencies.selection.preserveSelection() : null;
|
||||
for (const node of textNodes) {
|
||||
cleanTextNode(node, "\u200B", cursors);
|
||||
}
|
||||
cursors?.restore();
|
||||
}
|
||||
|
||||
insertText(selection, content) {
|
||||
if (selection.anchorNode.nodeType === Node.TEXT_NODE) {
|
||||
selection = this.dependencies.selection.setSelection(
|
||||
{
|
||||
anchorNode: selection.anchorNode.parentElement,
|
||||
anchorOffset: splitTextNode(selection.anchorNode, selection.anchorOffset),
|
||||
},
|
||||
{ normalize: false }
|
||||
);
|
||||
}
|
||||
|
||||
const txt = this.document.createTextNode(content || "#");
|
||||
const restore = prepareUpdate(selection.anchorNode, selection.anchorOffset);
|
||||
selection.anchorNode.insertBefore(
|
||||
txt,
|
||||
selection.anchorNode.childNodes[selection.anchorOffset]
|
||||
);
|
||||
restore();
|
||||
const [anchorNode, anchorOffset, focusNode, focusOffset] = boundariesOut(txt);
|
||||
this.dependencies.selection.setSelection(
|
||||
{ anchorNode, anchorOffset, focusNode, focusOffset },
|
||||
{ normalize: false }
|
||||
);
|
||||
return txt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the actual selection (assumed to be collapsed) and insert a
|
||||
* zero-width space at its anchor point. Then, select that zero-width
|
||||
* space.
|
||||
*
|
||||
* @returns {Node} the inserted zero-width space
|
||||
*/
|
||||
insertAndSelectZws() {
|
||||
const selection = this.dependencies.selection.getEditableSelection();
|
||||
const zws = this.insertText(selection, "\u200B");
|
||||
splitTextNode(zws, selection.anchorOffset);
|
||||
return zws;
|
||||
}
|
||||
|
||||
onBeforeInput(ev) {
|
||||
if (
|
||||
ev.inputType.startsWith("format") &&
|
||||
!isHtmlContentSupported(this.dependencies.selection.getEditableSelection())
|
||||
) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
if (ev.inputType === "insertText") {
|
||||
const selection = this.dependencies.selection.getEditableSelection();
|
||||
if (!selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
const element = closestElement(selection.anchorNode);
|
||||
if (element.hasAttribute("data-oe-zws-empty-inline")) {
|
||||
// Select its ZWS content to make sure the text will be
|
||||
// inserted inside the element, and not before (outside) it.
|
||||
// This addresses an undesired behavior of the
|
||||
// contenteditable.
|
||||
const [anchorNode, anchorOffset, focusNode, focusOffset] = boundariesIn(element);
|
||||
this.dependencies.selection.setSelection({
|
||||
anchorNode,
|
||||
anchorOffset,
|
||||
focusNode,
|
||||
focusOffset,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Node} root
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.preserveSelection=true]
|
||||
*/
|
||||
mergeAdjacentInlines(root, { preserveSelection = true } = {}) {
|
||||
let selectionToRestore = null;
|
||||
for (const node of [root, ...descendants(root)].filter(isElement)) {
|
||||
if (this.shouldBeMergedWithPreviousSibling(node)) {
|
||||
if (preserveSelection) {
|
||||
selectionToRestore ??= this.dependencies.selection.preserveSelection();
|
||||
selectionToRestore.update(callbacksForCursorUpdate.merge(node));
|
||||
}
|
||||
node.previousSibling.append(...childNodes(node));
|
||||
node.remove();
|
||||
}
|
||||
}
|
||||
selectionToRestore?.restore();
|
||||
}
|
||||
|
||||
shouldBeMergedWithPreviousSibling(node) {
|
||||
const isMergeable = (node) =>
|
||||
FORMATTABLE_TAGS.includes(node.nodeName) &&
|
||||
!this.getResource("unsplittable_node_predicates").some((predicate) => predicate(node));
|
||||
return (
|
||||
!isSelfClosingElement(node) &&
|
||||
areSimilarElements(node, node.previousSibling) &&
|
||||
isMergeable(node)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateSpan(node, ancestors) {
|
||||
const document = node.ownerDocument;
|
||||
const span = ancestors.find((element) => element.tagName === "SPAN" && element.isConnected);
|
||||
const lastInlineAncestor = ancestors.findLast(
|
||||
(element) => !isBlock(element) && element.isConnected
|
||||
);
|
||||
if (span) {
|
||||
return span;
|
||||
} else {
|
||||
const span = document.createElement("span");
|
||||
// Apply font span above current inline top ancestor so that
|
||||
// the font style applies to the other style tags as well.
|
||||
if (lastInlineAncestor) {
|
||||
lastInlineAncestor.after(span);
|
||||
span.append(lastInlineAncestor);
|
||||
} else {
|
||||
node.after(span);
|
||||
span.append(node);
|
||||
}
|
||||
return span;
|
||||
}
|
||||
}
|
||||
function removeFormat(node, formatSpec) {
|
||||
const document = node.ownerDocument;
|
||||
node = closestElement(node);
|
||||
if (formatSpec.hasStyle(node)) {
|
||||
formatSpec.removeStyle(node);
|
||||
if (["SPAN", "FONT"].includes(node.tagName) && !node.getAttributeNames().length) {
|
||||
return unwrapContents(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (formatSpec.isTag && formatSpec.isTag(node)) {
|
||||
const attributesNames = node
|
||||
.getAttributeNames()
|
||||
.filter((name) => name !== "data-oe-zws-empty-inline");
|
||||
if (attributesNames.length) {
|
||||
// Change tag name
|
||||
const newNode = document.createElement("span");
|
||||
while (node.firstChild) {
|
||||
newNode.appendChild(node.firstChild);
|
||||
}
|
||||
for (let index = node.attributes.length - 1; index >= 0; --index) {
|
||||
newNode.attributes.setNamedItem(node.attributes[index].cloneNode());
|
||||
}
|
||||
node.parentNode.replaceChild(newNode, node);
|
||||
} else {
|
||||
unwrapContents(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,20 @@
|
|||
import { Plugin } from "../plugin";
|
||||
|
||||
export class InputPlugin extends Plugin {
|
||||
static id = "input";
|
||||
static dependencies = ["history"];
|
||||
setup() {
|
||||
this.addDomListener(this.editable, "beforeinput", this.onBeforeInput);
|
||||
this.addDomListener(this.editable, "input", this.onInput);
|
||||
}
|
||||
|
||||
onBeforeInput(ev) {
|
||||
this.dependencies.history.stageSelection();
|
||||
this.dispatchTo("beforeinput_handlers", ev);
|
||||
}
|
||||
|
||||
onInput(ev) {
|
||||
this.dependencies.history.addStep();
|
||||
this.dispatchTo("input_handlers", ev);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import { splitTextNode } from "@html_editor/utils/dom";
|
||||
import { Plugin } from "../plugin";
|
||||
import { CTGROUPS, CTYPES } from "../utils/content_types";
|
||||
import { getState, isFakeLineBreak, prepareUpdate } from "../utils/dom_state";
|
||||
import { DIRECTIONS, leftPos, rightPos } from "../utils/position";
|
||||
import { closestElement } from "@html_editor/utils/dom_traversal";
|
||||
import { closestBlock, isBlock } from "../utils/blocks";
|
||||
import { nextLeaf } from "../utils/dom_info";
|
||||
|
||||
/**
|
||||
* @typedef { Object } LineBreakShared
|
||||
* @property { LineBreakPlugin['insertLineBreak'] } insertLineBreak
|
||||
* @property { LineBreakPlugin['insertLineBreakElement'] } insertLineBreakElement
|
||||
* @property { LineBreakPlugin['insertLineBreakNode'] } insertLineBreakNode
|
||||
*/
|
||||
|
||||
export class LineBreakPlugin extends Plugin {
|
||||
static dependencies = ["selection", "history", "input", "delete"];
|
||||
static id = "lineBreak";
|
||||
static shared = ["insertLineBreak", "insertLineBreakNode", "insertLineBreakElement"];
|
||||
resources = {
|
||||
beforeinput_handlers: this.onBeforeInput.bind(this),
|
||||
legit_feff_predicates: [
|
||||
(node) =>
|
||||
!node.nextSibling &&
|
||||
!isBlock(closestElement(node)) &&
|
||||
nextLeaf(node, closestBlock(node)),
|
||||
],
|
||||
};
|
||||
|
||||
insertLineBreak() {
|
||||
this.dispatchTo("before_line_break_handlers");
|
||||
let selection = this.dependencies.selection.getSelectionData().deepEditableSelection;
|
||||
if (!selection.isCollapsed) {
|
||||
// @todo @phoenix collapseIfZWS is not tested
|
||||
// this.shared.collapseIfZWS();
|
||||
this.dependencies.delete.deleteSelection();
|
||||
selection = this.dependencies.selection.getEditableSelection();
|
||||
}
|
||||
|
||||
const targetNode = selection.anchorNode;
|
||||
const targetOffset = selection.anchorOffset;
|
||||
|
||||
this.insertLineBreakNode({ targetNode, targetOffset });
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {Node} params.targetNode
|
||||
* @param {number} params.targetOffset
|
||||
*/
|
||||
insertLineBreakNode({ targetNode, targetOffset }) {
|
||||
const closestEl = closestElement(targetNode);
|
||||
if (closestEl && !closestEl.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
if (targetNode.nodeType === Node.TEXT_NODE) {
|
||||
targetOffset = splitTextNode(targetNode, targetOffset);
|
||||
targetNode = targetNode.parentElement;
|
||||
}
|
||||
|
||||
if (this.delegateTo("insert_line_break_element_overrides", { targetNode, targetOffset })) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.insertLineBreakElement({ targetNode, targetOffset });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {HTMLElement} params.targetNode
|
||||
* @param {number} params.targetOffset
|
||||
*/
|
||||
insertLineBreakElement({ targetNode, targetOffset }) {
|
||||
const closestEl = closestElement(targetNode);
|
||||
if (closestEl && !closestEl.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
const restore = prepareUpdate(targetNode, targetOffset);
|
||||
|
||||
const brEl = this.document.createElement("br");
|
||||
const brEls = [brEl];
|
||||
if (targetOffset >= targetNode.childNodes.length) {
|
||||
targetNode.appendChild(brEl);
|
||||
if (
|
||||
!isBlock(closestElement(targetNode)) &&
|
||||
nextLeaf(targetNode, closestBlock(targetNode))
|
||||
) {
|
||||
targetNode.appendChild(this.document.createTextNode("\uFEFF"));
|
||||
}
|
||||
} else {
|
||||
targetNode.insertBefore(brEl, targetNode.childNodes[targetOffset]);
|
||||
}
|
||||
if (
|
||||
isFakeLineBreak(brEl) &&
|
||||
!(getState(...leftPos(brEl), DIRECTIONS.LEFT).cType & (CTGROUPS.BLOCK | CTYPES.BR))
|
||||
) {
|
||||
const brEl2 = this.document.createElement("br");
|
||||
brEl.before(brEl2);
|
||||
brEls.unshift(brEl2);
|
||||
}
|
||||
|
||||
restore();
|
||||
|
||||
// @todo ask AGE about why this code was only needed for unbreakable.
|
||||
// See `this._applyCommand('oEnter') === UNBREAKABLE_ROLLBACK_CODE` in
|
||||
// web_editor. Because now we should have a strong handling of the link
|
||||
// selection with the link isolation, if we want to insert a BR outside,
|
||||
// we can move the cursor outside the link.
|
||||
// So if there is no reason to keep this code, we should remove it.
|
||||
//
|
||||
// const anchor = brEls[0].parentElement;
|
||||
// // @todo @phoenix should this case be handled by a LinkPlugin?
|
||||
// // @todo @phoenix Don't we want this for all spans ?
|
||||
// if (anchor.nodeName === "A" && brEls.includes(anchor.firstChild)) {
|
||||
// brEls.forEach((br) => anchor.before(br));
|
||||
// const pos = rightPos(brEls[brEls.length - 1]);
|
||||
// this.dependencies.selection.setSelection({ anchorNode: pos[0], anchorOffset: pos[1] });
|
||||
// } else if (anchor.nodeName === "A" && brEls.includes(anchor.lastChild)) {
|
||||
// brEls.forEach((br) => anchor.after(br));
|
||||
// const pos = rightPos(brEls[0]);
|
||||
// this.dependencies.selection.setSelection({ anchorNode: pos[0], anchorOffset: pos[1] });
|
||||
// }
|
||||
for (const el of brEls) {
|
||||
// @todo @phoenix we don t want to setSelection multiple times
|
||||
if (el.parentNode) {
|
||||
const pos = rightPos(el);
|
||||
this.dependencies.selection.setSelection({
|
||||
anchorNode: pos[0],
|
||||
anchorOffset: pos[1],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeInput(e) {
|
||||
if (e.inputType === "insertLineBreak") {
|
||||
e.preventDefault();
|
||||
this.insertLineBreak();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { getDeepestPosition, isParagraphRelatedElement } from "@html_editor/utils/dom_info";
|
||||
import { Plugin } from "../plugin";
|
||||
import { isNotAllowedContent } from "./selection_plugin";
|
||||
import { endPos, startPos } from "@html_editor/utils/position";
|
||||
import { childNodes } from "@html_editor/utils/dom_traversal";
|
||||
|
||||
export class NoInlineRootPlugin extends Plugin {
|
||||
static id = "noInlineRoot";
|
||||
static dependencies = ["baseContainer", "selection", "history"];
|
||||
|
||||
resources = {
|
||||
fix_selection_on_editable_root_overrides: this.fixSelectionOnEditableRoot.bind(this),
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.addDomListener(this.editable, "keydown", (ev) => {
|
||||
this.currentKeyDown = ev.key;
|
||||
});
|
||||
this.addDomListener(this.editable, "pointerdown", () => {
|
||||
this.isPointerDown = true;
|
||||
});
|
||||
this.addDomListener(this.editable, "pointerup", () => {
|
||||
this.isPointerDown = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Places the cursor in a safe place (not the editable root).
|
||||
* Inserts an empty paragraph if selection results from mouse click and
|
||||
* there's no other way to insert text before/after a block.
|
||||
*
|
||||
* @param {import("./selection_plugin").EditorSelection} selection
|
||||
* @returns {boolean} Whether the selection was fixed
|
||||
*/
|
||||
fixSelectionOnEditableRoot(selection) {
|
||||
if (!selection.isCollapsed || selection.anchorNode !== this.editable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const children = childNodes(this.editable);
|
||||
const nodeAfterCursor = children[selection.anchorOffset];
|
||||
const nodeBeforeCursor = children[selection.anchorOffset - 1];
|
||||
const key = this.currentKeyDown;
|
||||
delete this.currentKeyDown;
|
||||
|
||||
if (key?.startsWith("Arrow")) {
|
||||
return this.fixSelectionOnEditableRootArrowKeys(nodeAfterCursor, nodeBeforeCursor, key);
|
||||
}
|
||||
return (
|
||||
this.fixSelectionOnEditableRootGeneric(nodeAfterCursor, nodeBeforeCursor) ||
|
||||
this.fixSelectionOnEditableRootCreateP(nodeAfterCursor, nodeBeforeCursor)
|
||||
);
|
||||
}
|
||||
/**
|
||||
* @param {Node} nodeAfterCursor
|
||||
* @param {Node} nodeBeforeCursor
|
||||
* @param {string} key
|
||||
* @returns {boolean} Whether the selection was fixed
|
||||
*/
|
||||
fixSelectionOnEditableRootArrowKeys(nodeAfterCursor, nodeBeforeCursor, key) {
|
||||
if (!["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown"].includes(key)) {
|
||||
return false;
|
||||
}
|
||||
const directionForward = ["ArrowRight", "ArrowDown"].includes(key);
|
||||
let node = directionForward ? nodeAfterCursor : nodeBeforeCursor;
|
||||
while (node && isNotAllowedContent(node)) {
|
||||
node = directionForward ? node.nextElementSibling : node.previousElementSibling;
|
||||
}
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
let [anchorNode, anchorOffset] = directionForward ? startPos(node) : endPos(node);
|
||||
[anchorNode, anchorOffset] = getDeepestPosition(anchorNode, anchorOffset);
|
||||
this.dependencies.selection.setSelection({ anchorNode, anchorOffset });
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* @param {Node} nodeAfterCursor
|
||||
* @param {Node} nodeBeforeCursor
|
||||
* @returns {boolean} Whether the selection was fixed
|
||||
*/
|
||||
fixSelectionOnEditableRootGeneric(nodeAfterCursor, nodeBeforeCursor) {
|
||||
if (isParagraphRelatedElement(nodeAfterCursor)) {
|
||||
// Cursor is right before a 'P'.
|
||||
this.dependencies.selection.setCursorStart(nodeAfterCursor);
|
||||
return true;
|
||||
}
|
||||
if (isParagraphRelatedElement(nodeBeforeCursor)) {
|
||||
// Cursor is right after a 'P'.
|
||||
this.dependencies.selection.setCursorEnd(nodeBeforeCursor);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Handle cursor not next to a 'P'.
|
||||
* Insert a new 'P' if selection resulted from a mouse click.
|
||||
*
|
||||
* In some situations (notably around tables and horizontal
|
||||
* separators), the cursor could be placed having its anchorNode at
|
||||
* the editable root, allowing the user to insert inlined text at
|
||||
* it.
|
||||
*
|
||||
* @param {Node} nodeAfterCursor
|
||||
* @param {Node} nodeBeforeCursor
|
||||
* @returns {boolean} Whether the selection was fixed
|
||||
*/
|
||||
fixSelectionOnEditableRootCreateP(nodeAfterCursor, nodeBeforeCursor) {
|
||||
if (!this.isPointerDown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseContainer = this.dependencies.baseContainer.createBaseContainer();
|
||||
baseContainer.append(this.document.createElement("br"));
|
||||
if (!nodeAfterCursor) {
|
||||
// Cursor is at the end of the editable.
|
||||
this.editable.append(baseContainer);
|
||||
} else if (!nodeBeforeCursor) {
|
||||
// Cursor is at the beginning of the editable.
|
||||
this.editable.prepend(baseContainer);
|
||||
} else {
|
||||
// Cursor is between two non-p blocks
|
||||
nodeAfterCursor.before(baseContainer);
|
||||
}
|
||||
this.dependencies.selection.setCursorStart(baseContainer);
|
||||
this.dependencies.history.addStep();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
import {
|
||||
Component,
|
||||
onWillDestroy,
|
||||
useEffect,
|
||||
useExternalListener,
|
||||
useRef,
|
||||
useState,
|
||||
useSubEnv,
|
||||
xml,
|
||||
} from "@odoo/owl";
|
||||
import { OVERLAY_SYMBOL } from "@web/core/overlay/overlay_container";
|
||||
import { usePosition } from "@web/core/position/position_hook";
|
||||
import { useActiveElement } from "@web/core/ui/ui_service";
|
||||
import { closestScrollableY } from "@web/core/utils/scrolling";
|
||||
|
||||
export class EditorOverlay extends Component {
|
||||
static template = xml`
|
||||
<div t-ref="root" class="overlay" t-att-class="props.className" t-on-pointerdown.stop="() => {}">
|
||||
<t t-component="props.Component" t-props="props.props"/>
|
||||
</div>`;
|
||||
|
||||
static props = {
|
||||
target: { validate: (el) => el.nodeType === Node.ELEMENT_NODE, optional: true },
|
||||
initialSelection: { type: Object, optional: true },
|
||||
Component: Function,
|
||||
props: { type: Object, optional: true },
|
||||
editable: { validate: (el) => el.nodeType === Node.ELEMENT_NODE },
|
||||
bus: Object,
|
||||
history: Object,
|
||||
close: Function,
|
||||
isOverlayOpen: Function,
|
||||
|
||||
// Props from createOverlay
|
||||
positionOptions: { type: Object, optional: true },
|
||||
className: { type: String, optional: true },
|
||||
closeOnPointerdown: { type: Boolean, optional: true },
|
||||
hasAutofocus: { type: Boolean, optional: true },
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
className: "",
|
||||
closeOnPointerdown: true,
|
||||
hasAutofocus: false,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.lastSelection = this.props.initialSelection;
|
||||
/** @type {HTMLElement} */
|
||||
const editable = this.props.editable;
|
||||
let getTarget, position;
|
||||
if (this.props.target) {
|
||||
getTarget = () => this.props.target;
|
||||
} else {
|
||||
this.rangeElement = editable.ownerDocument.createElement("range-el");
|
||||
editable.after(this.rangeElement);
|
||||
onWillDestroy(() => {
|
||||
this.rangeElement.remove();
|
||||
});
|
||||
getTarget = this.getSelectionTarget.bind(this);
|
||||
}
|
||||
|
||||
useExternalListener(this.props.bus, "updatePosition", () => {
|
||||
position.unlock();
|
||||
});
|
||||
|
||||
const rootRef = useRef("root");
|
||||
|
||||
if (this.props.positionOptions?.updatePositionOnResize ?? true) {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
position.unlock();
|
||||
});
|
||||
useEffect(
|
||||
(root) => {
|
||||
resizeObserver.observe(root);
|
||||
return () => {
|
||||
resizeObserver.unobserve(root);
|
||||
};
|
||||
},
|
||||
() => [rootRef.el]
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.closeOnPointerdown) {
|
||||
const clickAway = (ev) => {
|
||||
if (!this.env[OVERLAY_SYMBOL]?.contains(ev.composedPath()[0])) {
|
||||
this.props.close();
|
||||
}
|
||||
};
|
||||
const editableDocument = this.props.editable.ownerDocument;
|
||||
useExternalListener(editableDocument, "pointerdown", clickAway);
|
||||
// Listen to pointerdown outside the iframe
|
||||
if (editableDocument !== document) {
|
||||
useExternalListener(document, "pointerdown", clickAway);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.hasAutofocus) {
|
||||
useActiveElement("root");
|
||||
}
|
||||
const topDocument = editable.ownerDocument.defaultView.top.document;
|
||||
const container = closestScrollable(editable) || topDocument.documentElement;
|
||||
const resizeObserver = new ResizeObserver(() => position.unlock());
|
||||
resizeObserver.observe(container);
|
||||
onWillDestroy(() => resizeObserver.disconnect());
|
||||
const positionOptions = {
|
||||
position: "bottom-start",
|
||||
container: container,
|
||||
...this.props.positionOptions,
|
||||
onPositioned: (el, solution) => {
|
||||
this.props.positionOptions?.onPositioned?.(el, solution);
|
||||
this.updateVisibility(el, solution, container);
|
||||
},
|
||||
};
|
||||
position = usePosition("root", getTarget, positionOptions);
|
||||
|
||||
this.overlayState = useState({ isOverlayVisible: true });
|
||||
useSubEnv({ overlayState: this.overlayState });
|
||||
}
|
||||
|
||||
getSelectionTarget() {
|
||||
const doc = this.props.editable.ownerDocument;
|
||||
const selection = doc.getSelection();
|
||||
if (!selection || !selection.rangeCount || !this.props.isOverlayOpen()) {
|
||||
return null;
|
||||
}
|
||||
const inEditable = this.props.editable.contains(selection.anchorNode);
|
||||
let range;
|
||||
if (inEditable) {
|
||||
range = selection.getRangeAt(0);
|
||||
this.lastSelection = { range };
|
||||
} else {
|
||||
if (!this.lastSelection) {
|
||||
return null;
|
||||
}
|
||||
range = this.lastSelection.range;
|
||||
}
|
||||
let rect = range.getBoundingClientRect();
|
||||
if (rect.x === 0 && rect.width === 0 && rect.height === 0) {
|
||||
// Attention, ignoring DOM mutations is always dangerous (when we add or remove nodes)
|
||||
// because if another mutation uses the target that is not observed, that mutation can never be applied
|
||||
// again (when undo/redo and in collaboration).
|
||||
this.props.history.ignoreDOMMutations(() => {
|
||||
const clonedRange = range.cloneRange();
|
||||
const shadowCaret = doc.createTextNode("|");
|
||||
clonedRange.insertNode(shadowCaret);
|
||||
clonedRange.selectNode(shadowCaret);
|
||||
rect = clonedRange.getBoundingClientRect();
|
||||
shadowCaret.remove();
|
||||
clonedRange.detach();
|
||||
});
|
||||
}
|
||||
// Html element with a patched getBoundingClientRect method. It
|
||||
// represents the range as a (HTMLElement) target for the usePosition
|
||||
// hook.
|
||||
this.rangeElement.getBoundingClientRect = () => rect;
|
||||
return this.rangeElement;
|
||||
}
|
||||
|
||||
updateVisibility(overlayElement, solution, container) {
|
||||
// @todo: mobile tests rely on a visible (yet overflowing) toolbar
|
||||
// Remove this once the mobile toolbar is fixed?
|
||||
if (this.env.isSmall) {
|
||||
return;
|
||||
}
|
||||
const shouldBeVisible = this.shouldOverlayBeVisible(overlayElement, solution, container);
|
||||
overlayElement.style.visibility = shouldBeVisible ? "visible" : "hidden";
|
||||
this.overlayState.isOverlayVisible = shouldBeVisible;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} overlayElement
|
||||
* @param {Object} solution
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
shouldOverlayBeVisible(overlayElement, solution, container) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const overflowsTop = solution.top < containerRect.top;
|
||||
const overflowsBottom = solution.top + overlayElement.offsetHeight > containerRect.bottom;
|
||||
const canFlip = this.props.positionOptions?.flip ?? true;
|
||||
if (overflowsTop) {
|
||||
if (overflowsBottom) {
|
||||
// Overlay is bigger than the cointainer. Hiding it would it
|
||||
// make always invisible.
|
||||
return true;
|
||||
}
|
||||
if (solution.direction === "top" && canFlip) {
|
||||
// Scrolling down will make overlay eventually flip and no longer overflow
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (overflowsBottom) {
|
||||
if (solution.direction === "bottom" && canFlip) {
|
||||
// Scrolling up will make overlay eventually flip and no longer overflow
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around closestScrollableY that keeps searching outside of iframes.
|
||||
*
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
function closestScrollable(el) {
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
return closestScrollableY(el) || closestScrollable(el.ownerDocument.defaultView.frameElement);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { markRaw, EventBus } from "@odoo/owl";
|
||||
import { Plugin } from "../plugin";
|
||||
import { EditorOverlay } from "./overlay";
|
||||
|
||||
/**
|
||||
* @typedef { Object } OverlayShared
|
||||
* @property { OverlayPlugin['createOverlay'] } createOverlay
|
||||
*/
|
||||
|
||||
/**
|
||||
* Provides the following feature:
|
||||
* - adding a component in overlay above the editor, with proper positioning
|
||||
*/
|
||||
export class OverlayPlugin extends Plugin {
|
||||
static id = "overlay";
|
||||
static dependencies = ["history"];
|
||||
static shared = ["createOverlay"];
|
||||
|
||||
overlays = [];
|
||||
|
||||
destroy() {
|
||||
super.destroy();
|
||||
for (const overlay of this.overlays) {
|
||||
overlay.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an overlay component and adds it to the list of overlays.
|
||||
*
|
||||
* @param {Function} Component
|
||||
* @param {Object} [props={}]
|
||||
* @param {Object} [options]
|
||||
* @returns {Overlay}
|
||||
*/
|
||||
createOverlay(Component, props = {}, options) {
|
||||
const overlay = new Overlay(this, Component, props, options);
|
||||
this.overlays.push(overlay);
|
||||
return overlay;
|
||||
}
|
||||
}
|
||||
|
||||
export class Overlay {
|
||||
constructor(plugin, C, props, options) {
|
||||
this.plugin = plugin;
|
||||
this.C = C;
|
||||
this.editorOverlayProps = props;
|
||||
this.options = options;
|
||||
this.isOpen = false;
|
||||
this._remove = null;
|
||||
this.component = null;
|
||||
this.bus = new EventBus();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {HTMLElement | null} [options.target] for the overlay.
|
||||
* If null or undefined, the current selection will be used instead
|
||||
* @param {any} [options.props] overlay component props
|
||||
*/
|
||||
open({ target, props }) {
|
||||
if (this.isOpen) {
|
||||
this.updatePosition();
|
||||
} else {
|
||||
this.isOpen = true;
|
||||
const selection = this.plugin.editable.ownerDocument.getSelection();
|
||||
let initialSelection;
|
||||
if (selection && selection.type !== "None") {
|
||||
initialSelection = {
|
||||
range: selection.getRangeAt(0),
|
||||
};
|
||||
}
|
||||
this._remove = this.plugin.services.overlay.add(
|
||||
EditorOverlay,
|
||||
markRaw({
|
||||
...this.editorOverlayProps,
|
||||
Component: this.C,
|
||||
editable: this.plugin.editable,
|
||||
props,
|
||||
target,
|
||||
initialSelection,
|
||||
bus: this.bus,
|
||||
close: this.close.bind(this),
|
||||
isOverlayOpen: this.isOverlayOpen.bind(this),
|
||||
history: {
|
||||
ignoreDOMMutations: this.plugin.dependencies.history.ignoreDOMMutations,
|
||||
},
|
||||
}),
|
||||
{
|
||||
...this.options,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
if (this._remove) {
|
||||
this._remove();
|
||||
}
|
||||
}
|
||||
|
||||
isOverlayOpen() {
|
||||
return this.isOpen;
|
||||
}
|
||||
|
||||
updatePosition() {
|
||||
this.bus.trigger("updatePosition");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import { Plugin } from "../plugin";
|
||||
import { isProtecting, isUnprotecting } from "../utils/dom_info";
|
||||
import { childNodes } from "../utils/dom_traversal";
|
||||
import { withSequence } from "@html_editor/utils/resource";
|
||||
|
||||
const PROTECTED_SELECTOR = `[data-oe-protected="true"],[data-oe-protected=""]`;
|
||||
const UNPROTECTED_SELECTOR = `[data-oe-protected="false"]`;
|
||||
|
||||
/**
|
||||
* @typedef { Object } ProtectedNodeShared
|
||||
* @property { ProtectedNodePlugin['setProtectingNode'] } setProtectingNode
|
||||
*
|
||||
* @typedef { import("./history_plugin").HistoryMutationRecord } HistoryMutationRecord
|
||||
*/
|
||||
|
||||
export class ProtectedNodePlugin extends Plugin {
|
||||
static id = "protectedNode";
|
||||
static shared = ["setProtectingNode"];
|
||||
resources = {
|
||||
/** Handlers */
|
||||
clean_for_save_handlers: ({ root }) => this.cleanForSave(root),
|
||||
normalize_handlers: withSequence(0, this.normalize.bind(this)),
|
||||
before_filter_mutation_record_handlers: this.beforeFilteringMutationRecords.bind(this),
|
||||
|
||||
unsplittable_node_predicates: [
|
||||
isProtecting, // avoid merge
|
||||
isUnprotecting,
|
||||
],
|
||||
savable_mutation_record_predicates: this.isMutationRecordSavable.bind(this),
|
||||
removable_descendants_providers: this.filterDescendantsToRemove.bind(this),
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.protectedNodes = new WeakSet();
|
||||
}
|
||||
|
||||
filterDescendantsToRemove(elem) {
|
||||
// TODO @phoenix: history plugin can register protected nodes in its
|
||||
// id maps, should it be prevented? => if yes, take care that data-oe-protected="false"
|
||||
// elements should also be registered even though they are protected.
|
||||
if (isProtecting(elem)) {
|
||||
const descendantsToRemove = [];
|
||||
for (const candidate of elem.querySelectorAll(UNPROTECTED_SELECTOR)) {
|
||||
if (candidate.closest(PROTECTED_SELECTOR) === elem) {
|
||||
descendantsToRemove.push(...childNodes(candidate));
|
||||
}
|
||||
}
|
||||
return descendantsToRemove;
|
||||
}
|
||||
}
|
||||
|
||||
protectNode(node) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (node.matches(UNPROTECTED_SELECTOR)) {
|
||||
this.unProtectDescendants(node);
|
||||
} else if (!this.protectedNodes.has(node)) {
|
||||
this.protectDescendants(node);
|
||||
}
|
||||
// assume that descendants are already handled if the node
|
||||
// is already protected.
|
||||
}
|
||||
this.protectedNodes.add(node);
|
||||
}
|
||||
|
||||
unProtectNode(node) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (node.matches(PROTECTED_SELECTOR)) {
|
||||
this.protectDescendants(node);
|
||||
} else if (this.protectedNodes.has(node)) {
|
||||
this.unProtectDescendants(node);
|
||||
}
|
||||
// assume that descendants are already handled if the node
|
||||
// is already not protected.
|
||||
}
|
||||
this.protectedNodes.delete(node);
|
||||
}
|
||||
|
||||
protectDescendants(node) {
|
||||
let child = node.firstChild;
|
||||
while (child) {
|
||||
this.protectNode(child);
|
||||
child = child.nextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
unProtectDescendants(node) {
|
||||
let child = node.firstChild;
|
||||
while (child) {
|
||||
this.unProtectNode(child);
|
||||
child = child.nextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HistoryMutationRecord[]} records
|
||||
*/
|
||||
beforeFilteringMutationRecords(records) {
|
||||
for (const record of records) {
|
||||
if (record.type === "childList") {
|
||||
if (record.target.nodeType !== Node.ELEMENT_NODE) {
|
||||
return;
|
||||
}
|
||||
const addedNodes = record.addedTrees.map((tree) => tree.node);
|
||||
if (
|
||||
(this.protectedNodes.has(record.target) &&
|
||||
!record.target.matches(UNPROTECTED_SELECTOR)) ||
|
||||
record.target.matches(PROTECTED_SELECTOR)
|
||||
) {
|
||||
for (const addedNode of addedNodes) {
|
||||
this.protectNode(addedNode);
|
||||
}
|
||||
} else if (
|
||||
!this.protectedNodes.has(record.target) ||
|
||||
record.target.matches(UNPROTECTED_SELECTOR)
|
||||
) {
|
||||
for (const addedNode of addedNodes) {
|
||||
this.unProtectNode(addedNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HistoryMutationRecord} record
|
||||
* @return {boolean}
|
||||
*/
|
||||
isMutationRecordSavable(record) {
|
||||
if (record.type === "childList") {
|
||||
return !(
|
||||
(this.protectedNodes.has(record.target) &&
|
||||
!record.target.matches(UNPROTECTED_SELECTOR)) ||
|
||||
record.target.matches(PROTECTED_SELECTOR)
|
||||
);
|
||||
}
|
||||
return !this.protectedNodes.has(record.target);
|
||||
}
|
||||
|
||||
forEachProtectingElem(elem, callback) {
|
||||
const selector = `[data-oe-protected]`;
|
||||
const protectingNodes = [...elem.querySelectorAll(selector)].reverse();
|
||||
if (elem.matches(selector)) {
|
||||
protectingNodes.push(elem);
|
||||
}
|
||||
for (const protectingNode of protectingNodes) {
|
||||
if (protectingNode.dataset.oeProtected === "false") {
|
||||
callback(protectingNode, false);
|
||||
} else {
|
||||
callback(protectingNode, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalize(elem) {
|
||||
this.forEachProtectingElem(elem, this.setProtectingNode.bind(this));
|
||||
}
|
||||
|
||||
setProtectingNode(elem, protecting) {
|
||||
elem.dataset.oeProtected = protecting;
|
||||
// contenteditable attribute is set on (un)protecting nodes for
|
||||
// implementation convenience. This could be removed but the editor
|
||||
// should be adapted to handle some use cases that are handled for
|
||||
// contenteditable elements. Currently unsupported configurations:
|
||||
// 1) unprotected non-editable content: would typically be added/removed
|
||||
// programmatically and shared in collaboration => some logic should
|
||||
// be added to handle undo/redo properly for consistency.
|
||||
// -> A adds content, A replaces his content with a new one, B replaces
|
||||
// content of A with his own, A undo => there is now the content of B
|
||||
// and the old content of A in the node, is it still coherent?
|
||||
// 2) protected editable content: need a specification of which
|
||||
// functions of the editor are allowed to work (and how) in that
|
||||
// editable part (none?) => should be enforced.
|
||||
if (protecting) {
|
||||
elem.setAttribute("contenteditable", "false");
|
||||
this.protectDescendants(elem);
|
||||
} else {
|
||||
elem.setAttribute("contenteditable", "true");
|
||||
this.unProtectDescendants(elem);
|
||||
}
|
||||
}
|
||||
|
||||
cleanForSave(clone) {
|
||||
this.forEachProtectingElem(clone, (protectingNode) => {
|
||||
protectingNode.removeAttribute("contenteditable");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { selectElements } from "@html_editor/utils/dom_traversal";
|
||||
import { Plugin } from "../plugin";
|
||||
|
||||
/**
|
||||
* @typedef { Object } SanitizeShared
|
||||
* @property { SanitizePlugin['sanitize'] } sanitize
|
||||
*/
|
||||
|
||||
export class SanitizePlugin extends Plugin {
|
||||
static id = "sanitize";
|
||||
static shared = ["sanitize"];
|
||||
resources = {
|
||||
clean_for_save_handlers: this.cleanForSave.bind(this),
|
||||
normalize_handlers: this.normalize.bind(this),
|
||||
};
|
||||
|
||||
setup() {
|
||||
if (!window.DOMPurify) {
|
||||
throw new Error("DOMPurify is not available");
|
||||
}
|
||||
this.DOMPurify = DOMPurify(this.window);
|
||||
}
|
||||
/**
|
||||
* Sanitizes in place an html element. Current implementation uses the
|
||||
* DOMPurify library.
|
||||
*
|
||||
* @param {HTMLElement} elem
|
||||
* @returns {HTMLElement} the element itself
|
||||
*/
|
||||
sanitize(elem) {
|
||||
return this.DOMPurify.sanitize(elem, {
|
||||
IN_PLACE: true,
|
||||
ADD_TAGS: ["#document-fragment", "fake-el"],
|
||||
ADD_ATTR: ["contenteditable", "t-field", "t-out", "t-esc"],
|
||||
});
|
||||
}
|
||||
|
||||
normalize(element) {
|
||||
for (const el of selectElements(
|
||||
element,
|
||||
".o-contenteditable-false, .o-contenteditable-true"
|
||||
)) {
|
||||
el.contentEditable = el.matches(".o-contenteditable-true");
|
||||
}
|
||||
for (const el of selectElements(element, "[data-oe-role]")) {
|
||||
el.setAttribute("role", el.dataset.oeRole);
|
||||
}
|
||||
for (const el of selectElements(element, "[data-oe-aria-label]")) {
|
||||
el.setAttribute("aria-label", el.dataset.oeAriaLabel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that attributes sanitized by the server are properly removed before
|
||||
* the save, to avoid mismatches and a reset of the editable content.
|
||||
* Only attributes under the responsibility (associated with an editor
|
||||
* attribute or class) of the sanitize plugin are removed.
|
||||
*
|
||||
* /!\ CAUTION: using server-sanitized attributes without editor-specific
|
||||
* classes/attributes in a custom plugin should be managed by that same
|
||||
* custom plugin.
|
||||
*/
|
||||
cleanForSave({ root }) {
|
||||
for (const el of selectElements(
|
||||
root,
|
||||
".o-contenteditable-false, .o-contenteditable-true"
|
||||
)) {
|
||||
el.removeAttribute("contenteditable");
|
||||
}
|
||||
for (const el of selectElements(root, "[data-oe-role]")) {
|
||||
el.removeAttribute("role");
|
||||
}
|
||||
for (const el of selectElements(root, "[data-oe-aria-label]")) {
|
||||
el.removeAttribute("aria-label");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,61 @@
|
|||
import { Plugin, isValidTargetForDomListener } from "../plugin";
|
||||
|
||||
/**
|
||||
* @typedef {Object} Shortcut
|
||||
* @property {string} hotkey
|
||||
* @property {string} commandId
|
||||
* @property {Object} [commandParams]
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* resources = {
|
||||
* user_commands: [
|
||||
* { id: "myCommands", run: myCommandFunction },
|
||||
* ],
|
||||
* shortcuts: [
|
||||
* { hotkey: "control+shift+q", commandId: "myCommands" },
|
||||
* ],
|
||||
* }
|
||||
*/
|
||||
|
||||
export class ShortCutPlugin extends Plugin {
|
||||
static id = "shortcut";
|
||||
static dependencies = ["userCommand", "selection"];
|
||||
|
||||
setup() {
|
||||
const hotkeyService = this.services.hotkey;
|
||||
if (!hotkeyService) {
|
||||
throw new Error("ShorcutPlugin needs hotkey service to properly work");
|
||||
}
|
||||
if (document !== this.document) {
|
||||
hotkeyService.registerIframe({ contentWindow: this.window });
|
||||
}
|
||||
for (const shortcut of this.getResource("shortcuts")) {
|
||||
const command = this.dependencies.userCommand.getCommand(shortcut.commandId);
|
||||
this.addShortcut(
|
||||
shortcut.hotkey,
|
||||
() => {
|
||||
command.run(shortcut.commandParams);
|
||||
},
|
||||
{
|
||||
isAvailable: command.isAvailable,
|
||||
global: !!shortcut.global,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
addShortcut(hotkey, action, { isAvailable, global }) {
|
||||
this._cleanups.push(
|
||||
this.services.hotkey.add(hotkey, action, {
|
||||
area: () => this.editable,
|
||||
bypassEditableProtection: true,
|
||||
allowRepeat: true,
|
||||
isAvailable: (target) =>
|
||||
(!isAvailable ||
|
||||
isAvailable(this.dependencies.selection.getEditableSelection())) &&
|
||||
(global || isValidTargetForDomListener(target)),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
import { Plugin } from "../plugin";
|
||||
import { isBlock } from "../utils/blocks";
|
||||
import { fillEmpty, splitTextNode } from "../utils/dom";
|
||||
import {
|
||||
isContentEditable,
|
||||
isContentEditableAncestor,
|
||||
isTextNode,
|
||||
isVisible,
|
||||
} from "../utils/dom_info";
|
||||
import { prepareUpdate } from "../utils/dom_state";
|
||||
import { childNodes, closestElement, firstLeaf, lastLeaf } from "../utils/dom_traversal";
|
||||
import { DIRECTIONS, childNodeIndex, nodeSize } from "../utils/position";
|
||||
import { isProtected, isProtecting } from "@html_editor/utils/dom_info";
|
||||
|
||||
/**
|
||||
* @typedef { Object } SplitShared
|
||||
* @property { SplitPlugin['isUnsplittable'] } isUnsplittable
|
||||
* @property { SplitPlugin['splitAroundUntil'] } splitAroundUntil
|
||||
* @property { SplitPlugin['splitBlock'] } splitBlock
|
||||
* @property { SplitPlugin['splitBlockNode'] } splitBlockNode
|
||||
* @property { SplitPlugin['splitElement'] } splitElement
|
||||
* @property { SplitPlugin['splitElementBlock'] } splitElementBlock
|
||||
* @property { SplitPlugin['splitSelection'] } splitSelection
|
||||
*/
|
||||
|
||||
export class SplitPlugin extends Plugin {
|
||||
static dependencies = ["baseContainer", "selection", "history", "input", "delete", "lineBreak"];
|
||||
static id = "split";
|
||||
static shared = [
|
||||
"splitBlock",
|
||||
"splitBlockNode",
|
||||
"splitElementBlock",
|
||||
"splitElement",
|
||||
"splitAroundUntil",
|
||||
"splitSelection",
|
||||
"isUnsplittable",
|
||||
];
|
||||
resources = {
|
||||
beforeinput_handlers: this.onBeforeInput.bind(this),
|
||||
|
||||
unsplittable_node_predicates: [
|
||||
// An unremovable element is also unmergeable (as merging two
|
||||
// elements results in removing one of them).
|
||||
// An unmergeable element is unsplittable and vice-versa (as
|
||||
// split and merge are reverse operations from one another).
|
||||
// Therefore, unremovable nodes are also unsplittable.
|
||||
(node) =>
|
||||
this.getResource("unremovable_node_predicates").some((predicate) =>
|
||||
predicate(node)
|
||||
),
|
||||
// "Unbreakable" is a legacy term that means unsplittable and
|
||||
// unmergeable.
|
||||
(node) => node.classList?.contains("oe_unbreakable"),
|
||||
(node) => {
|
||||
const isExplicitlyNotContentEditable = (node) =>
|
||||
// In the `contenteditable` attribute consideration,
|
||||
// disconnected nodes can be unsplittable only if they are
|
||||
// explicitly set under a contenteditable="false" element.
|
||||
!isContentEditable(node) &&
|
||||
(node.isConnected || closestElement(node, "[contenteditable]"));
|
||||
return (
|
||||
isExplicitlyNotContentEditable(node) ||
|
||||
// If node sets contenteditable='true' and is inside a non-editable
|
||||
// context, it has to be unsplittable since splitting it would modify
|
||||
// the non-editable parent content.
|
||||
(node.parentElement &&
|
||||
isContentEditableAncestor(node) &&
|
||||
isExplicitlyNotContentEditable(node.parentElement))
|
||||
);
|
||||
},
|
||||
(node) => node.nodeName === "SECTION",
|
||||
],
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// commands
|
||||
// --------------------------------------------------------------------------
|
||||
splitBlock() {
|
||||
this.dispatchTo("before_split_block_handlers");
|
||||
let selection = this.dependencies.selection.getSelectionData().deepEditableSelection;
|
||||
if (!selection.isCollapsed) {
|
||||
// @todo @phoenix collapseIfZWS is not tested
|
||||
// this.shared.collapseIfZWS();
|
||||
this.dependencies.delete.deleteSelection();
|
||||
selection = this.dependencies.selection.getEditableSelection();
|
||||
}
|
||||
|
||||
return this.splitBlockNode({
|
||||
targetNode: selection.anchorNode,
|
||||
targetOffset: selection.anchorOffset,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} param0
|
||||
* @param {Node} param0.targetNode
|
||||
* @param {number} param0.targetOffset
|
||||
* @returns {[HTMLElement|undefined, HTMLElement|undefined]}
|
||||
*/
|
||||
splitBlockNode({ targetNode, targetOffset }) {
|
||||
if (targetNode.nodeType === Node.TEXT_NODE) {
|
||||
targetOffset = splitTextNode(targetNode, targetOffset);
|
||||
targetNode = targetNode.parentElement;
|
||||
}
|
||||
const blockToSplit = closestElement(targetNode, isBlock);
|
||||
const params = { targetNode, targetOffset, blockToSplit };
|
||||
|
||||
if (this.delegateTo("split_element_block_overrides", params)) {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
|
||||
return this.splitElementBlock(params);
|
||||
}
|
||||
/**
|
||||
* @param {Object} param0
|
||||
* @param {HTMLElement} param0.targetNode
|
||||
* @param {number} param0.targetOffset
|
||||
* @param {HTMLElement} param0.blockToSplit
|
||||
* @returns {[HTMLElement|undefined, HTMLElement|undefined]}
|
||||
*/
|
||||
splitElementBlock({ targetNode, targetOffset, blockToSplit }) {
|
||||
// If the block is unsplittable, insert a line break instead.
|
||||
if (this.isUnsplittable(blockToSplit)) {
|
||||
// @todo: t-if, t-else etc are not blocks, but they are
|
||||
// unsplittable. The check must be done from the targetNode up to
|
||||
// the block for unsplittables. There are apparently no tests for
|
||||
// this.
|
||||
this.dependencies.lineBreak.insertLineBreakElement({ targetNode, targetOffset });
|
||||
return [undefined, undefined];
|
||||
}
|
||||
const restore = prepareUpdate(targetNode, targetOffset);
|
||||
|
||||
const [beforeElement, afterElement] = this.splitElementUntil(
|
||||
targetNode,
|
||||
targetOffset,
|
||||
blockToSplit.parentElement
|
||||
);
|
||||
restore();
|
||||
const fillEmptyElement = (node) => {
|
||||
if (isProtecting(node) || isProtected(node)) {
|
||||
// TODO ABD: add test
|
||||
return;
|
||||
} else if (node.nodeType === Node.TEXT_NODE && !isVisible(node)) {
|
||||
const parent = node.parentElement;
|
||||
node.remove();
|
||||
fillEmptyElement(parent);
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (node.hasAttribute("data-oe-zws-empty-inline")) {
|
||||
delete node.dataset.oeZwsEmptyInline;
|
||||
}
|
||||
fillEmpty(node);
|
||||
}
|
||||
};
|
||||
fillEmptyElement(lastLeaf(beforeElement));
|
||||
fillEmptyElement(firstLeaf(afterElement));
|
||||
|
||||
this.dependencies.selection.setCursorStart(afterElement);
|
||||
|
||||
return [beforeElement, afterElement];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isUnsplittable(node) {
|
||||
return this.getResource("unsplittable_node_predicates").some((p) => p(node));
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the given element at the given offset. The element will be removed in
|
||||
* the process so caution is advised in dealing with its reference. Returns a
|
||||
* tuple containing the new elements on both sides of the split.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {number} offset
|
||||
* @returns {[HTMLElement, HTMLElement]}
|
||||
*/
|
||||
splitElement(element, offset) {
|
||||
/** @type {HTMLElement} **/
|
||||
const firstPart = element.cloneNode();
|
||||
/** @type {HTMLElement} **/
|
||||
const secondPart = element.cloneNode();
|
||||
element.before(firstPart);
|
||||
element.after(secondPart);
|
||||
const children = childNodes(element);
|
||||
firstPart.append(...children.slice(0, offset));
|
||||
secondPart.append(...children.slice(offset));
|
||||
element.remove();
|
||||
this.dispatchTo("after_split_element_handlers", { firstPart, secondPart });
|
||||
return [firstPart, secondPart];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the given element at the given offset, until the given limit ancestor.
|
||||
* The element will be removed in the process so caution is advised in dealing
|
||||
* with its reference. Returns a tuple containing the new elements on both sides
|
||||
* of the split.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {number} offset
|
||||
* @param {HTMLElement} limitAncestor
|
||||
* @returns {[HTMLElement, HTMLElement]}
|
||||
*/
|
||||
splitElementUntil(element, offset, limitAncestor) {
|
||||
if (element === limitAncestor) {
|
||||
return [element, element];
|
||||
}
|
||||
let [before, after] = this.splitElement(element, offset);
|
||||
if (after.parentElement !== limitAncestor) {
|
||||
const afterIndex = childNodeIndex(after);
|
||||
[before, after] = this.splitElementUntil(
|
||||
after.parentElement,
|
||||
afterIndex,
|
||||
limitAncestor
|
||||
);
|
||||
}
|
||||
return [before, after];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split around the given elements, until a given ancestor (included). Elements
|
||||
* will be removed in the process so caution is advised in dealing with their
|
||||
* references. Returns the new split root element that is a clone of
|
||||
* limitAncestor or the original limitAncestor if no split occured.
|
||||
*
|
||||
* @param {Node[] | Node} elements
|
||||
* @param {HTMLElement} limitAncestor
|
||||
* @returns { Node }
|
||||
*/
|
||||
splitAroundUntil(elements, limitAncestor) {
|
||||
elements = Array.isArray(elements) ? elements : [elements];
|
||||
const firstNode = elements[0];
|
||||
const lastNode = elements[elements.length - 1];
|
||||
if ([firstNode, lastNode].includes(limitAncestor)) {
|
||||
return limitAncestor;
|
||||
}
|
||||
let before = firstNode.previousSibling;
|
||||
let after = lastNode.nextSibling;
|
||||
let beforeSplit, afterSplit;
|
||||
if (
|
||||
!before &&
|
||||
!after &&
|
||||
firstNode.parentElement !== limitAncestor &&
|
||||
lastNode.parentElement !== limitAncestor
|
||||
) {
|
||||
return this.splitAroundUntil(
|
||||
[firstNode.parentElement, lastNode.parentElement],
|
||||
limitAncestor
|
||||
);
|
||||
} else if (!after && lastNode.parentElement !== limitAncestor) {
|
||||
return this.splitAroundUntil([firstNode, lastNode.parentElement], limitAncestor);
|
||||
} else if (!before && firstNode.parentElement !== limitAncestor) {
|
||||
return this.splitAroundUntil([firstNode.parentElement, lastNode], limitAncestor);
|
||||
}
|
||||
// Split up ancestors up to font
|
||||
while (after && after.parentElement !== limitAncestor) {
|
||||
afterSplit = this.splitElement(after.parentElement, childNodeIndex(after))[0];
|
||||
after = afterSplit.nextSibling;
|
||||
}
|
||||
if (after) {
|
||||
afterSplit = this.splitElement(limitAncestor, childNodeIndex(after))[0];
|
||||
limitAncestor = afterSplit;
|
||||
}
|
||||
while (before && before.parentElement !== limitAncestor) {
|
||||
beforeSplit = this.splitElement(before.parentElement, childNodeIndex(before) + 1)[1];
|
||||
before = beforeSplit.previousSibling;
|
||||
}
|
||||
if (before) {
|
||||
beforeSplit = this.splitElement(limitAncestor, childNodeIndex(before) + 1)[1];
|
||||
}
|
||||
return beforeSplit || afterSplit || limitAncestor;
|
||||
}
|
||||
|
||||
splitSelection() {
|
||||
let { startContainer, startOffset, endContainer, endOffset, direction } =
|
||||
this.dependencies.selection.getEditableSelection();
|
||||
const isInSingleContainer = startContainer === endContainer;
|
||||
if (isTextNode(endContainer) && endOffset > 0 && endOffset < nodeSize(endContainer)) {
|
||||
const endParent = endContainer.parentNode;
|
||||
const splitOffset = splitTextNode(endContainer, endOffset);
|
||||
endContainer = endParent.childNodes[splitOffset - 1] || endParent.firstChild;
|
||||
if (isInSingleContainer) {
|
||||
startContainer = endContainer;
|
||||
}
|
||||
endOffset = endContainer.textContent.length;
|
||||
}
|
||||
if (
|
||||
isTextNode(startContainer) &&
|
||||
startOffset > 0 &&
|
||||
startOffset < nodeSize(startContainer)
|
||||
) {
|
||||
splitTextNode(startContainer, startOffset);
|
||||
startOffset = 0;
|
||||
if (isInSingleContainer) {
|
||||
endOffset = startContainer.textContent.length;
|
||||
}
|
||||
}
|
||||
|
||||
const selection =
|
||||
direction === DIRECTIONS.RIGHT
|
||||
? {
|
||||
anchorNode: startContainer,
|
||||
anchorOffset: startOffset,
|
||||
focusNode: endContainer,
|
||||
focusOffset: endOffset,
|
||||
}
|
||||
: {
|
||||
anchorNode: endContainer,
|
||||
anchorOffset: endOffset,
|
||||
focusNode: startContainer,
|
||||
focusOffset: startOffset,
|
||||
};
|
||||
return this.dependencies.selection.setSelection(selection, { normalize: false });
|
||||
}
|
||||
|
||||
onBeforeInput(e) {
|
||||
if (e.inputType === "insertParagraph") {
|
||||
e.preventDefault();
|
||||
this.splitBlock();
|
||||
this.dependencies.history.addStep();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { Plugin } from "@html_editor/plugin";
|
||||
import { backgroundImageCssToParts, backgroundImagePartsToCss } from "@html_editor/utils/image";
|
||||
|
||||
/**
|
||||
* @typedef { Object } StyleShared
|
||||
* @property { StylePlugin['setBackgroundImageUrl'] } setBackgroundImageUrl
|
||||
*/
|
||||
|
||||
export class StylePlugin extends Plugin {
|
||||
static id = "style";
|
||||
static shared = ["setBackgroundImageUrl"];
|
||||
|
||||
setBackgroundImageUrl(el, value) {
|
||||
const parts = backgroundImageCssToParts(el.style["background-image"]);
|
||||
if (value) {
|
||||
parts.url = `url('${value}')`;
|
||||
} else {
|
||||
delete parts.url;
|
||||
}
|
||||
el.style["background-image"] = backgroundImagePartsToCss(parts);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { Plugin } from "../plugin";
|
||||
|
||||
/**
|
||||
* @typedef { import("./selection_plugin").EditorSelection } EditorSelection
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef { Object } UserCommand
|
||||
* @property { string } id
|
||||
* @property { Function } run
|
||||
* @property { String } [title]
|
||||
* @property { String } [description]
|
||||
* @property { string } [icon]
|
||||
* @property { (selection: EditorSelection) => boolean } [isAvailable]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef { Object } UserCommandShared
|
||||
* @property { UserCommandPlugin['getCommand'] } getCommand
|
||||
*/
|
||||
|
||||
export class UserCommandPlugin extends Plugin {
|
||||
static id = "userCommand";
|
||||
static shared = ["getCommand"];
|
||||
|
||||
setup() {
|
||||
this.commands = {};
|
||||
for (const command of this.getResource("user_commands")) {
|
||||
if (command.id in this.commands) {
|
||||
throw new Error(`Duplicate user command id: ${command.id}`);
|
||||
}
|
||||
this.commands[command.id] = command;
|
||||
}
|
||||
Object.freeze(this.commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} commandId
|
||||
* @returns {UserCommand}
|
||||
* @throws {Error} if the command ID is unknown.
|
||||
*/
|
||||
getCommand(commandId) {
|
||||
const command = this.commands[commandId];
|
||||
if (!command) {
|
||||
throw new Error(`Unknown user command id: ${commandId}`);
|
||||
}
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { useEffect, useState } from "@odoo/owl";
|
||||
|
||||
export function useDropdownAutoVisibility(overlayState, popoverRef) {
|
||||
if (!overlayState) {
|
||||
return;
|
||||
}
|
||||
const state = useState(overlayState);
|
||||
useEffect(
|
||||
() => {
|
||||
if (popoverRef.el) {
|
||||
if (!state.isOverlayVisible) {
|
||||
popoverRef.el.style.visibility = "hidden";
|
||||
} else {
|
||||
popoverRef.el.style.visibility = "visible";
|
||||
}
|
||||
}
|
||||
},
|
||||
() => [state.isOverlayVisible]
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
import { MAIN_PLUGINS } from "./plugin_sets";
|
||||
import { createBaseContainer, SUPPORTED_BASE_CONTAINER_NAMES } from "./utils/base_container";
|
||||
import { fillShrunkPhrasingParent, removeClass } from "./utils/dom";
|
||||
import { isEmpty } from "./utils/dom_info";
|
||||
import { resourceSequenceSymbol, withSequence } from "./utils/resource";
|
||||
import { fixInvalidHTML, initElementForEdition } from "./utils/sanitize";
|
||||
import { setElementContent } from "@web/core/utils/html";
|
||||
|
||||
/**
|
||||
* @typedef { import("./plugin_sets").SharedMethods } SharedMethods
|
||||
* @typedef {typeof import("./plugin").Plugin} PluginConstructor
|
||||
**/
|
||||
|
||||
/**
|
||||
* @typedef { Object } CollaborationConfig
|
||||
* @property { string } collaboration.peerId
|
||||
* @property { Object } collaboration.busService
|
||||
* @property { Object } collaboration.collaborationChannel
|
||||
* @property { String } collaboration.collaborationChannel.collaborationModelName
|
||||
* @property { String } collaboration.collaborationChannel.collaborationFieldName
|
||||
* @property { Number } collaboration.collaborationChannel.collaborationResId
|
||||
* @property { 'start' | 'focus' } [collaboration.collaborativeTrigger]
|
||||
|
||||
* @typedef { Object } EditorConfig
|
||||
* @property { string } [content]
|
||||
* @property { boolean } [allowInlineAtRoot]
|
||||
* @property { string[] } [baseContainers]
|
||||
* @property { PluginConstructor[] } [Plugins]
|
||||
* @property { string[] } [classList]
|
||||
* @property { Object } [localOverlayContainers]
|
||||
* @property { Object } [embeddedComponentInfo]
|
||||
* @property { Object } [resources]
|
||||
* @property { string } [direction="ltr"]
|
||||
* @property { Function } [onChange]
|
||||
* @property { Function } [onEditorReady]
|
||||
* @property { boolean } [dropImageAsAttachment]
|
||||
* @property { CollaborationConfig } [collaboration]
|
||||
* @property { Function } getRecordInfo
|
||||
*/
|
||||
|
||||
function sortPlugins(plugins) {
|
||||
const initialPlugins = new Set(plugins);
|
||||
const inResult = new Set();
|
||||
// need to sort them
|
||||
const result = [];
|
||||
let P;
|
||||
|
||||
function findPlugin() {
|
||||
for (const P of initialPlugins) {
|
||||
if (P.dependencies.every((dep) => inResult.has(dep))) {
|
||||
initialPlugins.delete(P);
|
||||
return P;
|
||||
}
|
||||
}
|
||||
}
|
||||
while ((P = findPlugin())) {
|
||||
inResult.add(P.id);
|
||||
result.push(P);
|
||||
}
|
||||
if (initialPlugins.size) {
|
||||
const messages = [];
|
||||
for (const P of initialPlugins) {
|
||||
messages.push(
|
||||
`"${P.id}" is missing (${P.dependencies
|
||||
.filter((d) => !inResult.has(d))
|
||||
.join(", ")})`
|
||||
);
|
||||
}
|
||||
throw new Error(`Missing dependencies: ${messages.join(", ")}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class Editor {
|
||||
/**
|
||||
* @param { EditorConfig } config
|
||||
*/
|
||||
constructor(config, services) {
|
||||
this.isReady = false;
|
||||
this.isDestroyed = false;
|
||||
this.config = config;
|
||||
this.services = services;
|
||||
this.resources = null;
|
||||
this.plugins = [];
|
||||
/** @type { HTMLElement } **/
|
||||
this.editable = null;
|
||||
/** @type { Document } **/
|
||||
this.document = null;
|
||||
/** @ts-ignore @type { SharedMethods } **/
|
||||
this.shared = {};
|
||||
}
|
||||
|
||||
attachTo(editable) {
|
||||
if (this.isDestroyed || this.editable) {
|
||||
throw new Error("Cannot re-attach an editor");
|
||||
}
|
||||
this.editable = editable;
|
||||
this.document = editable.ownerDocument;
|
||||
this.preparePlugins();
|
||||
if ("content" in this.config) {
|
||||
setElementContent(editable, fixInvalidHTML(this.config.content));
|
||||
if (isEmpty(editable)) {
|
||||
const baseContainer = createBaseContainer(
|
||||
this.config.baseContainers[0],
|
||||
this.document
|
||||
);
|
||||
fillShrunkPhrasingParent(baseContainer);
|
||||
editable.replaceChildren(baseContainer);
|
||||
}
|
||||
}
|
||||
editable.setAttribute("contenteditable", true);
|
||||
initElementForEdition(editable, { allowInlineAtRoot: !!this.config.allowInlineAtRoot });
|
||||
editable.classList.add("odoo-editor-editable");
|
||||
if (this.config.classList) {
|
||||
editable.classList.add(...this.config.classList);
|
||||
}
|
||||
if (this.config.height) {
|
||||
editable.style.height = this.config.height;
|
||||
}
|
||||
if (
|
||||
!this.config.baseContainers.every((name) =>
|
||||
SUPPORTED_BASE_CONTAINER_NAMES.includes(name)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid baseContainers: ${this.config.baseContainers.join(
|
||||
", "
|
||||
)}. Supported: ${SUPPORTED_BASE_CONTAINER_NAMES.join(", ")}`
|
||||
);
|
||||
}
|
||||
this.startPlugins();
|
||||
this.isReady = true;
|
||||
this.config.onEditorReady?.();
|
||||
}
|
||||
|
||||
preparePlugins() {
|
||||
const Plugins = sortPlugins(this.config.Plugins || MAIN_PLUGINS);
|
||||
this.config = Object.assign({}, ...Plugins.map((P) => P.defaultConfig), this.config);
|
||||
const plugins = new Map();
|
||||
for (const P of Plugins) {
|
||||
if (P.id === "") {
|
||||
throw new Error(`Missing plugin id (class ${P.name})`);
|
||||
}
|
||||
if (plugins.has(P.id)) {
|
||||
throw new Error(`Duplicate plugin id: ${P.id}`);
|
||||
}
|
||||
const imports = {};
|
||||
for (const dep of P.dependencies) {
|
||||
if (plugins.has(dep)) {
|
||||
imports[dep] = {};
|
||||
for (const h of plugins.get(dep).shared) {
|
||||
imports[dep][h] = this.shared[dep][h];
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Missing dependency for plugin ${P.id}: ${dep}`);
|
||||
}
|
||||
}
|
||||
plugins.set(P.id, P);
|
||||
const plugin = new P(this.document, this.editable, imports, this.config, this.services);
|
||||
this.plugins.push(plugin);
|
||||
const exports = {};
|
||||
for (const h of P.shared) {
|
||||
if (!(h in plugin)) {
|
||||
throw new Error(`Missing helper implementation: ${h} in plugin ${P.id}`);
|
||||
}
|
||||
exports[h] = plugin[h].bind(plugin);
|
||||
}
|
||||
this.shared[P.id] = exports;
|
||||
}
|
||||
const resources = this.createResources();
|
||||
for (const plugin of this.plugins) {
|
||||
plugin._resources = resources;
|
||||
}
|
||||
this.resources = resources;
|
||||
}
|
||||
|
||||
startPlugins() {
|
||||
for (const plugin of this.plugins) {
|
||||
plugin.setup();
|
||||
}
|
||||
this.resources["normalize_handlers"].forEach((cb) => cb(this.editable));
|
||||
this.resources["start_edition_handlers"].forEach((cb) => cb());
|
||||
}
|
||||
|
||||
createResources() {
|
||||
const resources = {};
|
||||
|
||||
function registerResources(obj) {
|
||||
for (const key in obj) {
|
||||
if (!(key in resources)) {
|
||||
resources[key] = [];
|
||||
}
|
||||
resources[key].push(obj[key]);
|
||||
}
|
||||
}
|
||||
if (this.config.resources) {
|
||||
registerResources(this.config.resources);
|
||||
}
|
||||
for (const plugin of this.plugins) {
|
||||
if (plugin.resources) {
|
||||
registerResources(plugin.resources);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key in resources) {
|
||||
const resource = resources[key]
|
||||
.flat()
|
||||
.map((r) => {
|
||||
const isObjectWithSequence =
|
||||
typeof r === "object" && r !== null && resourceSequenceSymbol in r;
|
||||
return isObjectWithSequence ? r : withSequence(10, r);
|
||||
})
|
||||
.sort((a, b) => a[resourceSequenceSymbol] - b[resourceSequenceSymbol])
|
||||
.map((r) => r.object);
|
||||
|
||||
resources[key] = resource;
|
||||
Object.freeze(resources[key]);
|
||||
}
|
||||
|
||||
return Object.freeze(resources);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} resourceId
|
||||
* @returns {Array}
|
||||
*/
|
||||
getResource(resourceId) {
|
||||
return this.resources[resourceId] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the functions registered under resourceId with the given
|
||||
* arguments.
|
||||
*
|
||||
* @param {string} resourceId
|
||||
* @param {...any} args The arguments to pass to the handlers
|
||||
*/
|
||||
dispatchTo(resourceId, ...args) {
|
||||
this.getResource(resourceId).forEach((handler) => handler(...args));
|
||||
}
|
||||
|
||||
getContent() {
|
||||
return this.getElContent().innerHTML;
|
||||
}
|
||||
|
||||
getElContent() {
|
||||
const el = this.editable.cloneNode(true);
|
||||
this.resources["clean_for_save_handlers"].forEach((cb) => cb({ root: el }));
|
||||
return el;
|
||||
}
|
||||
|
||||
destroy(willBeRemoved) {
|
||||
if (this.editable) {
|
||||
let plugin;
|
||||
while ((plugin = this.plugins.pop())) {
|
||||
plugin.destroy();
|
||||
}
|
||||
this.shared = {};
|
||||
if (!willBeRemoved) {
|
||||
// we only remove class/attributes when necessary. If we know that the editable
|
||||
// element will be removed, no need to make changes that may require the browser
|
||||
// to recompute the layout
|
||||
this.editable.removeAttribute("contenteditable");
|
||||
removeClass(this.editable, "odoo-editor-editable");
|
||||
}
|
||||
this.editable = null;
|
||||
}
|
||||
this.isDestroyed = true;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue