squashed commit

This commit is contained in:
hoangvv
2026-09-18 13:55:25 +07:00
commit 039c98d4d0
45882 changed files with 24235585 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
from . import models
from . import controllers
+52
View File
@@ -0,0 +1,52 @@
{
'name': "HTML Editor",
'summary': """
A Html Editor component and plugin system
""",
'description': """
Html Editor
==========================
This addon provides an extensible, maintainable editor.
""",
'website': "https://www.odoo.com",
'version': '1.0',
'category': 'Hidden',
'depends': ['base', 'bus', 'web'],
'auto_install': True,
'assets': {
'web.assets_frontend': [
('include', 'html_editor.assets_media_dialog')
],
'web.assets_backend': [
'html_editor/static/src/**/*',
('include', 'html_editor.assets_media_dialog'),
('include', 'html_editor.assets_link_popover'),
('remove', 'html_editor/static/src/components/history_dialog/history_dialog.dark.scss'),
('remove', 'html_editor/static/src/main/toolbar/toolbar.dark.scss'),
],
'html_editor.assets_media_dialog': [
# Bundle to use the media dialog in the backend and the frontend
'html_editor/static/src/main/media/media_dialog/**/*',
'html_editor/static/src/utils/**/*',
],
"web.assets_web_dark": [
'html_editor/static/src/components/history_dialog/history_dialog.dark.scss',
'html_editor/static/src/main/toolbar/toolbar.dark.scss',
],
'web.assets_unit_tests': [
'html_editor/static/tests/**/*',
],
'html_editor.assets_image_cropper': [
'html_editor/static/lib/cropperjs/cropper.css',
'html_editor/static/lib/cropperjs/cropper.js',
],
'html_editor.assets_link_popover': [
'html_editor/static/src/main/link/link_popover.js',
'html_editor/static/src/main/link/link_popover.xml',
'html_editor/static/src/main/link/utils.js',
],
},
'license': 'LGPL-3'
}
@@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
+645
View File
@@ -0,0 +1,645 @@
import contextlib
import re
import uuid
from base64 import b64decode
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.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
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)?:\s*.*?)"
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, 0, re.MULTILINE)
return svg
def _clean_context(self):
# avoid allowed_company_ids which may erroneously restrict based on website
context = dict(request.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='json', 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='json', 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):
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
)
@http.route(['/web_editor/attachment/add_data', '/html_editor/attachment/add_data'], type='json', 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 = tools.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='json', 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="json", 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='json', 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('web_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:
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/generate_text", "/html_editor/generate_text"], type="json", auth="user")
def generate_text(self, prompt, conversation_history):
try:
IrConfigParameter = request.env['ir.config_parameter'].sudo()
olg_api_endpoint = IrConfigParameter.get_param('web_editor.olg_api_endpoint', DEFAULT_OLG_ENDPOINT)
# Telemetry disabled - database_id not sent to prevent data transmission
response = iap_tools.iap_jsonrpc(olg_api_endpoint + "/api/olg/1/chat", params={
'prompt': prompt,
'conversation_history': conversation_history or [],
# Telemetry disabled - database_id not sent to prevent data transmission
}, 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='json', 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="json", 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')
document.check_field_access_rights('read', [field_name])
document.check_field_access_rights('write', [field_name])
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="json", 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="json", 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_sudo = request.env[action_type].sudo().browse(action.id)
model = request.env[action_sudo.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)}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
from . import ir_attachment
@@ -0,0 +1,87 @@
# 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,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,3 @@
.html-history-dialog .history-container {
--border-color: #3C3E4B;
}
@@ -0,0 +1,112 @@
/** @odoo-module **/
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 } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { user } from "@web/core/user";
import { HtmlViewer } from "@html_editor/fields/html_viewer";
import { READONLY_MAIN_EMBEDDINGS } from "@html_editor/others/embedded_components/embedding_sets";
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 },
};
static defaultProps = {
title: _t("History"),
noContentHelper: markup(""),
embeddedComponents: [...READONLY_MAIN_EMBEDDINGS],
};
state = useState({
revisionsData: [],
revisionContent: null,
revisionComparison: null,
revisionId: null,
});
setup() {
this.size = "xl";
this.title = this.props.title;
this.orm = useService("orm");
this.notebookTabs = [_t("Content"), _t("Comparison")];
onMounted(() => this.init());
}
getConfig(value) {
return {
value: this.state[value],
embeddedComponents: this.props.embeddedComponents,
};
}
async init() {
this.state.revisionsData = this.props.historyMetadata;
await this.updateCurrentRevision(this.props.historyMetadata[0]["revision_id"]);
}
async updateCurrentRevision(revisionId) {
if (this.state.revisionId === revisionId) {
return;
}
this.env.services.ui.block();
this.state.revisionId = revisionId;
this.state.revisionContent = await this.getRevisionContent(revisionId);
this.state.revisionComparison = await this.getRevisionComparison(revisionId);
this.env.services.ui.unblock();
}
getRevisionComparison = memoize(
async function getRevisionComparison(revisionId) {
const comparison = await this.orm.call(
this.props.recordModel,
"html_field_history_get_comparison_at_revision",
[this.props.recordId, this.props.versionedFieldName, revisionId]
);
return markup(comparison);
}.bind(this)
);
getRevisionContent = memoize(
async function getRevisionContent(revisionId) {
const content = await this.orm.call(
this.props.recordModel,
"html_field_history_get_content_at_revision",
[this.props.recordId, this.props.versionedFieldName, revisionId]
);
return 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();
}
/**
* Getters
**/
getRevisionDate(revision) {
return formatDateTime(
DateTime.fromISO(revision["create_date"], { zone: "utc" }).setZone(user.tz)
);
}
}
@@ -0,0 +1,52 @@
.html-history-dialog {
.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 {
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 {
margin: 38px 0 0 8px;
overflow: auto;
max-height: 100%;
width: 220px;
float : left;
.btn {
border-radius: 0;
display: block;
text-align: left;
width: 220px;
margin-bottom: 8px;
position: relative;
&:before {
content: '\f105';
font-family: 'FontAwesome';
position: absolute;
right : 8px;
top: 0;
font-size: 34px;
}
}
}
}
@@ -0,0 +1,46 @@
<?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'">
<div class="dialog-container html-history-dialog">
<div class="revision-list d-flex flex-column align-content-stretch">
<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-class="btn btn-outline-primary #{state.revisionId === rev.revision_id ? 'active' : ''}"
t-on-click="() => this.updateCurrentRevision(rev.revision_id )">
<strong><t t-esc="this.getRevisionDate(rev)" /></strong>
<br/>
<small><t t-esc="rev.create_user_name" /></small>
</a>
</t>
</div>
<div class="history-container">
<Notebook defaultPage="'history'">
<t t-set-slot="history" name="'history'" isVisible="true" title="notebookTabs[0]">
<t t-if="state.revisionContent?.length">
<div class="pe-none">
<HtmlViewer config="getConfig('revisionContent')"/>
</div>
</t>
<t t-else="" t-out="props.noContentHelper" />
</t>
<t t-set-slot="comparison" name="'comparison'" isVisible="true" title="notebookTabs[1]">
<div class="pe-none">
<HtmlViewer config="getConfig('revisionComparison')"/>
</div>
</t>
</Notebook>
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="_onRestoreRevisionClick">Restore history</button>
<button class="btn btn-secondary" t-on-click="props.close">Discard</button>
</t>
</Dialog>
</t>
</templates>
@@ -0,0 +1,173 @@
import {
containsAnyNonPhrasingContent,
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";
export class BaseContainerPlugin extends Plugin {
static id = "baseContainer";
static shared = ["createBaseContainer", "getDefaultNodeName", "isCandidateForBaseContainer"];
/**
* Register one of the predicates for `invalid_for_base_container_predicates`
* as a property for optimization, see variants of `isCandidateForBaseContainer`.
*/
hasNonPhrasingContentPredicate = (element) => {
return 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) => {
return 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)),
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.baseContainer || "P";
}
/**
* 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) {
if (this.config.baseContainer && this.config.baseContainer !== "DIV") {
return;
}
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,801 @@
import {
isTextNode,
isParagraphRelatedElement,
isIconElement,
isEmptyBlock,
} from "../utils/dom_info";
import { Plugin } from "../plugin";
import { closestBlock, isBlock } from "../utils/blocks";
import { fillClipboardData } from "../utils/clipboard";
import {
unwrapContents,
wrapInlinesInBlocks,
splitTextNode,
setTagName,
fillEmpty,
} from "../utils/dom";
import { ancestors, 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";
/**
* @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",
// Odoo tables
"o_table",
"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();
const commonAncestor = selection.commonAncestorContainer;
if (commonAncestor && commonAncestor.nodeType === Node.ELEMENT_NODE) {
this.dispatchTo("clean_handlers", commonAncestor);
}
let clonedContents = selection.cloneContents();
if (!clonedContents.hasChildNodes()) {
if (commonAncestor && commonAncestor.nodeType === Node.ELEMENT_NODE) {
this.dispatchTo("normalize_handlers", commonAncestor);
}
return;
}
// Repair the copied range.
if (clonedContents.firstChild.nodeName === "LI") {
const list = selection.commonAncestorContainer.cloneNode();
list.replaceChildren(...childNodes(clonedContents));
clonedContents = list;
}
if (
clonedContents.firstChild.nodeName === "TR" ||
clonedContents.firstChild.nodeName === "TD"
) {
// We enter this case only if selection is within single table.
const table = closestElement(selection.commonAncestorContainer, "table");
const tableClone = table.cloneNode(true);
// A table is considered fully selected if it is nested inside a
// cell that is itself selected, or if all its own cells are
// selected.
const isTableFullySelected =
(table.parentElement &&
!!closestElement(table.parentElement, "td.o_selected_td")) ||
[...table.querySelectorAll("td")]
.filter((td) => closestElement(td, "table") === table)
.every((td) => td.classList.contains("o_selected_td"));
if (!isTableFullySelected) {
for (const td of tableClone.querySelectorAll("td:not(.o_selected_td)")) {
if (closestElement(td, "table") === tableClone) {
// ignore nested
td.remove();
}
}
const trsWithoutTd = Array.from(tableClone.querySelectorAll("tr")).filter(
(row) => !row.querySelector("td")
);
for (const tr of trsWithoutTd) {
if (closestElement(tr, "table") === tableClone) {
// ignore nested
tr.remove();
}
}
}
// If it is fully selected, clone the whole table rather than
// just its rows.
clonedContents = tableClone;
}
const startTable = closestElement(selection.startContainer, "table");
if (clonedContents.firstChild.nodeName === "TABLE" && startTable) {
// Make sure the full leading table is copied.
clonedContents.firstChild.after(startTable.cloneNode(true));
clonedContents.firstChild.remove();
}
const endTable = closestElement(selection.endContainer, "table");
if (clonedContents.lastChild.nodeName === "TABLE" && endTable) {
// Make sure the full trailing table is copied.
clonedContents.lastChild.before(endTable.cloneNode(true));
clonedContents.lastChild.remove();
}
const commonAncestorElement = closestElement(selection.commonAncestorContainer);
if (commonAncestorElement && !isBlock(clonedContents.firstChild)) {
// Get the list of ancestor elements starting from the provided
// commonAncestorElement up to the block-level element.
const blockEl = closestBlock(commonAncestorElement);
const ancestorsList = [
commonAncestorElement,
...ancestors(commonAncestorElement, blockEl),
];
// Wrap rangeContent with clones of their ancestors to keep the styles.
for (const ancestor of ancestorsList) {
// Keep the formatting by keeping inline ancestors and paragraph
// related ones like headings etc.
if (!isBlock(ancestor) || isParagraphRelatedElement(ancestor)) {
const clone = ancestor.cloneNode();
clone.append(...childNodes(clonedContents));
clonedContents.appendChild(clone);
}
}
}
fillClipboardData(ev, selection.textContent(), clonedContents);
if (commonAncestor && commonAncestor.nodeType === Node.ELEMENT_NODE) {
this.dispatchTo("normalize_handlers", commonAncestor);
}
}
/**
* Handle safe pasting of html or plain text into the editor.
*/
onPaste(ev) {
let selection = this.dependencies.selection.getEditableSelection();
if (!selection.anchorNode.isConnected) {
return;
}
ev.preventDefault();
this.dependencies.history.stageSelection();
this.dispatchTo("before_paste_handlers", selection);
// 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) {
const targetSupportsHtmlContent = isHtmlContentSupported(selection.anchorNode);
if (!targetSupportsHtmlContent) {
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 = 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 (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(selection, text);
}
}
/**
* @param {EditorSelection} selection
* @param {string} text
*/
pasteText(selection, text) {
const textFragments = text.split(/\r?\n/);
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 &nbsp.
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);
// The selection must be updated after calling insert, as the insertion
// process modifies the selection.
selection = this.dependencies.selection.getEditableSelection();
if (textIndex < textFragments.length) {
// 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();
}
selection = this.dependencies.selection.getEditableSelection();
}
}
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");
}
// 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 = setTagName(node, "TBODY");
}
} else if (node.nodeName === "TH") {
// Convert all <th> into <td>
node = setTagName(node, "TD");
}
if (node.nodeName === "TD") {
// 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]) {
// 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) && !isIconElement(node)) {
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();
if (!isHtmlContentSupported(ev.target)) {
return;
}
const selection = this.dependencies.selection.getEditableSelection();
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");
// Mark images as having to be saved as attachments.
if (this.config.dropImageAsAttachment) {
imageNode.classList.add("o_b64_image_to_save");
}
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);
};
});
}
// @phoenix @todo: move to Odoo plugin?
/**
* Returns true if the provided node can suport html content.
*
* @param {Node} node
* @returns {boolean}
*/
export function isHtmlContentSupported(node) {
return !closestElement(
node,
'[data-oe-model]:not([data-oe-field="arch"]):not([data-oe-type="html"]),[data-oe-translation-id]'
);
}
@@ -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();
}
}
}
}
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,658 @@
import { _t } from "@web/core/l10n/translation";
import { Plugin } from "../plugin";
import { closestBlock, isBlock } from "../utils/blocks";
import {
cleanTrailingBR,
fillEmpty,
fillShrunkPhrasingParent,
makeContentsInline,
removeClass,
splitTextNode,
unwrapContents,
wrapInlinesInBlocks,
} from "../utils/dom";
import {
allowsParagraphRelatedElements,
getDeepestPosition,
isContentEditable,
isContentEditableAncestor,
isEmptyBlock,
isListElement,
isListItemElement,
isParagraphRelatedElement,
isProtecting,
isProtected,
isSelfClosingElement,
isShrunkBlock,
isTangible,
isUnprotecting,
listElementSelector,
paragraphRelatedElementsSelector,
isEditorTab,
isPhrasingContent,
} 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";
/**
* 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
*/
export class DomPlugin extends Plugin {
static id = "dom";
static dependencies = ["baseContainer", "selection", "history", "split", "delete", "lineBreak"];
static shared = ["insert", "copyAttributes", "setTag", "setTagName"];
resources = {
user_commands: [
{ id: "insertFontAwesome", run: this.insertFontAwesome.bind(this) },
{ id: "setTag", run: this.setTag.bind(this) },
{
id: "insertSeparator",
title: _t("Separator"),
description: _t("Insert a horizontal rule separator"),
icon: "fa-minus",
run: this.insertSeparator.bind(this),
},
],
powerbox_items: {
categoryId: "structure",
commandId: "insertSeparator",
},
/** Handlers */
clean_handlers: this.removeEmptyClassAndStyleAttributes.bind(this),
clean_for_save_handlers: ({ root }) => {
this.removeEmptyClassAndStyleAttributes(root);
for (const el of root.querySelectorAll("hr[contenteditable]")) {
el.removeAttribute("contenteditable");
}
},
normalize_handlers: this.normalize.bind(this),
functional_empty_node_predicates: [isSelfClosingElement, isEditorTab],
};
contentEditableToRemove = new Set();
// Shared
/**
* @param {string | DocumentFragment | Element | null} content
*/
insert(content) {
if (!content) {
return;
}
let selection = this.dependencies.selection.getEditableSelection();
let startNode;
let insertBefore = false;
if (!selection.isCollapsed) {
this.dependencies.delete.deleteSelection();
selection = this.dependencies.selection.getEditableSelection();
}
if (selection.startContainer.nodeType === Node.TEXT_NODE) {
insertBefore = !selection.startOffset;
splitTextNode(selection.startContainer, selection.startOffset, DIRECTIONS.LEFT);
startNode = selection.startContainer;
}
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);
}
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);
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) {
this.dispatchTo("clean_handlers", source);
if (source?.nodeType !== Node.ELEMENT_NODE || target?.nodeType !== Node.ELEMENT_NODE) {
return;
}
// TODO: provide a resource to ignore some attributes.
const ignoredAttrs = new Set();
const ignoredClasses = new Set(this.getResource("system_classes"));
for (const attr of source.attributes) {
if (ignoredAttrs.has(attr)) {
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 setTag 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;
}
// --------------------------------------------------------------------------
// 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 });
}
/**
* @param {Object} param0
* @param {string} param0.tagName
* @param {string} [param0.extraClass]
*/
setTag({ 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 { commonAncestorContainer } = this.dependencies.selection.getEditableSelection();
// Clean before preserving cursors otherwise the saved cursors might
// reference a node that will be removed when setTagName eventually
// calls clean of its own.
this.dispatchTo("clean_handlers", closestElement(commonAncestorContainer));
const cursors = this.dependencies.selection.preserveSelection();
const targetedBlocks = [...this.dependencies.selection.getTargetedBlocks()];
const deepestTargetedBlocks = targetedBlocks.filter(
(block) =>
!descendants(block).some((descendant) => targetedBlocks.includes(descendant)) &&
block.isContentEditable
);
for (const block of deepestTargetedBlocks) {
if (
isParagraphRelatedElement(block) ||
isPhrasingContent(block) ||
block.nodeName === "PRE" || // TODO remove: PRE should be a paragraphRelatedElement
isListItemElement(block)
) {
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);
}
} 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.dependencies.history.addStep();
}
insertSeparator() {
const selection = this.dependencies.selection.getEditableSelection();
const sep = this.document.createElement("hr");
const block = closestBlock(selection.startContainer);
const element =
closestElement(selection.startContainer, paragraphRelatedElementsSelector) ||
(block && !isListItemElement(block) ? block : null);
if (element && element !== this.editable) {
if (isEmptyBlock(element)) {
element.before(sep);
} else {
element.after(sep);
const baseContainer = this.dependencies.baseContainer.createBaseContainer();
fillEmpty(baseContainer);
sep.after(baseContainer);
this.dependencies.selection.setCursorStart(baseContainer);
}
}
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");
}
}
}
normalize(el) {
if (el.tagName === "HR") {
el.setAttribute(
"contenteditable",
el.hasAttribute("contenteditable") ? el.getAttribute("contenteditable") : "false"
);
} else {
for (const separator of el.querySelectorAll("hr")) {
separator.setAttribute(
"contenteditable",
separator.hasAttribute("contenteditable")
? separator.getAttribute("contenteditable")
: "false"
);
}
}
}
}
@@ -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,630 @@
import { Plugin } from "../plugin";
import { closestBlock, isBlock } from "../utils/blocks";
import { hasAnyNodesColor, TEXT_CLASSES_REGEX, BG_CLASSES_REGEX } from "@html_editor/utils/color";
import { cleanTextNode, removeEmptyTextNodes, splitTextNode, unwrapContents } from "../utils/dom";
import {
areSimilarElements,
isContentEditable,
isEmptyTextNode,
isEmptyBlock,
isSelfClosingElement,
isTextNode,
isVisibleTextNode,
isZwnbsp,
isZWS,
previousLeaf,
} from "../utils/dom_info";
import {
childNodes,
closestElement,
descendants,
selectElements,
findFurthest,
} from "../utils/dom_traversal";
import { FONT_SIZE_CLASSES, formatsSpecs } from "../utils/formatting";
import { boundariesIn, boundariesOut, DIRECTIONS, leftPos, rightPos } from "../utils/position";
import { prepareUpdate } from "@html_editor/utils/dom_state";
import { _t } from "@web/core/l10n/translation";
import { callbacksForCursorUpdate } from "@html_editor/utils/selection";
import { withSequence } from "@html_editor/utils/resource";
import { isFakeLineBreak } from "../utils/dom_state";
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",
title: _t("Toggle bold"),
icon: "fa-bold",
run: this.formatSelection.bind(this, "bold"),
},
{
id: "formatItalic",
title: _t("Toggle italic"),
icon: "fa-italic",
run: this.formatSelection.bind(this, "italic"),
},
{
id: "formatUnderline",
title: _t("Toggle underline"),
icon: "fa-underline",
run: this.formatSelection.bind(this, "underline"),
},
{
id: "formatStrikethrough",
title: _t("Toggle strikethrough"),
icon: "fa-strikethrough",
run: this.formatSelection.bind(this, "strikeThrough"),
},
{
id: "formatFontSize",
run: ({ size }) => {
return this.formatSelection("fontSize", {
applyStyle: true,
formatProps: { size },
});
},
},
{
id: "formatFontSizeClassName",
run: ({ className }) => {
return this.formatSelection("setFontSizeClassName", {
applyStyle: true,
formatProps: { className },
});
},
},
{
id: "removeFormat",
title: _t("Remove Format"),
icon: "fa-eraser",
run: this.removeFormat.bind(this),
},
],
shortcuts: [
{ hotkey: "control+b", commandId: "formatBold" },
{ hotkey: "control+i", commandId: "formatItalic" },
{ hotkey: "control+u", commandId: "formatUnderline" },
{ hotkey: "control+5", commandId: "formatStrikethrough" },
],
toolbar_groups: withSequence(20, { id: "decoration" }),
toolbar_items: [
{
id: "bold",
groupId: "decoration",
commandId: "formatBold",
isActive: isFormatted(this, "bold"),
},
{
id: "italic",
groupId: "decoration",
commandId: "formatItalic",
isActive: isFormatted(this, "italic"),
},
{
id: "underline",
groupId: "decoration",
commandId: "formatUnderline",
isActive: isFormatted(this, "underline"),
},
{
id: "strikethrough",
groupId: "decoration",
commandId: "formatStrikethrough",
isActive: isFormatted(this, "strikeThrough"),
},
{
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),
intangible_char_for_keyboard_navigation_predicates: (_, char) => char === "\u200b",
};
removeFormat() {
const targetedNodes = this.dependencies.selection.getTargetedNodes();
for (const format of Object.keys(formatsSpecs)) {
if (
!formatsSpecs[format].removeStyle ||
!this.hasSelectionFormat(format, targetedNodes)
) {
continue;
}
this._formatSelection(format, { applyStyle: false });
}
this.dispatchTo("remove_format_handlers");
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(
(node) =>
isTextNode(node) &&
!isZwnbsp(node) &&
!isEmptyTextNode(node) &&
(!/^\n+$/.test(node.nodeValue) || !isBlock(closestElement(node)))
);
const isFormatted = formatsSpecs[format].isFormatted;
return (
targetedTextNodes.length &&
targetedTextNodes.every((node) => isFormatted(node, { editable: this.editable }))
);
}
// @todo: issues:
// - the calls to hasAnyColor should probably be replaced by calls to predicates
// registered as resources (e.g. by the ColorPlugin).
hasAnyFormat(targetedNodes) {
for (const format of Object.keys(formatsSpecs)) {
if (
formatsSpecs[format].removeStyle &&
this.hasSelectionFormat(format, targetedNodes)
) {
return true;
}
}
return (
hasAnyNodesColor(targetedNodes, "color") ||
hasAnyNodesColor(targetedNodes, "backgroundColor")
);
}
formatSelection(...args) {
if (this._formatSelection(...args)) {
this.dependencies.history.addStep();
}
}
// @todo phoenix: refactor this method.
_formatSelection(formatName, { applyStyle, formatProps } = {}) {
// 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+$/.test(n.nodeValue) && !applyStyle))) ||
(n.nodeName === "BR" &&
(isFakeLineBreak(n) ||
previousLeaf(n, closestBlock(n))?.nodeName === "BR"))) &&
isContentEditable(n)
)
);
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 selectedTextNodes) {
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 related to font size (in case the formatting
// comes from the class).
while (
parentNode &&
!isBlock(parentNode) &&
!this.dependencies.split.isUnsplittable(parentNode) &&
(parentNode.classList.length === 0 ||
[...parentNode.classList].every(
(cls) =>
FONT_SIZE_CLASSES.includes(cls) ||
TEXT_CLASSES_REGEX.test(cls) ||
BG_CLASSES_REGEX.test(cls)
))
) {
const isUselessZws =
parentNode.tagName === "SPAN" &&
parentNode.hasAttribute("data-oe-zws-empty-inline") &&
parentNode.getAttributeNames().length === 1;
if (isUselessZws) {
unwrapContents(parentNode);
} else {
const cursors = this.dependencies.selection.preserveSelection();
this.dispatchTo("clean_handlers", parentNode);
// Remove empty text nodes (replaced FEFFs) before splitting,
// to prevent creating empty elements in the DOM.
removeEmptyTextNodes(parentNode, cursors);
cursors.restore();
const newLastAncestorInlineFormat = this.dependencies.split.splitAroundUntil(
currentNode,
parentNode
);
removeFormat(newLastAncestorInlineFormat, formatSpec);
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 && 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) &&
selectedTextNodes.includes(siblings[0]) &&
selectedTextNodes.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 (
selectedTextNodes.length === 1 &&
selectedTextNodes[0] &&
selectedTextNodes[0].textContent === "\u200B"
) {
this.dependencies.selection.setCursorStart(selectedTextNodes[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]")) {
this.cleanElement(element, { preserveSelection });
}
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.length) {
// 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 === "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 descendants(root)) {
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) =>
!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) => {
return 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,25 @@
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) {
const selection = this.document.getSelection();
if (!this.editable.contains(selection?.anchorNode)) {
ev.preventDefault();
return;
}
this.dependencies.history.stageSelection();
this.dispatchTo("beforeinput_handlers", ev);
}
onInput(ev) {
this.dependencies.history.addStep();
this.dispatchTo("input_handlers", ev);
}
}
@@ -0,0 +1,130 @@
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";
/**
* @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),
};
insertLineBreak() {
this.dispatchTo("before_line_break_handlers");
let selection = this.dependencies.selection.getEditableSelection();
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);
} 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,150 @@
import { getDeepestPosition, isParagraphRelatedElement } from "@html_editor/utils/dom_info";
import { Plugin } from "../plugin";
import { isNotAllowedContent } from "./selection_plugin";
import { nodeSize } from "@html_editor/utils/position";
export class NoInlineRootPlugin extends Plugin {
static id = "noInlineRoot";
static dependencies = ["baseContainer", "selection", "history"];
resources = {
...(!this.config.allowInlineAtRoot && {
fix_selection_on_editable_root_handlers: 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;
this.preventNextPointerdownFix = 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 {Selection} selection - Collapsed selection at the editable root.
*/
fixSelectionOnEditableRoot(selection) {
if (
!selection.isCollapsed ||
selection.anchorNode !== this.editable ||
this.config.allowInlineAtRoot
) {
return false;
}
const nodeAfterCursor = this.editable.childNodes[selection.anchorOffset];
const nodeBeforeCursor = nodeAfterCursor && nodeAfterCursor.previousElementSibling;
return (
this.fixSelectionOnEditableRootArrowKeys(nodeAfterCursor, nodeBeforeCursor) ||
this.fixSelectionOnEditableRootGeneric(nodeAfterCursor, nodeBeforeCursor) ||
this.fixSelectionOnEditableRootCreateP(nodeAfterCursor, nodeBeforeCursor)
);
}
/**
* @param {Node} nodeAfterCursor
* @param {Node} nodeBeforeCursor
* @returns {boolean}
*/
fixSelectionOnEditableRootArrowKeys(nodeAfterCursor, nodeBeforeCursor) {
const currentKeyDown = this.currentKeyDown;
delete this.currentKeyDown;
if (currentKeyDown === "ArrowRight" || currentKeyDown === "ArrowDown") {
while (nodeAfterCursor && isNotAllowedContent(nodeAfterCursor)) {
nodeAfterCursor = nodeAfterCursor.nextElementSibling;
}
const [anchorNode] = getDeepestPosition(nodeAfterCursor, 0);
if (nodeAfterCursor) {
this.dependencies.selection.setSelection({
anchorNode: anchorNode,
anchorOffset: 0,
});
return true;
} else {
this.dependencies.selection.resetActiveSelection();
}
} else if (currentKeyDown === "ArrowLeft" || currentKeyDown === "ArrowUp") {
while (nodeBeforeCursor && isNotAllowedContent(nodeBeforeCursor)) {
nodeBeforeCursor = nodeBeforeCursor.previousElementSibling;
}
if (nodeBeforeCursor) {
const [anchorNode, anchorOffset] = getDeepestPosition(
nodeBeforeCursor,
nodeSize(nodeBeforeCursor)
);
this.dependencies.selection.setSelection({
anchorNode: anchorNode,
anchorOffset: anchorOffset,
});
return true;
} else {
this.dependencies.selection.resetActiveSelection();
}
}
}
/**
* @param {Node} nodeAfterCursor
* @param {Node} nodeBeforeCursor
* @returns {boolean}
*/
fixSelectionOnEditableRootGeneric(nodeAfterCursor, nodeBeforeCursor) {
// Handle arrow key presses.
if (nodeAfterCursor && isParagraphRelatedElement(nodeAfterCursor)) {
// Cursor is right before a 'P'.
this.dependencies.selection.setCursorStart(nodeAfterCursor);
return true;
} else if (nodeBeforeCursor && isParagraphRelatedElement(nodeBeforeCursor)) {
// Cursor is right after a 'P'.
this.dependencies.selection.setCursorEnd(nodeBeforeCursor);
return true;
}
}
/**
* 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}
*/
fixSelectionOnEditableRootCreateP(nodeAfterCursor, nodeBeforeCursor) {
if (this.isPointerDown && !this.preventNextPointerdownFix) {
// The setSelection at the end of this fix could trigger another
// setSelection (that would re-trigger this fix). So this flag is
// used to prevent to fix twice from the same mouse event.
this.preventNextPointerdownFix = true;
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;
}
return false;
}
}
@@ -0,0 +1,159 @@
import {
Component,
onWillDestroy,
useEffect,
useExternalListener,
useRef,
useState,
useSubEnv,
xml,
} from "@odoo/owl";
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,
getContainer: Function,
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;
let getTarget, position;
if (this.props.target) {
getTarget = () => this.props.target;
} else {
useExternalListener(this.props.bus, "updatePosition", () => {
position.unlock();
});
const editable = this.props.editable;
this.rangeElement = editable.ownerDocument.createElement("range-el");
editable.after(this.rangeElement);
onWillDestroy(() => {
this.rangeElement.remove();
});
getTarget = this.getSelectionTarget.bind(this);
}
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 editableDocument = this.props.editable.ownerDocument;
useExternalListener(editableDocument, "pointerdown", this.props.close);
// Listen to pointerdown outside the iframe
if (editableDocument !== document) {
useExternalListener(document, "pointerdown", this.props.close);
}
}
if (this.props.hasAutofocus) {
useActiveElement("root");
}
const positionOptions = {
position: "bottom-start",
container: this.props.getContainer,
...this.props.positionOptions,
onPositioned: (el, solution) => {
this.props.positionOptions?.onPositioned?.(el, solution);
this.updateVisibility(el, solution);
},
};
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, using disableObserver and enableObserver 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.disableObserver();
const clonedRange = range.cloneRange();
const shadowCaret = doc.createTextNode("|");
clonedRange.insertNode(shadowCaret);
clonedRange.selectNode(shadowCaret);
rect = clonedRange.getBoundingClientRect();
shadowCaret.remove();
clonedRange.detach();
this.props.history.enableObserver();
}
// 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) {
// @todo: mobile tests rely on a visible (yet overflowing) toolbar
// Remove this once the mobile toolbar is fixed?
if (this.env.isSmall) {
return;
}
const container = closestScrollableY(this.props.editable) || this.props.getContainer();
const containerRect = container.getBoundingClientRect();
const shouldBeVisible = solution.top > containerRect.top;
overlayElement.style.visibility = shouldBeVisible ? "visible" : "hidden";
this.overlayState.isOverlayVisible = shouldBeVisible;
}
}
@@ -0,0 +1,140 @@
import { markRaw, EventBus } from "@odoo/owl";
import { Plugin } from "../plugin";
import { EditorOverlay } from "./overlay";
import { throttleForAnimation } from "@web/core/utils/timing";
import { findUpTo } from "@html_editor/utils/dom_traversal";
/**
* @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"];
resources = {
step_added_handlers: this.getScrollContainer.bind(this),
};
overlays = [];
setup() {
this.iframe = this.document.defaultView.frameElement;
this.topDocument = this.iframe?.ownerDocument || this.document;
this.container = this.getScrollContainer();
this.throttledUpdateContainer = throttleForAnimation(() => {
this.container = this.getScrollContainer();
});
this.addDomListener(this.topDocument.defaultView, "resize", this.throttledUpdateContainer);
}
destroy() {
this.throttledUpdateContainer.cancel();
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, () => this.container, props, options);
this.overlays.push(overlay);
return overlay;
}
getScrollContainer() {
const isScrollable = (element) =>
element.scrollHeight > element.clientHeight &&
["auto", "scroll"].includes(getComputedStyle(element).overflowY);
return (
findUpTo(this.iframe || this.editable, null, isScrollable) ||
this.topDocument.documentElement
);
}
}
export class Overlay {
constructor(plugin, C, getContainer, 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();
this.getContainer = getContainer;
}
/**
* @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,
getContainer: this.getContainer,
close: this.close.bind(this),
isOverlayOpen: this.isOverlayOpen.bind(this),
history: {
enableObserver: this.plugin.dependencies.history.enableObserver,
disableObserver: this.plugin.dependencies.history.disableObserver,
},
}),
{
...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";
const PROTECTED_SELECTOR = `[data-oe-protected="true"],[data-oe-protected=""]`;
const UNPROTECTED_SELECTOR = `[data-oe-protected="false"]`;
/**
* @typedef { Object } ProtectedNodeShared
* @property { ProtectedNodePlugin['setProtectingNode'] } setProtectingNode
*/
export class ProtectedNodePlugin extends Plugin {
static id = "protectedNode";
static shared = ["setProtectingNode"];
resources = {
/** Handlers */
clean_for_save_handlers: ({ root }) => this.cleanForSave(root),
normalize_handlers: 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;
}
}
beforeFilteringMutationRecords(records) {
for (const record of records) {
if (record.type === "childList") {
if (record.target.nodeType !== Node.ELEMENT_NODE) {
return;
}
if (
(this.protectedNodes.has(record.target) &&
!record.target.matches(UNPROTECTED_SELECTOR)) ||
record.target.matches(PROTECTED_SELECTOR)
) {
for (const addedNode of record.addedNodes) {
this.protectNode(addedNode);
}
} else if (
!this.protectedNodes.has(record.target) ||
record.target.matches(UNPROTECTED_SELECTOR)
) {
for (const addedNode of record.addedNodes) {
this.unProtectNode(addedNode);
}
}
}
}
}
/**
* @param {MutationRecord} record
* @return {boolean}
*/
isMutationRecordSavable(record) {
if (record.type === "attributes") {
if (record.attributeName === "contenteditable") {
return (
!this.protectedNodes.has(record.target) ||
record.target.matches(UNPROTECTED_SELECTOR)
);
}
} else if (record.target.nodeType === Node.ELEMENT_NODE) {
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.document.defaultView);
}
/**
* 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"],
});
}
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,48 @@
import { Plugin } 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"];
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.document.defaultView });
}
for (const shortcut of this.getResource("shortcuts")) {
const command = this.dependencies.userCommand.getCommand(shortcut.commandId);
this.addShortcut(shortcut.hotkey, () => {
command.run(shortcut.commandParams);
});
}
}
addShortcut(hotkey, action) {
this.services.hotkey.add(hotkey, action, {
area: () => this.editable,
bypassEditableProtection: true,
allowRepeat: true,
});
}
}
@@ -0,0 +1,326 @@
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.
return (
!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.getEditableSelection();
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 removeEmptyAndFill = (node) => {
if (isProtecting(node) || isProtected(node)) {
// TODO ABD: add test
return;
} else if (!isBlock(node) && !isVisible(node)) {
const parent = node.parentElement;
node.remove();
removeEmptyAndFill(parent);
} else {
fillEmpty(node);
}
};
removeEmptyAndFill(lastLeaf(beforeElement));
removeEmptyAndFill(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) {
this.dispatchTo("clean_handlers", element);
// const before = /** @type {HTMLElement} **/ (element.cloneNode());
/** @type {HTMLElement} **/
const before = element.cloneNode();
const after = /** @type {HTMLElement} **/ (element.cloneNode());
element.before(before);
element.after(after);
let index = 0;
for (const child of childNodes(element)) {
index < offset ? before.appendChild(child) : after.appendChild(child);
index++;
}
element.remove();
return [before, after];
}
/**
* 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,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, menuRef) {
if (!overlayState) {
return;
}
const state = useState(overlayState);
useEffect(
() => {
if (menuRef.el) {
if (!state.isOverlayVisible) {
menuRef.el.style.visibility = "hidden";
} else {
menuRef.el.style.visibility = "visible";
}
}
},
() => [state.isOverlayVisible]
);
}
+234
View File
@@ -0,0 +1,234 @@
import { MAIN_PLUGINS } from "./plugin_sets";
import { createBaseContainer } 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";
/**
* @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 } [baseContainer]
* @property { PluginConstructor[] } [Plugins]
* @property { boolean } [disableFloatingToolbar]
* @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.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;
if (this.config.content) {
editable.innerHTML = fixInvalidHTML(this.config.content);
if (isEmpty(editable)) {
const baseContainer = createBaseContainer(this.config.baseContainer, this.document);
fillShrunkPhrasingParent(baseContainer);
editable.replaceChildren(baseContainer);
}
}
this.preparePlugins();
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;
}
this.startPlugins();
this.config.onEditorReady?.();
}
preparePlugins() {
const Plugins = sortPlugins(this.config.Plugins || MAIN_PLUGINS);
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);
}
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;
}
}
@@ -0,0 +1,391 @@
import { HtmlUpgradeManager } from "@html_editor/html_migrations/html_upgrade_manager";
import { stripVersion } from "@html_editor/html_migrations/html_migrations_utils";
import { stripHistoryIds } from "@html_editor/others/collaboration/collaboration_odoo_plugin";
import {
COLLABORATION_PLUGINS,
DYNAMIC_PLACEHOLDER_PLUGINS,
EMBEDDED_COMPONENT_PLUGINS,
MAIN_PLUGINS,
} from "@html_editor/plugin_sets";
import {
MAIN_EMBEDDINGS,
READONLY_MAIN_EMBEDDINGS,
} from "@html_editor/others/embedded_components/embedding_sets";
import { normalizeHTML } from "@html_editor/utils/html";
import { Wysiwyg } from "@html_editor/wysiwyg";
import { Component, markup, status, useRef, useState } from "@odoo/owl";
import { localization } from "@web/core/l10n/localization";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { Mutex } from "@web/core/utils/concurrency";
import { useBus, useService } from "@web/core/utils/hooks";
import { useRecordObserver } from "@web/model/relational_model/utils";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { TranslationButton } from "@web/views/fields/translation_button";
import { HtmlViewer } from "./html_viewer";
import { withSequence } from "@html_editor/utils/resource";
import { fixInvalidHTML, instanceofMarkup } from "@html_editor/utils/sanitize";
const HTML_FIELD_METADATA_ATTRIBUTES = ["data-last-history-steps"];
/**
* Check whether the current value contains nodes that would break
* on insertion inside an existing body.
*
* @returns {boolean} true if 'this.props.value' contains a node
* that can only exist once per document.
*/
function computeContainsComplexHTML(value) {
const domParser = new DOMParser();
if (!value) {
return false;
}
const parsedOriginal = domParser.parseFromString(value, "text/html");
return !!parsedOriginal.head.innerHTML.trim();
}
export class HtmlField extends Component {
static template = "html_editor.HtmlField";
static props = {
...standardFieldProps,
isCollaborative: { type: Boolean, optional: true },
collaborativeTrigger: { type: String, optional: true },
dynamicPlaceholder: { type: Boolean, optional: true, default: false },
dynamicPlaceholderModelReferenceField: { type: String, optional: true },
cssReadonlyAssetId: { type: String, optional: true },
sandboxedPreview: { type: Boolean, optional: true },
codeview: { type: Boolean, optional: true },
editorConfig: { type: Object, optional: true },
embeddedComponents: { type: Boolean, optional: true },
};
static defaultProps = {
dynamicPlaceholder: false,
};
static components = {
Wysiwyg,
HtmlViewer,
TranslationButton,
};
setup() {
this.htmlUpgradeManager = new HtmlUpgradeManager();
this.mutex = new Mutex();
this.codeViewRef = useRef("codeView");
const { model } = this.props.record;
useBus(model.bus, "WILL_SAVE_URGENTLY", () => this.commitChanges({ urgent: true }));
useBus(model.bus, "NEED_LOCAL_CHANGES", ({ detail }) =>
detail.proms.push(this.commitChanges())
);
this.busService = this.env.services.bus_service;
this.ormService = useService("orm");
this.isDirty = false;
this.state = useState({
key: 0,
showCodeView: false,
containsComplexHTML: computeContainsComplexHTML(
this.props.record.data[this.props.name]
),
});
useRecordObserver((record) => {
// Reset Wysiwyg when we discard or onchange value
const newValue = fixInvalidHTML(record.data[this.props.name]);
if (!this.isDirty) {
const value = normalizeHTML(newValue, this.clearElementToCompare.bind(this));
if (this.lastValue !== value) {
this.state.key++;
this.state.containsComplexHTML = computeContainsComplexHTML(newValue);
this.lastValue = value;
}
}
});
useRecordObserver((record) => {
const value = record.data[this.props.dynamicPlaceholderModelReferenceField || "model"];
// update Dynamic Placeholder reference model
if (this.props.dynamicPlaceholder && this.editor) {
this.editor.shared.dynamicPlaceholder?.updateDphDefaultModel(value);
}
});
}
get value() {
const value = this.props.record.data[this.props.name];
const newVal = this.htmlUpgradeManager.processForUpgrade(fixInvalidHTML(value), {
containsComplexHTML: this.state.containsComplexHTML,
env: this.env,
});
if (instanceofMarkup(value)) {
return markup(newVal);
}
return newVal;
}
get displayReadonly() {
return this.props.readonly || (this.sandboxedPreview && !this.state.showCodeView);
}
get wysiwygKey() {
return `${this.props.record.resId}_${this.state.key}`;
}
get sandboxedPreview() {
// @todo @phoenix maybe remove containsComplexHTML and alway use sandboxedPreview options
return this.props.sandboxedPreview || this.state.containsComplexHTML;
}
get isTranslatable() {
return this.props.record.fields[this.props.name].translate;
}
clearElementToCompare(element) {
if (this.props.isCollaborative) {
stripHistoryIds(element);
}
stripVersion(element);
}
async updateValue(value) {
this.lastValue = normalizeHTML(value, this.clearElementToCompare.bind(this));
this.isDirty = false;
await this.props.record.update({ [this.props.name]: value }).catch(() => {
this.isDirty = true;
});
this.props.record.model.bus.trigger("FIELD_IS_DIRTY", this.isDirty);
}
async getEditorContent() {
const content = this.editor.getElContent();
const oldSrcToNewSrcMap = await this.editor.shared.media?.savePendingImages(content);
// Update the actual editable if still in the DOM.
if (this.editor.editable && oldSrcToNewSrcMap) {
this.editor.editable
.querySelectorAll('.o_b64_image_to_save, .o_modified_image_to_save')
.forEach((unsavedImage) => {
const oldSrc = unsavedImage.getAttribute('src');
if (oldSrcToNewSrcMap.has(oldSrc)) {
unsavedImage.setAttribute(
'src',
oldSrcToNewSrcMap.get(oldSrc)
);
}
unsavedImage.classList.remove("o_b64_image_to_save", "o_modified_image_to_save");
});
}
return content;
}
async _commitChanges({ urgent }) {
if (status(this) === "destroyed") {
return;
}
if (this.isDirty) {
if (this.state.showCodeView) {
await this.updateValue(this.codeViewRef.el.value);
return;
}
if (urgent) {
await this.updateValue(this.editor.getContent());
}
const el = await this.getEditorContent();
const content = el.innerHTML;
this.clearElementToCompare(el);
const comparisonValue = el.innerHTML;
if (!urgent || (urgent && this.lastValue !== comparisonValue)) {
await this.updateValue(content);
}
}
}
async commitChanges({ urgent } = {}) {
if (urgent) {
this._commitChanges({ urgent });
} else {
return this.mutex.exec(() => this._commitChanges({ urgent }));
}
}
onEditorLoad(editor) {
this.editor = editor;
}
onChange() {
this.isDirty = true;
this.props.record.model.bus.trigger("FIELD_IS_DIRTY", true);
}
onBlur() {
return this.commitChanges();
}
async toggleCodeView() {
await this.commitChanges();
this.state.showCodeView = !this.state.showCodeView;
if (!this.state.showCodeView && this.editor) {
this.editor.editable.innerHTML = this.value;
this.editor.shared.history.addStep();
}
}
getConfig() {
const config = {
content: this.value,
Plugins: [
...MAIN_PLUGINS,
...(this.props.isCollaborative ? COLLABORATION_PLUGINS : []),
...(this.props.dynamicPlaceholder ? DYNAMIC_PLACEHOLDER_PLUGINS : []),
...(this.props.embeddedComponents ? EMBEDDED_COMPONENT_PLUGINS : []),
],
classList: this.classList,
onChange: this.onChange.bind(this),
collaboration: this.props.isCollaborative && {
busService: this.busService,
ormService: this.ormService,
collaborativeTrigger: this.props.collaborativeTrigger,
collaborationChannel: {
collaborationModelName: this.props.record.resModel,
collaborationFieldName: this.props.name,
collaborationResId: parseInt(this.props.record.resId),
},
peerId: this.generateId(),
},
dropImageAsAttachment: true, // @todo @phoenix always true ?
dynamicPlaceholder: this.dynamicPlaceholder,
dynamicPlaceholderResModel:
this.props.record.data[this.props.dynamicPlaceholderModelReferenceField || "model"],
direction: localization.direction || "ltr",
getRecordInfo: () => {
const { resModel, resId } = this.props.record;
return { resModel, resId };
},
resources: {},
...this.props.editorConfig,
};
if (!("baseContainer" in config)) {
config.baseContainer = "DIV";
}
if (this.props.embeddedComponents) {
// TODO @engagement: fill this array with default/base components
config.resources.embedded_components = [...MAIN_EMBEDDINGS];
}
const { sanitize_tags, sanitize } = this.props.record.fields[this.props.name];
if (
!("disableVideo" in config) &&
(sanitize_tags || (sanitize_tags === undefined && sanitize))
) {
config.disableVideo = true; // Tag-sanitized fields remove videos.
}
if (this.props.codeview) {
config.resources = {
...config.resources,
user_commands: [
{
id: "codeview",
title: _t("Code view"),
icon: "fa-code",
run: this.toggleCodeView.bind(this),
},
],
toolbar_groups: withSequence(100, {
id: "codeview",
}),
toolbar_items: {
id: "codeview",
groupId: "codeview",
commandId: "codeview",
},
};
}
return config;
}
getReadonlyConfig() {
const config = {
value: this.value,
cssAssetId: this.props.cssReadonlyAssetId,
hasFullHtml: this.sandboxedPreview,
isFixedValue: true,
};
if (this.props.embeddedComponents) {
config.embeddedComponents = [...READONLY_MAIN_EMBEDDINGS];
}
return config;
}
generateId() {
// No need for secure random number.
return Math.floor(Math.random() * Math.pow(2, 52)).toString();
}
}
export const htmlField = {
component: HtmlField,
displayName: _t("Html"),
supportedTypes: ["html"],
extractProps({ attrs, options }, dynamicInfo) {
const editorConfig = {
mediaModalParams: {
useMediaLibrary: true,
},
};
if (attrs.placeholder) {
editorConfig.placeholder = attrs.placeholder;
}
if (options.height) {
editorConfig.height = `${options.height}px`;
editorConfig.classList = ["overflow-auto"];
}
if ("disableImage" in options) {
editorConfig.disableImage = Boolean(options.disableImage);
}
if ("disableVideo" in options) {
editorConfig.disableVideo = Boolean(options.disableVideo);
}
if ("disableFile" in options) {
editorConfig.disableFile = Boolean(options.disableFile);
}
if ("baseContainer" in options) {
editorConfig.baseContainer = options.baseContainer;
}
return {
editorConfig,
isCollaborative: options.collaborative,
collaborativeTrigger: options.collaborative_trigger,
dynamicPlaceholder: options.dynamic_placeholder,
dynamicPlaceholderModelReferenceField:
options.dynamic_placeholder_model_reference_field,
embeddedComponents:
"embedded_components" in options ? options.embedded_components : true,
sandboxedPreview: Boolean(options.sandboxedPreview),
cssReadonlyAssetId: options.cssReadonly,
codeview: Boolean(odoo.debug && options.codeview),
};
},
};
registry.category("fields").add("html", htmlField, { force: true });
export function getHtmlFieldMetadata(content) {
const metadata = {};
for (const attribute of HTML_FIELD_METADATA_ATTRIBUTES) {
const regex = new RegExp(`${attribute}\\s*=\\s*"([^"]+)"`);
metadata[attribute] = content.match(regex)?.[1];
}
return metadata;
}
export function setHtmlFieldMetadata(content, metadata) {
const htmlContent = content.toString() || "<div></div>";
const parser = new DOMParser();
const contentDocument = parser.parseFromString(htmlContent, "text/html");
for (const [attribute, value] of Object.entries(metadata)) {
if (value) {
contentDocument.body.firstChild.setAttribute(attribute, value);
}
}
return contentDocument.body.innerHTML;
}
@@ -0,0 +1,17 @@
textarea.o_codeview {
min-height: 400px;
}
.note-editable {
padding: 4px;
}
div.o_field_html {
.o_show_codeview span.o_field_translate {
right: 40px;
}
.o_field_translate .note-editable {
padding-right: 40px;
}
}
@@ -0,0 +1,32 @@
<templates xml:space="preserve">
<t t-name="html_editor.HtmlField">
<t t-if="this.displayReadonly">
<HtmlViewer
config="getReadonlyConfig()"/>
</t>
<div t-else="" class="h-100" t-att-class="{'o_show_codeview': state.showCodeView, 'o_field_translate': isTranslatable}">
<t t-if="state.showCodeView">
<textarea t-ref="codeView" class="o_codeview" t-att-value="this.value" t-on-change="onChange"/>
</t>
<t t-if="!this.sandboxedPreview">
<Wysiwyg
config="this.getConfig()"
onLoad.bind="onEditorLoad"
contentClass="`note-editable ${this.state.showCodeView ? 'd-none' : ''}`"
onBlur.bind="onBlur"
t-key="wysiwygKey"/>
</t>
<t t-if="isTranslatable">
<TranslationButton
fieldName="props.name"
record="props.record"
/>
</t>
</div>
<div t-if="state.showCodeView || (sandboxedPreview and !props.readonly)" t-ref="codeViewButton" id="codeview-btn-group" class="btn-group" t-on-click="toggleCodeView">
<button class="o_codeview_btn btn btn-primary">
<i class="fa fa-code" />
</button>
</div>
</t>
</templates>
@@ -0,0 +1,329 @@
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 { fillClipboardData } from "@html_editor/utils/clipboard";
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";
import { getDeepestPosition } from "@html_editor/utils/dom_info";
export class HtmlViewer extends Component {
static template = "html_editor.HtmlViewer";
static props = {
config: { type: Object },
};
static defaultProps = {
hasFullHtml: false,
};
setup() {
this._cleanups = [];
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(
() => {
if (this.readonlyElementRef.el) {
// Set innerHTML directly to render HTML instead of escaped text
const htmlContent = this.state.value || '';
this.readonlyElementRef.el.innerHTML = htmlContent;
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);
}
}
addDomListener(target, eventName, fn, capture = false) {
const handler = (ev) => {
fn?.call(this, ev);
};
target.addEventListener(eventName, handler, capture);
this._cleanups.push(() => target.removeEventListener(eventName, handler, capture));
}
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) {
if (this.props.config.isFixedValue) {
return value;
}
const newVal = this.htmlUpgradeManager.processForUpgrade(fixInvalidHTML(value), {
env: this.env,
});
if (instanceofMarkup(value)) {
return markup(newVal);
}
return newVal;
}
processReadonlyContent(container) {
this.retargetLinks(container);
this.applyAccessibilityAttributes(container);
this.addDomListener(container, "copy", this.onCopy);
}
/**
* @param {ClipboardEvent} ev
*/
onCopy(ev) {
ev.preventDefault();
const selection = ev.target.ownerDocument.defaultView.getSelection();
const [deepAnchorNode, deepAnchorOffset] = getDeepestPosition(
selection.anchorNode,
selection.anchorOffset
);
const [deepFocusNode, deepFocusOffset] = getDeepestPosition(
selection.focusNode,
selection.focusOffset
);
const range = new Range();
range.setStart(deepAnchorNode, deepAnchorOffset);
range.setEnd(deepFocusNode, deepFocusOffset);
const clonedContents = range.cloneContents();
fillClipboardData(ev, selection.toString(), clonedContents);
}
/**
* 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 cleanup of this._cleanups) {
cleanup();
}
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>
</t>
</templates>
@@ -0,0 +1,36 @@
import { registry } from "@web/core/registry";
export function htmlEditorVersions() {
return Object.keys(registry.category("html_editor_upgrade").subRegistries).sort(
compareVersions
);
}
export const VERSION_SELECTOR = "[data-oe-version]";
export function stripVersion(element) {
element.querySelectorAll(VERSION_SELECTOR).forEach((el) => {
delete el.dataset.oeVersion;
});
}
/**
* Compare 2 versions
*
* @param {string} version1
* @param {string} version2
* @returns {number} -1 if version1 < version2
* 0 if version1 === version2
* 1 if version1 > version2
*/
export function compareVersions(version1, version2) {
version1 = version1.split(".").map((v) => parseInt(v));
version2 = version2.split(".").map((v) => parseInt(v));
if (version1[0] < version2[0] || (version1[0] === version2[0] && version1[1] < version2[1])) {
return -1;
} else if (version1[0] === version2[0] && version1[1] === version2[1]) {
return 0;
} else {
return 1;
}
}
@@ -0,0 +1,96 @@
import {
compareVersions,
VERSION_SELECTOR,
htmlEditorVersions,
} from "@html_editor/html_migrations/html_migrations_utils";
import { registry } from "@web/core/registry";
import { fixInvalidHTML } from "@html_editor/utils/sanitize";
/**
* Handle HTML transformations dependent on the current implementation of the
* editor and its plugins for HtmlField values that were not upgraded through
* conventional means (python upgrade script), i.e. modify obsolete
* classes/style, convert deprecated Knowledge Behaviors to their
* EmbeddedComponent counterparts, ...
*
* How to use:
* - Create a file to export a `upgrade(element, env)` function which applies
* the necessary modifications inside `element` related to a specific version:
* - HTMLElement `element`: a container for the HtmlField value
* - Object `env`: the typical `owl` environment (can be used to check
* the current record data, use a service, ...).
* !!! ALWAYS assume that the `env` may not have the resource used in your
* upgrade function and adjust accordingly.
* - Refer to that file in the `html_editor_upgrade` registry, in the version
* category related to your change: `major.minor` (bump major for an IMP,
* minor for a FIX), in a sub-category related to your module.
* Example for the version 1.1 in `html_editor`:
* `registry
* .category("html_editor_upgrade")
* .category("1.1")
* .add("html_editor", "@html_editor/html_migrations/migration-1.1")`
*/
export class HtmlUpgradeManager {
constructor() {
this.upgradeRegistry = registry.category("html_editor_upgrade");
this.parser = new DOMParser();
this.originalValue = undefined;
this.upgradedValue = undefined;
this.element = undefined;
this.env = {};
}
get value() {
return this.upgradedValue;
}
processForUpgrade(value, { containsComplexHTML, env } = {}) {
this.env = env || {};
this.containsComplexHTML = containsComplexHTML;
const strValue = value.toString();
if (
strValue === this.originalValue?.toString() ||
strValue === this.upgradedValue?.toString()
) {
return this.value;
}
this.originalValue = value;
this.upgradedValue = value;
this.element = this.parser.parseFromString(fixInvalidHTML(value.toString()), "text/html")[
this.containsComplexHTML ? "documentElement" : "body"
];
const versionNode = this.element.querySelector(VERSION_SELECTOR);
const version = versionNode?.dataset.oeVersion || "0.0";
const VERSIONS = htmlEditorVersions();
const currentVersion = VERSIONS.at(-1);
if (!currentVersion || version === currentVersion) {
return this.value;
}
try {
const upgradeSequence = VERSIONS.filter((subVersion) => {
// skip already applied versions
return compareVersions(subVersion, version) > 0;
});
this.upgradedValue = this.upgrade(upgradeSequence);
} catch {
// If an upgrade fails, silently continue to use the raw value.
}
return this.value;
}
upgrade(upgradeSequence) {
for (const version of upgradeSequence) {
const modules = this.upgradeRegistry.category(version);
for (const [key, module] of modules.getEntries()) {
const upgrade = odoo.loader.modules.get(module).upgrade;
if (!upgrade) {
console.error(
`An "${key}" upgrade function could not be found at "${module}" or it did not load.`
);
}
upgrade(this.element, this.env);
}
}
return this.element[this.containsComplexHTML ? "outerHTML" : "innerHTML"];
}
}
@@ -0,0 +1,9 @@
import { registry } from "@web/core/registry";
const html_upgrade = registry.category("html_editor_upgrade");
// Remove the Excalidraw EmbeddedComponent and replace it with a link.
html_upgrade.category("1.1").add("html_editor", "@html_editor/html_migrations/migration-1.1");
// Fix Banner classes to properly handle `contenteditable` attribute
html_upgrade.category("1.2").add("html_editor", "@html_editor/html_migrations/migration-1.2");
@@ -0,0 +1,18 @@
/**
* Remove the Excalidraw EmbeddedComponent and replace it with a link
*
* @param {HTMLElement} container
* @param {Object} env
*/
export function upgrade(container) {
const excalidrawContainers = container.querySelectorAll("[data-embedded='draw']");
for (const excalidrawContainer of excalidrawContainers) {
const source = JSON.parse(excalidrawContainer.dataset.embeddedProps).source;
const newParagraph = document.createElement("P");
const anchor = document.createElement("A");
newParagraph.append(anchor);
anchor.append(document.createTextNode(source));
anchor.href = source;
excalidrawContainer.replaceWith(newParagraph);
}
}
@@ -0,0 +1,47 @@
import { _t } from "@web/core/l10n/translation";
const ARIA_LABELS = {
".o_editor_banner.alert-danger": _t("Banner Danger"),
".o_editor_banner.alert-info": _t("Banner Info"),
".o_editor_banner.alert-success": _t("Banner Success"),
".o_editor_banner.alert-warning": _t("Banner Warning"),
};
function getAriaLabel(element) {
for (const [selector, ariaLabel] of Object.entries(ARIA_LABELS)) {
if (element.matches(selector)) {
return ariaLabel;
}
}
}
/**
* Replace the `o_editable` and `o_not_editable` on `banner` elements by
* `o-contenteditable-true` and `o-content-editable-false`.
* Add `o_editor_banner_content` to the content parent element.
* Add accessibility editor-specific attributes (data-oe-role and
* data-oe-aria-label).
*
* @param {HTMLElement} container
*/
export function upgrade(container) {
const bannerContainers = container.querySelectorAll(".o_editor_banner");
for (const bannerContainer of bannerContainers) {
bannerContainer.classList.remove("o_not_editable");
bannerContainer.classList.add("o-contenteditable-false");
bannerContainer.dataset.oeRole = "status";
const icon = bannerContainer.querySelector(".o_editor_banner_icon");
if (icon) {
const ariaLabel = getAriaLabel(bannerContainer);
if (ariaLabel) {
icon.dataset.oeAriaLabel = ariaLabel;
}
}
const bannerContent = bannerContainer.querySelector(".o_editor_banner_icon ~ div");
if (bannerContent) {
bannerContent.classList.remove("o_editable");
bannerContent.classList.add("o_editor_banner_content");
bannerContent.classList.add("o-contenteditable-true");
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -0,0 +1,29 @@
import { Component } from "@odoo/owl";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { useForwardRefToParent } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { useRegistry } from "@web/core/registry_hook";
/**
* TODO ABD: refactor to propagate a reactive object instead of using a registry with an identifier
*/
export class LocalOverlayContainer extends MainComponentsContainer {
static template = "html_editor.LocalOverlayContainer";
static props = {
localOverlay: { type: Function, optional: true },
identifier: { type: String, optional: true },
};
static defaultProps = {
identifier: "overlay_components",
};
setup() {
const overlayComponents = registry.category(this.props.identifier);
overlayComponents.addValidation({
Component: { validate: (c) => c.prototype instanceof Component },
props: { type: Object, optional: true },
});
this.Components = useRegistry(overlayComponents);
useForwardRefToParent("localOverlay");
}
}
@@ -0,0 +1,14 @@
<templates xml:space="preserve">
<t t-name="html_editor.LocalOverlayContainer">
<div class="o-wysiwyg-local-overlay position-relative h-0 w-0" t-ref="localOverlay"/>
<div class="o-wysiwyg-local-overlay position-relative h-0 w-0">
<t t-foreach="Components.entries" t-as="C" t-key="C[0]">
<div class="oe-local-overlay" t-att-data-oe-local-overlay-id="C[0]">
<ErrorHandler onError="error => this.handleComponentError(error, C)">
<t t-component="C[1].Component" t-props="C[1].props"/>
</ErrorHandler>
</div>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,35 @@
import { Plugin } from "@html_editor/plugin";
import { closestBlock } from "@html_editor/utils/blocks";
import { isVisibleTextNode } from "@html_editor/utils/dom_info";
export class AlignPlugin extends Plugin {
static id = "align";
static dependencies = ["selection"];
resources = {
user_commands: [
{ id: "alignLeft", run: () => this.align("left") },
{ id: "alignRight", run: () => this.align("right") },
{ id: "alignCenter", run: () => this.align("center") },
{ id: "justify", run: () => this.align("justify") },
],
};
align(mode) {
const visitedBlocks = new Set();
const targetedNodes = this.dependencies.selection.getTargetedNodes();
for (const node of targetedNodes) {
if (isVisibleTextNode(node)) {
const block = closestBlock(node);
if (!visitedBlocks.has(block)) {
// todo @phoenix: check if it s correct in right to left ?
let textAlign = getComputedStyle(block).textAlign;
textAlign = textAlign === "start" ? "left" : textAlign;
if (textAlign !== mode && block.isContentEditable) {
block.style.textAlign = mode;
}
visitedBlocks.add(block);
}
}
}
}
}
@@ -0,0 +1,4 @@
.o_editor_banner .o-paragraph:last-child {
// Force margin to align the text container with the icon.
margin-bottom: 1rem !important;
}
@@ -0,0 +1,130 @@
import { Plugin } from "@html_editor/plugin";
import { fillShrunkPhrasingParent } from "@html_editor/utils/dom";
import { closestElement } from "@html_editor/utils/dom_traversal";
import { parseHTML } from "@html_editor/utils/html";
import { withSequence } from "@html_editor/utils/resource";
import { htmlEscape } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
function isAvailable(selection) {
return !closestElement(selection.anchorNode, ".o_editor_banner");
}
export class BannerPlugin extends Plugin {
static id = "banner";
// sanitize plugin is required to handle `contenteditable` attribute.
static dependencies = ["baseContainer", "history", "dom", "emoji", "selection", "sanitize"];
resources = {
user_commands: [
{
id: "banner_info",
title: _t("Banner Info"),
description: _t("Insert an info banner"),
icon: "fa-info-circle",
isAvailable,
run: () => {
this.insertBanner(_t("Banner Info"), "💡", "info");
},
},
{
id: "banner_success",
title: _t("Banner Success"),
description: _t("Insert a success banner"),
icon: "fa-check-circle",
isAvailable,
run: () => {
this.insertBanner(_t("Banner Success"), "✅", "success");
},
},
{
id: "banner_warning",
title: _t("Banner Warning"),
description: _t("Insert a warning banner"),
icon: "fa-exclamation-triangle",
isAvailable,
run: () => {
this.insertBanner(_t("Banner Warning"), "⚠️", "warning");
},
},
{
id: "banner_danger",
title: _t("Banner Danger"),
description: _t("Insert a danger banner"),
icon: "fa-exclamation-circle",
isAvailable,
run: () => {
this.insertBanner(_t("Banner Danger"), "❌", "danger");
},
},
],
powerbox_categories: withSequence(20, { id: "banner", name: _t("Banner") }),
powerbox_items: [
{
commandId: "banner_info",
categoryId: "banner",
},
{
commandId: "banner_success",
categoryId: "banner",
},
{
commandId: "banner_warning",
categoryId: "banner",
},
{
commandId: "banner_danger",
categoryId: "banner",
},
],
power_buttons_visibility_predicates: ({ anchorNode }) =>
!closestElement(anchorNode, ".o_editor_banner"),
};
setup() {
this.addDomListener(this.editable, "click", (e) => {
if (e.target.classList.contains("o_editor_banner_icon")) {
this.onBannerEmojiChange(e.target);
}
});
}
insertBanner(title, emoji, alertClass) {
const baseContainer = this.dependencies.baseContainer.createBaseContainer();
fillShrunkPhrasingParent(baseContainer);
const baseContainerHtml = baseContainer.outerHTML;
const bannerElement = parseHTML(
this.document,
`<div class="o_editor_banner user-select-none o-contenteditable-false lh-1 d-flex align-items-center alert alert-${alertClass} pb-0 pt-3" data-oe-role="status">
<i class="o_editor_banner_icon mb-3 fst-normal" data-oe-aria-label="${htmlEscape(
title
)}">${emoji}</i>
<div class="o_editor_banner_content o-contenteditable-true w-100 px-3">
${baseContainerHtml}
</div>
</div`
).childNodes[0];
this.dependencies.dom.insert(bannerElement);
// If the first child of editable is contenteditable false element
// a chromium bug prevents selecting the container.
// Add a baseContainer above it so it's no longer the first child.
if (this.editable.firstChild === bannerElement) {
const baseContainer = this.dependencies.baseContainer.createBaseContainer();
baseContainer.append(this.document.createElement("br"));
bannerElement.before(baseContainer);
}
const baseContainerName = this.dependencies.baseContainer.getDefaultNodeName();
this.dependencies.selection.setCursorStart(
bannerElement.querySelector(`.o_editor_banner_content > ${baseContainerName}`)
);
this.dependencies.history.addStep();
}
onBannerEmojiChange(iconElement) {
this.dependencies.emoji.showEmojiPicker({
target: iconElement,
onSelect: (emoji) => {
iconElement.textContent = emoji;
this.dependencies.history.addStep();
},
});
}
}
@@ -0,0 +1,137 @@
import { _t } from "@web/core/l10n/translation";
import { useState } from "@odoo/owl";
import { ChatGPTDialog } from "./chatgpt_dialog";
export const DEFAULT_ALTERNATIVES_MODES = {
correct: _t("Correct"),
short: _t("Shorten"),
long: _t("Lengthen"),
friendly: _t("Friendly"),
professional: _t("Professional"),
persuasive: _t("Persuasive"),
};
let messageId = 0;
let nextBatchId = 0;
export class ChatGPTAlternativesDialog extends ChatGPTDialog {
static template = "html_editor.ChatGPTAlternativesDialog";
static props = {
...super.props,
originalText: String,
alternativesModes: { type: Object, optional: true },
numberOfAlternatives: { type: Number, optional: true },
};
static defaultProps = {
...super.defaultProps,
alternativesModes: DEFAULT_ALTERNATIVES_MODES,
numberOfAlternatives: 3,
};
setup() {
super.setup();
this.state = useState({
...this.state,
conversationHistory: [
{
role: "system",
content:
"The user wrote the following text:\n" +
"<generated_text>" +
this.props.originalText +
"</generated_text>\n" +
"Your goal is to help the user write alternatives to that text.\n" +
"Conditions:\n" +
"- You must respect the format (wrapping the alternative between <generated_text> and </generated_text>)\n" +
"- You must detect the language of the text given to you and respond in that language\n" +
"- Do not write HTML\n" +
"- You must suggest one and only one alternative per answer\n" +
"- Your answer must be different every time, never repeat yourself\n" +
"- You must respect whatever extra conditions the user gives you\n",
},
],
messages: [],
alternativesMode: "",
messagesInProgress: 0,
currentBatchId: null,
});
this._generationIndex = 0;
this.generateAlternatives();
}
switchAlternativesMode(ev) {
this.state.alternativesMode = ev.currentTarget.getAttribute("data-mode");
this.generateAlternatives(1);
}
async generateAlternatives(numberOfAlternatives = this.props.numberOfAlternatives) {
this.state.messagesInProgress = numberOfAlternatives;
const batchId = nextBatchId++;
this.state.currentBatchId = batchId;
let wasError = false;
let messageIndex = 0;
while (
!wasError &&
messageIndex < numberOfAlternatives &&
this.state.currentBatchId === batchId
) {
this._generationIndex += 1;
let query = messageIndex
? "Write one alternative version of the original text."
: "Try again another single version of the original text.";
if (this.state.alternativesMode && !messageIndex) {
query += ` Make it more ${this.state.alternativesMode} than your last answer.`;
}
if (this.state.alternativesMode === "correct") {
query =
"Simply correct the text, without altering its meaning in any way. Preserve whatever language the user wrote their text in.";
}
await this.generate(query, (content, isError) => {
if (this.state.currentBatchId === batchId) {
const alternative = content
.replace(/^[\s\S]*<generated_text>/, "")
.replace(/<\/generated_text>[\s\S]*$/, "");
if (isError) {
wasError = true;
} else {
this.state.conversationHistory.push(
{
role: "user",
content: query,
},
{
role: "assistant",
content,
}
);
}
this.state.messages.push({
author: "assistant",
text: alternative,
isError,
batchId,
mode: this.state.alternativesMode,
id: messageId++,
});
}
}).catch(() => {
if (this.state.currentBatchId === batchId) {
wasError = true;
this.state.messages = [];
}
});
messageIndex += 1;
this.state.messagesInProgress -= 1;
if (wasError) {
break;
}
}
this.state.messagesInProgress = 0;
}
preventDialogMousedown(ev) {
// Prevent the default behavior of a mousedown event on the dialog
// itself so it doesn't cancel the user's text selection in the editor.
ev.preventDefault();
}
}
@@ -0,0 +1,56 @@
<templates id="template" xml:space="preserve">
<t t-name="html_editor.ChatGPTAlternativesDialog">
<Dialog size="'lg'" title.translate="AI Copywriter" t-on-mousedown="preventDialogMousedown">
<div class="md-8">
<div class="mb-3">
<t t-foreach="Object.entries(props.alternativesModes)"
t-as="alternative" t-key="alternative_index">
<button type="button" class="btn me-2 btn-sm btn-info"
t-att-class="state.alternativesMode == alternative[0] and state.messagesInProgress ? 'btn-success' : 'btn-info'"
t-on-click="switchAlternativesMode" t-att-data-mode="alternative[0]">
<t t-out="alternative[1]"/>
</button>
</t>
</div>
<div class="list-group">
<div t-if="state.messagesInProgress" class="d-flex align" t-att-class="{ 'mb-3': state.messages.length }">
<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 align-self-center">
<em t-if="state.messagesInProgress == 1">Generating an alternative...</em>
<em t-else="">Generating <t t-out="state.messagesInProgress"/> alternatives...</em>
</p>
</div>
<t t-foreach="[...state.messages].reverse()" t-as="message" t-key="message.id">
<t t-if="message.isError">
<div class="list-group-item o-chatgpt-alternative border-danger bg-danger o-message-error"
t-att-class="{ 'text-muted': state.currentBatchId != message.batchId }">
<t t-out="message.text"/>
</div>
</t>
<t t-else="">
<button type="button" class="list-group-item list-group-item-action o-chatgpt-alternative"
t-on-click="selectMessage"
t-att-data-message-id="message.id"
t-att-class="{
active: state.selectedMessageId == message.id,
'text-muted': state.selectedMessageId != message.id and state.currentBatchId != message.batchId,
}">
<span t-if="message.mode" class="badge bg-secondary float-end"><t t-out="props.alternativesModes[message.mode]"/></span>
<t t-out="formatContent(message.text)"/>
</button>
</t>
</t>
</div>
</div>
<!-- FOOTER -->
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="_confirm"
t-att-disabled="typeof state.selectedMessageId !== 'number'">Insert</button>
<button class="btn btn-secondary" t-on-click="_cancel">Cancel</button>
</t>
</Dialog>
</t>
</templates>

Some files were not shown because too many files have changed in this diff Show More